termix / src /modules /explorer /ExplorerSearch.tsx
lekmikdok's picture
Prepare Terax for web deployment
50fe3c9 verified
Raw
History Blame Contribute Delete
6.73 kB
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Cancel01Icon,
Folder01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { invoke } from "@tauri-apps/api/core";
import { motion } from "motion/react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { usePreferencesStore } from "@/modules/settings/preferences";
import { fileIconUrl } from "./lib/iconResolver";
type SearchHit = {
path: string;
rel: string;
name: string;
is_dir: boolean;
};
type SearchResult = {
hits: SearchHit[];
truncated: boolean;
};
const MIN_QUERY_LEN = 2;
const DEBOUNCE_MS = 300;
type Props = {
rootPath: string;
onOpenFile: (path: string) => void;
open: boolean;
onRequestClose: () => void;
onActiveChange?: (active: boolean) => void;
};
export type ExplorerSearchHandle = {
focus: () => void;
isFocused: () => boolean;
};
export const ExplorerSearch = forwardRef<ExplorerSearchHandle, Props>(function ExplorerSearch({
rootPath,
onOpenFile,
open,
onRequestClose,
onActiveChange,
}: Props,
ref,
) {
const showHidden = usePreferencesStore((s) => s.showHidden);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false);
const [truncated, setTruncated] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const active = query.trim().length > 0;
useEffect(() => {
onActiveChange?.(active);
}, [active, onActiveChange]);
useEffect(() => {
if (open) {
inputRef.current?.focus();
} else {
setQuery("");
setResults([]);
setSearching(false);
setTruncated(false);
}
}, [open]);
useEffect(() => {
const q = query.trim();
if (q.length < MIN_QUERY_LEN) {
setResults([]);
setSearching(false);
setTruncated(false);
return;
}
setSearching(true);
let alive = true;
const handle = setTimeout(async () => {
try {
const res = await invoke<SearchResult>("fs_search", {
root: rootPath,
query: q,
limit: 200,
showHidden,
});
if (alive) {
setResults(res.hits);
setTruncated(res.truncated);
}
} catch (e) {
if (alive) {
console.error("fs_search failed:", e);
setResults([]);
setTruncated(false);
}
} finally {
if (alive) setSearching(false);
}
}, DEBOUNCE_MS);
return () => {
alive = false;
clearTimeout(handle);
};
}, [query, rootPath, showHidden]);
useImperativeHandle(
ref,
() => ({
focus: () => {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
},
isFocused: () => document.activeElement === inputRef.current,
}),
[],
);
return (
<div className="flex flex-col">
{open ? (
<motion.div
className="relative shrink-0 px-2 py-1.5"
initial={{ opacity: 0, transform: "translateY(-15px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
>
<HugeiconsIcon
icon={Search01Icon}
size={13}
strokeWidth={2}
className="absolute top-1/2 left-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onRequestClose();
return;
}
if (e.key === "Enter") {
e.preventDefault();
const firstOpenable = results.find((hit) => !hit.is_dir);
if (firstOpenable) onOpenFile(firstOpenable.path);
}
}}
placeholder="Search files…"
className="h-7 pr-7 pl-6.5 text-xs"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute top-1/2 right-3.5 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Clear search"
>
<HugeiconsIcon icon={Cancel01Icon} size={11} strokeWidth={2} />
</button>
) : null}
</motion.div>
) : null}
{active ? (
<ScrollArea className="min-h-0 flex-1">
<div className="py-1">
{searching && results.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
Searching…
</div>
) : results.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
No matches
</div>
) : (
results.map((hit, index) => {
const url = hit.is_dir ? null : fileIconUrl(hit.name);
return (
<button
key={hit.path}
type="button"
onClick={() => {
if (!hit.is_dir) onOpenFile(hit.path);
}}
className={`flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs ${
index === 0 ? "bg-accent" : "hover:bg-accent"
}`}
title={hit.path}
>
{url ? (
<img src={url} alt="" className="size-3.5 shrink-0" />
) : (
<HugeiconsIcon
icon={Folder01Icon}
size={13}
strokeWidth={1.75}
className="shrink-0 text-muted-foreground"
/>
)}
<span className="truncate">{hit.name}</span>
<span className="ml-auto truncate text-[10px] text-muted-foreground">
{hit.rel}
</span>
</button>
);
})
)}
{truncated && results.length > 0 ? (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground">
Showing partial results — refine your query.
</div>
) : null}
</div>
</ScrollArea>
) : null}
</div>
);
});