import { useState, useRef, useEffect, useCallback } from "preact/hooks"; interface SearchResult { name: string; path: string; isDirectory: boolean; size: number; modified: string; } interface GlobalSearchProps { open: boolean; onClose: () => void; onNavigate: (path: string) => void; } function formatSize(bytes: number): string { if (bytes === 0) return "-"; const units = ["B", "KB", "MB", "GB"]; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function getIconType(name: string, isDirectory: boolean): string { if (isDirectory) return "folder"; const ext = name.split(".").pop()?.toLowerCase() || ""; const iconMap: Record = { js: "code", ts: "code", jsx: "code", tsx: "code", py: "code", rb: "code", go: "code", rs: "code", java: "code", c: "code", cpp: "code", h: "code", cs: "code", php: "code", swift: "code", kt: "code", vue: "code", svelte: "code", html: "web", htm: "web", css: "web", scss: "web", less: "web", json: "data", xml: "data", yaml: "data", yml: "data", csv: "data", sql: "data", toml: "data", md: "doc", txt: "doc", pdf: "doc", doc: "doc", docx: "doc", rtf: "doc", png: "image", jpg: "image", jpeg: "image", gif: "image", svg: "image", webp: "image", ico: "image", bmp: "image", zip: "archive", tar: "archive", gz: "archive", rar: "archive", "7z": "archive", bz2: "archive", env: "config", gitignore: "config", dockerignore: "config", editorconfig: "config", mp4: "media", mp3: "media", wav: "media", avi: "media", mkv: "media", }; return iconMap[ext] || "file"; } function ResultIcon({ type }: { type: string }) { const icons: Record = { folder: , file: , code: , image: , doc: , data: , web: , archive: , config: , media: , }; return icons[type] || icons.file; } function highlightMatch(text: string, query: string) { if (!query) return text; const queryLower = query.toLowerCase(); const textLower = text.toLowerCase(); const parts: any[] = []; let qi = 0; for (let ti = 0; ti < text.length; ti++) { if (qi < queryLower.length && textLower[ti] === queryLower[qi]) { parts.push({text[ti]}); qi++; } else { parts.push(text[ti]); } } return <>{parts}; } function getParentPath(path: string): string { // "/foo/bar/baz.txt" -> "foo/bar/" const withoutLeading = path.startsWith("/") ? path.slice(1) : path; const lastSlash = withoutLeading.lastIndexOf("/"); if (lastSlash === -1) return "/"; return withoutLeading.slice(0, lastSlash + 1); } export function GlobalSearch({ open, onClose, onNavigate }: GlobalSearchProps) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [selectedIdx, setSelectedIdx] = useState(0); const [loading, setLoading] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); const abortRef = useRef(null); const debounceRef = useRef | null>(null); // Focus input when opened useEffect(() => { if (open) { setQuery(""); setResults([]); setSelectedIdx(0); setTimeout(() => inputRef.current?.focus(), 0); } }, [open]); // Search with debounce const doSearch = useCallback((q: string) => { if (abortRef.current) abortRef.current.abort(); if (!q.trim()) { setResults([]); setSelectedIdx(0); setLoading(false); return; } setLoading(true); const controller = new AbortController(); abortRef.current = controller; fetch(`/__api/search?q=${encodeURIComponent(q.trim())}`, { signal: controller.signal }) .then((res) => res.json()) .then((data) => { if (!controller.signal.aborted) { setResults(data.results || []); setSelectedIdx(0); setLoading(false); } }) .catch((err) => { if (err.name !== "AbortError") { setLoading(false); } }); }, []); const handleInput = useCallback((value: string) => { setQuery(value); setSelectedIdx(0); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => doSearch(value), 120); }, [doSearch]); // Scroll selected item into view useEffect(() => { if (listRef.current) { const item = listRef.current.children[selectedIdx] as HTMLElement; if (item) item.scrollIntoView({ block: "nearest" }); } }, [selectedIdx]); const handleAction = useCallback((result: SearchResult) => { if (result.isDirectory) { onNavigate(result.path); onClose(); } else { // Download the file window.location.href = `/__download${result.path}`; onClose(); } }, [onNavigate, onClose]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); return; } if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((prev) => Math.max(prev - 1, 0)); return; } if (e.key === "Enter") { e.preventDefault(); const result = results[selectedIdx]; if (result) handleAction(result); return; } }, [results, selectedIdx, handleAction, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }}>
handleInput((e.target as HTMLInputElement).value)} onKeyDown={handleKeyDown as any} /> {loading && } Esc
{results.length > 0 && (
{results.map((result, i) => { const iconType = getIconType(result.name, result.isDirectory); const parentPath = getParentPath(result.path); return (
handleAction(result)} onMouseEnter={() => setSelectedIdx(i)} > {highlightMatch(result.name, query)} {parentPath} {!result.isDirectory && ( {formatSize(result.size)} )}
); })}
)} {query && !loading && results.length === 0 && (
No files matching "{query}"
)} {!query && (
Type to search all files and folders
)}
); }