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(function ExplorerSearch({ rootPath, onOpenFile, open, onRequestClose, onActiveChange, }: Props, ref, ) { const showHidden = usePreferencesStore((s) => s.showHidden); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [truncated, setTruncated] = useState(false); const inputRef = useRef(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("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 (
{open ? ( 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 ? ( ) : null} ) : null} {active ? (
{searching && results.length === 0 ? (
Searching…
) : results.length === 0 ? (
No matches
) : ( results.map((hit, index) => { const url = hit.is_dir ? null : fileIconUrl(hit.name); return ( ); }) )} {truncated && results.length > 0 ? (
Showing partial results — refine your query.
) : null}
) : null}
); });