import { message } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { vfsApi, type VfsEntry } from '../../../api/client'; import type { SearchResultItem } from '../../../api/vfs'; import { useI18n } from '../../../i18n'; export type SearchMode = 'vector' | 'filename'; export type SearchDisplayItem = { item: SearchResultItem; fullPath: string; dir: string; name: string; }; const PAGE_SIZE = 10; interface UseFileSearchParams { currentPath: string; navigateTo: (path: string) => void; openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void; openContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void; closeContextMenus: () => void; activeEntry?: VfsEntry | null; } export function useFileSearch({ currentPath, navigateTo, openFileWithDefaultApp, openContextMenu, closeContextMenus, activeEntry, }: UseFileSearchParams) { const location = useLocation(); const navigate = useNavigate(); const { t } = useI18n(); const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); const query = (searchParams.get('q') || '').trim(); const mode = (searchParams.get('mode') === 'filename' ? 'filename' : 'vector') as SearchMode; const page = Math.max(1, Number(searchParams.get('page') || '1') || 1); const isSearching = Boolean(query); const [loading, setLoading] = useState(false); const [results, setResults] = useState([]); const [hasMore, setHasMore] = useState(false); const requestIdRef = useRef(0); const entryCacheRef = useRef>(new Map()); const [entrySnapshot, setEntrySnapshot] = useState>({}); const [selectedPaths, setSelectedPaths] = useState([]); const [actionPath, setActionPath] = useState('/'); const normalizeFullPath = useCallback((fullPath: string) => { const raw = (fullPath || '').replace(/\/+$/, ''); if (!raw) return '/'; const leading = raw.startsWith('/') ? raw : `/${raw}`; return leading.replace(/\/{2,}/g, '/'); }, []); const splitPath = useCallback((fullPath: string) => { const normalized = normalizeFullPath(fullPath); const parts = normalized.split('/').filter(Boolean); const name = parts.pop() || ''; const dir = parts.length > 0 ? `/${parts.join('/')}` : '/'; return { fullPath: normalized, dir, name }; }, [normalizeFullPath]); const runSearch = useCallback(async (requestId: number) => { try { const res = await vfsApi.searchFiles( query, mode === 'filename' ? PAGE_SIZE : 10, mode, mode === 'filename' ? page : undefined, mode === 'filename' ? PAGE_SIZE : undefined, ); if (requestId !== requestIdRef.current) return; setResults(res.items || []); if (mode === 'filename') { setHasMore(Boolean(res.pagination?.has_more)); } else { setHasMore(false); } } catch (e) { if (requestId !== requestIdRef.current) return; const msg = e instanceof Error ? e.message : t('Load failed'); message.error(msg); setResults([]); setHasMore(false); } finally { if (requestId === requestIdRef.current) { setLoading(false); } } }, [mode, page, query, t]); useEffect(() => { if (!isSearching) { setResults([]); setHasMore(false); setLoading(false); requestIdRef.current += 1; setSelectedPaths([]); entryCacheRef.current.clear(); setEntrySnapshot({}); return; } const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setLoading(true); setSelectedPaths([]); closeContextMenus(); void runSearch(requestId); }, [closeContextMenus, isSearching, runSearch]); const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise => { const normalized = normalizeFullPath(fullPath); const cached = entryCacheRef.current.get(normalized); if (cached) { return { ...cached, name: cached.name || defaultName }; } try { const stat = await vfsApi.stat(normalized); const entry: VfsEntry = { name: (stat as any)?.name || defaultName, is_dir: Boolean((stat as any)?.is_dir), size: Number((stat as any)?.size ?? 0), mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0), type: (stat as any)?.type, has_thumbnail: Boolean((stat as any)?.has_thumbnail), }; entryCacheRef.current.set(normalized, entry); setEntrySnapshot(prev => ({ ...prev, [normalized]: entry })); return entry; } catch { const fallback: VfsEntry = { name: defaultName, is_dir: false, size: 0, mtime: 0 }; entryCacheRef.current.set(normalized, fallback); setEntrySnapshot(prev => ({ ...prev, [normalized]: fallback })); return fallback; } }, [normalizeFullPath]); const refreshSearch = useCallback(() => { if (!isSearching) return; const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setLoading(true); void runSearch(requestId); }, [isSearching, runSearch]); useEffect(() => { if (!isSearching) { setActionPath(currentPath || '/'); return; } if (actionPath === '/' && currentPath) { setActionPath(currentPath); } }, [actionPath, currentPath, isSearching]); const displayItems = useMemo(() => { if (!isSearching) return []; return (results || []).map((item) => { const { fullPath, dir, name } = splitPath(item.path || ''); return { item, fullPath, dir, name }; }).filter(it => it.fullPath && it.fullPath !== '/' && it.name); }, [isSearching, results, splitPath]); const itemByPath = useMemo(() => { const map = new Map(); for (const it of displayItems) { map.set(it.fullPath, it); } return map; }, [displayItems]); useEffect(() => { if (!isSearching) return; selectedPaths.forEach((p) => { const info = itemByPath.get(p); if (info) { void ensureEntry(info.fullPath, info.name); } }); }, [ensureEntry, isSearching, itemByPath, selectedPaths]); const updateSearchPage = useCallback((nextPage: number) => { const params = new URLSearchParams(location.search); if (nextPage <= 1) params.delete('page'); else params.set('page', String(nextPage)); const next = params.toString(); navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true }); }, [location.pathname, location.search, navigate]); const clearSearchParams = useCallback(() => { const params = new URLSearchParams(location.search); params.delete('q'); params.delete('mode'); params.delete('page'); const next = params.toString(); navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true }); }, [location.pathname, location.search, navigate]); const openResult = useCallback(async (fullPath: string) => { const info = itemByPath.get(fullPath); if (!info) return; setActionPath(info.dir); const entry = await ensureEntry(info.fullPath, info.name); if (entry.is_dir) { navigateTo(info.fullPath); return; } openFileWithDefaultApp(entry, info.dir); }, [ensureEntry, itemByPath, navigateTo, openFileWithDefaultApp]); const selectResult = useCallback((fullPath: string, additive: boolean) => { const info = itemByPath.get(fullPath); if (!info) return; if (actionPath !== info.dir) { setActionPath(info.dir); setSelectedPaths([fullPath]); return; } if (!additive) { setSelectedPaths([fullPath]); return; } setSelectedPaths((prev) => { const exists = prev.includes(fullPath); return exists ? prev.filter(p => p !== fullPath) : [...prev, fullPath]; }); }, [actionPath, itemByPath]); const openResultContextMenu = useCallback(async (e: React.MouseEvent, fullPath: string) => { e.preventDefault(); e.stopPropagation(); const info = itemByPath.get(fullPath); if (!info) return; setActionPath(info.dir); setSelectedPaths((prev) => { if (actionPath !== info.dir) { return [fullPath]; } return prev.includes(fullPath) ? prev : [fullPath]; }); const entry = await ensureEntry(info.fullPath, info.name); openContextMenu(e, entry); }, [actionPath, ensureEntry, itemByPath, openContextMenu]); const openResultContextMenuAt = useCallback(async (x: number, y: number, fullPath: string) => { const info = itemByPath.get(fullPath); if (!info) return; setActionPath(info.dir); setSelectedPaths((prev) => { if (actionPath !== info.dir) { return [fullPath]; } return prev.includes(fullPath) ? prev : [fullPath]; }); const entry = await ensureEntry(info.fullPath, info.name); openContextMenu({ preventDefault() {}, clientX: x, clientY: y } as React.MouseEvent, entry); }, [actionPath, ensureEntry, itemByPath, openContextMenu]); const selectedNames = useMemo(() => { const names: string[] = []; for (const p of selectedPaths) { const info = itemByPath.get(p); if (info && info.dir === actionPath) { names.push(info.name); } } return names; }, [actionPath, itemByPath, selectedPaths]); const contextEntries = useMemo(() => { if (!isSearching) return []; const map = new Map(); for (const p of selectedPaths) { const info = itemByPath.get(p); if (!info || info.dir !== actionPath) continue; const cached = entrySnapshot[info.fullPath]; map.set(info.name, cached || { name: info.name, is_dir: false, size: 0, mtime: 0 }); } if (activeEntry) { map.set(activeEntry.name, activeEntry); } return Array.from(map.values()); }, [actionPath, activeEntry, entrySnapshot, isSearching, itemByPath, selectedPaths]); const totalItems = useMemo(() => { if (mode !== 'filename') return results.length; if (hasMore) return page * PAGE_SIZE + 1; return (page - 1) * PAGE_SIZE + results.length; }, [hasMore, mode, page, results.length]); const showPagination = isSearching && mode === 'filename' && results.length > 0; const clearSelection = useCallback(() => setSelectedPaths([]), []); return { isSearching, query, mode, page, pageSize: PAGE_SIZE, loading, displayItems, selectedPaths, selectedNames, contextEntries, entrySnapshot, actionPath, showPagination, totalItems, clearSearchParams, updateSearchPage, refreshSearch, openResult, selectResult, openResultContextMenu, openResultContextMenuAt, clearSelection, }; }