mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-10 17:43:35 +08:00
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
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<SearchResultItem[]>([]);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const requestIdRef = useRef(0);
|
|
|
|
const entryCacheRef = useRef<Map<string, VfsEntry>>(new Map());
|
|
const [entrySnapshot, setEntrySnapshot] = useState<Record<string, VfsEntry>>({});
|
|
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
|
const [actionPath, setActionPath] = useState<string>('/');
|
|
|
|
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<VfsEntry> => {
|
|
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<string, SearchDisplayItem>();
|
|
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<string, VfsEntry>();
|
|
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,
|
|
};
|
|
}
|