| 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> |
| ); |
| }); |
|
|