mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-04 22:01:56 +08:00
feat: implement file search functionality in FileExplorerPage
This commit is contained in:
@@ -38,6 +38,30 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
|
|||||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
|
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
|
||||||
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
|
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
|
||||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||||
|
.fx-grid-item .thumb .score-badge {
|
||||||
|
left:auto;
|
||||||
|
right:8px;
|
||||||
|
top:8px;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
border: 1px solid rgba(255,255,255,0.25);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: opacity .18s ease, transform .18s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.fx-grid-item:hover .thumb .score-badge,
|
||||||
|
.fx-grid-item.selected .thumb .score-badge {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination, message } from 'antd';
|
import { Modal, Input, Flex, Segmented } from 'antd';
|
||||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
|
||||||
import { type VfsEntry } from '../api/client';
|
|
||||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
import { useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
|
||||||
import { ContextMenu } from '../pages/FileExplorerPage/components/ContextMenu';
|
|
||||||
import { RenameModal } from '../pages/FileExplorerPage/components/Modals/RenameModal';
|
|
||||||
import { MoveCopyModal } from '../pages/FileExplorerPage/components/Modals/MoveCopyModal';
|
|
||||||
import { ShareModal } from '../pages/FileExplorerPage/components/Modals/ShareModal';
|
|
||||||
import { DirectLinkModal } from '../pages/FileExplorerPage/components/Modals/DirectLinkModal';
|
|
||||||
import { FileDetailModal } from '../pages/FileExplorerPage/components/FileDetailModal';
|
|
||||||
import { ProcessorModal } from '../pages/FileExplorerPage/components/Modals/ProcessorModal';
|
|
||||||
import { useFileActions } from '../pages/FileExplorerPage/hooks/useFileActions.tsx';
|
|
||||||
import { useProcessor } from '../pages/FileExplorerPage/hooks/useProcessor';
|
|
||||||
|
|
||||||
interface SearchDialogProps {
|
interface SearchDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -23,329 +10,32 @@ interface SearchDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SearchMode = 'vector' | 'filename';
|
type SearchMode = 'vector' | 'filename';
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
|
||||||
const [searched, setSearched] = useState(false);
|
|
||||||
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
|
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
const location = useLocation();
|
||||||
const statCacheRef = useRef<Map<string, VfsEntry>>(new Map());
|
const isOnFiles = location.pathname.startsWith('/files');
|
||||||
|
|
||||||
const [contextMenuState, setContextMenuState] = useState<{ entry: VfsEntry; x: number; y: number; path: string } | null>(null);
|
|
||||||
const [menuEntries, setMenuEntries] = useState<VfsEntry[]>([]);
|
|
||||||
const [selectedEntryNames, setSelectedEntryNames] = useState<string[]>([]);
|
|
||||||
const [currentPath, setCurrentPath] = useState<string>('/');
|
|
||||||
const [processorTypes, setProcessorTypes] = useState<ProcessorTypeMeta[]>([]);
|
|
||||||
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
|
|
||||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
|
||||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
|
||||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
|
||||||
const [detailData, setDetailData] = useState<any>(null);
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
|
||||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
|
||||||
|
|
||||||
const closeContextMenu = useCallback(() => {
|
|
||||||
setContextMenuState(null);
|
|
||||||
setMenuEntries([]);
|
|
||||||
setSelectedEntryNames([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const noop = useCallback(() => {}, []);
|
|
||||||
|
|
||||||
const handleShare = useCallback((entries: VfsEntry[]) => {
|
|
||||||
setSharingEntries(entries);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDirectLink = useCallback((entry: VfsEntry) => {
|
|
||||||
setDirectLinkEntry(entry);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { doDelete, doDownload, doRename, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
|
|
||||||
path: currentPath,
|
|
||||||
refresh: noop,
|
|
||||||
clearSelection: noop,
|
|
||||||
onShare: handleShare,
|
|
||||||
onGetDirectLink: handleDirectLink,
|
|
||||||
});
|
|
||||||
|
|
||||||
const processorHook = useProcessor({ path: currentPath, processorTypes, refresh: noop });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) return;
|
||||||
statCacheRef.current.clear();
|
if (!isOnFiles) {
|
||||||
setProcessorTypes([]);
|
setSearch('');
|
||||||
closeContextMenu();
|
setSearchMode('vector');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
const params = new URLSearchParams(location.search);
|
||||||
(async () => {
|
setSearch(params.get('q') || '');
|
||||||
try {
|
setSearchMode(params.get('mode') === 'filename' ? 'filename' : 'vector');
|
||||||
const list = await processorsApi.list();
|
}, [open, isOnFiles, location.search]);
|
||||||
if (!cancelled) {
|
|
||||||
setProcessorTypes(list);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (cancelled) return;
|
|
||||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
|
||||||
message.error(msg);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, closeContextMenu, t]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
setSearchMode('vector');
|
setSearchMode('vector');
|
||||||
setPage(1);
|
|
||||||
setHasMore(false);
|
|
||||||
requestIdRef.current = 0;
|
|
||||||
setLoading(false);
|
|
||||||
closeContextMenu();
|
|
||||||
setProcessorTypes([]);
|
|
||||||
setRenaming(null);
|
|
||||||
setSharingEntries([]);
|
|
||||||
setDirectLinkEntry(null);
|
|
||||||
setDetailEntry(null);
|
|
||||||
setDetailData(null);
|
|
||||||
setDetailLoading(false);
|
|
||||||
setMovingEntries([]);
|
|
||||||
setCopyingEntries([]);
|
|
||||||
setCurrentPath('/');
|
|
||||||
statCacheRef.current.clear();
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [closeContextMenu, onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const renderSourceLabel = (value?: string) => {
|
|
||||||
switch ((value || '').toLowerCase()) {
|
|
||||||
case 'vector':
|
|
||||||
return t('Vector Search');
|
|
||||||
case 'filename':
|
|
||||||
return t('Name Search');
|
|
||||||
case 'text':
|
|
||||||
return t('Text Chunk');
|
|
||||||
case 'image':
|
|
||||||
return t('Image Description');
|
|
||||||
default:
|
|
||||||
return t('Vector Search');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sourceColor = (value?: string) => {
|
|
||||||
switch ((value || '').toLowerCase()) {
|
|
||||||
case 'vector':
|
|
||||||
return 'blue';
|
|
||||||
case 'filename':
|
|
||||||
return 'green';
|
|
||||||
case 'image':
|
|
||||||
return 'volcano';
|
|
||||||
case 'text':
|
|
||||||
return 'geekblue';
|
|
||||||
default:
|
|
||||||
return 'purple';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFullPath = useCallback((entryName: string, basePath?: string) => {
|
|
||||||
const dir = basePath ?? currentPath;
|
|
||||||
const prefix = dir === '/' ? '' : dir;
|
|
||||||
const combined = `${prefix}/${entryName}`.replace(/\/{2,}/g, '/');
|
|
||||||
if (!combined) return '/';
|
|
||||||
return combined.startsWith('/') ? combined : `/${combined}`;
|
|
||||||
}, [currentPath]);
|
|
||||||
|
|
||||||
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
|
||||||
if (!targetEntries || targetEntries.length === 0) return '';
|
|
||||||
if (targetEntries.length > 1) {
|
|
||||||
return currentPath || '/';
|
|
||||||
}
|
|
||||||
const entry = targetEntries[0];
|
|
||||||
const base = currentPath === '/' ? '' : currentPath;
|
|
||||||
const segments = [base, entry.name].filter(Boolean);
|
|
||||||
const joined = segments.join('/');
|
|
||||||
if (!joined) return '/';
|
|
||||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
|
||||||
}, [currentPath]);
|
|
||||||
|
|
||||||
const openDetail = useCallback(async (entry: VfsEntry) => {
|
|
||||||
setDetailEntry(entry);
|
|
||||||
setDetailLoading(true);
|
|
||||||
try {
|
|
||||||
const stat = await vfsApi.stat(buildFullPath(entry.name));
|
|
||||||
setDetailData(stat);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
|
||||||
setDetailData({ error: msg });
|
|
||||||
message.error(msg);
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false);
|
|
||||||
}
|
|
||||||
}, [buildFullPath, t]);
|
|
||||||
|
|
||||||
const handleOpenEntry = useCallback((entry: VfsEntry) => {
|
|
||||||
const basePath = contextMenuState?.path ?? currentPath;
|
|
||||||
if (entry.is_dir) {
|
|
||||||
const next = buildFullPath(entry.name, basePath);
|
|
||||||
navigate(`/files${next === '/' ? '' : next}`, { state: { highlight: { name: entry.name } } });
|
|
||||||
closeContextMenu();
|
|
||||||
handleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openFileWithDefaultApp(entry, basePath);
|
|
||||||
closeContextMenu();
|
|
||||||
handleClose();
|
|
||||||
}, [buildFullPath, navigate, closeContextMenu, handleClose, openFileWithDefaultApp, currentPath, contextMenuState]);
|
|
||||||
|
|
||||||
const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise<VfsEntry | null> => {
|
|
||||||
const cached = statCacheRef.current.get(fullPath);
|
|
||||||
if (cached) {
|
|
||||||
return { ...cached, name: cached.name || defaultName };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stat = await vfsApi.stat(fullPath);
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
statCacheRef.current.set(fullPath, entry);
|
|
||||||
return entry;
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
|
||||||
message.error(msg);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const handleResultContextMenu = useCallback(async (event: React.MouseEvent, item: SearchResultItem) => {
|
|
||||||
event.preventDefault();
|
|
||||||
closeContextMenu();
|
|
||||||
const rawPath = (item.path || '').replace(/\/+$/, '');
|
|
||||||
if (!rawPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
||||||
const segments = normalizedPath.split('/').filter(Boolean);
|
|
||||||
const filename = segments.pop() || '';
|
|
||||||
const dir = segments.length ? `/${segments.join('/')}` : '/';
|
|
||||||
const entry = await ensureEntry(normalizedPath, filename);
|
|
||||||
if (!entry) return;
|
|
||||||
setCurrentPath(dir || '/');
|
|
||||||
setMenuEntries([entry]);
|
|
||||||
setSelectedEntryNames([entry.name]);
|
|
||||||
setContextMenuState({
|
|
||||||
entry,
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
path: dir || '/',
|
|
||||||
});
|
|
||||||
}, [closeContextMenu, ensureEntry]);
|
|
||||||
|
|
||||||
const performSearch = async (options?: { page?: number; mode?: SearchMode }) => {
|
|
||||||
const query = search.trim();
|
|
||||||
if (!query) {
|
|
||||||
setSearched(false);
|
|
||||||
setResults([]);
|
|
||||||
setHasMore(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMode = options?.mode ?? searchMode;
|
|
||||||
const targetPage = currentMode === 'filename' ? (options?.page ?? (currentMode === searchMode ? page : 1)) : 1;
|
|
||||||
|
|
||||||
const requestId = requestIdRef.current + 1;
|
|
||||||
requestIdRef.current = requestId;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
closeContextMenu();
|
|
||||||
setSearched(true);
|
|
||||||
if (currentMode === 'filename') {
|
|
||||||
setPage(targetPage);
|
|
||||||
} else {
|
|
||||||
setPage(1);
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await vfsApi.searchFiles(
|
|
||||||
query,
|
|
||||||
currentMode === 'filename' ? PAGE_SIZE : 10,
|
|
||||||
currentMode,
|
|
||||||
currentMode === 'filename' ? targetPage : undefined,
|
|
||||||
currentMode === 'filename' ? PAGE_SIZE : undefined,
|
|
||||||
);
|
|
||||||
if (requestId !== requestIdRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setResults(res.items);
|
|
||||||
if (currentMode === 'filename') {
|
|
||||||
const pagination = res.pagination;
|
|
||||||
setHasMore(Boolean(pagination?.has_more));
|
|
||||||
if (pagination?.page) {
|
|
||||||
setPage(pagination.page);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (requestId !== requestIdRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setResults([]);
|
|
||||||
if (currentMode === 'filename') {
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
if (!search.trim()) {
|
|
||||||
closeContextMenu();
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
setHasMore(false);
|
|
||||||
setPage(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void performSearch({ page: searchMode === 'filename' ? 1 : undefined });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModeChange = (value: string | number) => {
|
|
||||||
const nextMode = value as SearchMode;
|
|
||||||
setHasMore(false);
|
|
||||||
setPage(1);
|
|
||||||
setSearchMode(nextMode);
|
|
||||||
closeContextMenu();
|
|
||||||
if (search.trim()) {
|
|
||||||
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
|
||||||
} else {
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalItems = searchMode === 'filename'
|
|
||||||
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
|
||||||
: results.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -375,7 +65,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
{ label: t('Name Search'), value: 'filename' },
|
{ label: t('Name Search'), value: 'filename' },
|
||||||
]}
|
]}
|
||||||
value={searchMode}
|
value={searchMode}
|
||||||
onChange={handleModeChange}
|
onChange={(value) => setSearchMode(value as SearchMode)}
|
||||||
style={{
|
style={{
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -390,18 +80,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
placeholder={t('Search files / tags / types')}
|
placeholder={t('Search files / tags / types')}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => {
|
onChange={e => setSearch(e.target.value)}
|
||||||
const value = e.target.value;
|
|
||||||
setSearch(value);
|
|
||||||
if (!value.trim()) {
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
setHasMore(false);
|
|
||||||
setPage(1);
|
|
||||||
requestIdRef.current += 1;
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ fontSize: 18, height: 40, flex: 1, minWidth: 240 }}
|
style={{ fontSize: 18, height: 40, flex: 1, minWidth: 240 }}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
@@ -409,197 +88,28 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={() => {
|
||||||
|
const trimmed = search.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
if (isOnFiles) {
|
||||||
|
navigate(location.pathname);
|
||||||
|
}
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', trimmed);
|
||||||
|
params.set('mode', searchMode);
|
||||||
|
if (searchMode === 'filename') {
|
||||||
|
params.set('page', '1');
|
||||||
|
}
|
||||||
|
const targetPath = isOnFiles ? location.pathname : '/files';
|
||||||
|
navigate(`${targetPath}?${params.toString()}`);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{!searched ? null : (
|
|
||||||
<Flex vertical style={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<Divider style={{ margin: 0, padding: '0 0 12px' }}>{t('Search Results')}</Divider>
|
|
||||||
{loading ? (
|
|
||||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
|
||||||
<Spin />
|
|
||||||
</Flex>
|
|
||||||
) : results.length === 0 ? (
|
|
||||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
|
||||||
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingRight: 6 }}>
|
|
||||||
<List
|
|
||||||
itemLayout="horizontal"
|
|
||||||
dataSource={results}
|
|
||||||
split={false}
|
|
||||||
renderItem={item => {
|
|
||||||
const fullPath = item.path || '';
|
|
||||||
const trimmed = fullPath.replace(/\/+$/, '');
|
|
||||||
const parts = trimmed.split('/');
|
|
||||||
const filename = parts.pop() || '';
|
|
||||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
|
||||||
const snippet = item.snippet || '';
|
|
||||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
|
||||||
const retrievalLabel = renderSourceLabel(retrieval);
|
|
||||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List.Item
|
|
||||||
style={{ padding: '10px 12px', borderRadius: 6, background: '#fafafa', marginBottom: 8 }}
|
|
||||||
onContextMenu={(event) => { void handleResultContextMenu(event, item); }}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={<FileTextOutlined style={{ fontSize: 18, color: '#8c8c8c' }} />}
|
|
||||||
title={
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } });
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
style={{ fontSize: 16 }}
|
|
||||||
>
|
|
||||||
{fullPath}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
description={(
|
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
||||||
{snippet ? (
|
|
||||||
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
|
||||||
{snippet}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
<Space size={10} wrap>
|
|
||||||
{retrieval ? (
|
|
||||||
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
|
||||||
{retrievalLabel}
|
|
||||||
</Tag>
|
|
||||||
) : null}
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('Relevance')}: {scoreText}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{searchMode === 'filename' && results.length > 0 ? (
|
|
||||||
<Pagination
|
|
||||||
current={page}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
total={Math.max(totalItems, 1)}
|
|
||||||
showSizeChanger={false}
|
|
||||||
size="small"
|
|
||||||
style={{ marginTop: 12, textAlign: 'right' }}
|
|
||||||
onChange={(nextPage) => {
|
|
||||||
void performSearch({ page: nextPage });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
{contextMenuState ? (
|
|
||||||
<ContextMenu
|
|
||||||
x={contextMenuState.x}
|
|
||||||
y={contextMenuState.y}
|
|
||||||
entry={contextMenuState.entry}
|
|
||||||
entries={menuEntries}
|
|
||||||
selectedEntries={selectedEntryNames}
|
|
||||||
processorTypes={processorTypes}
|
|
||||||
onClose={closeContextMenu}
|
|
||||||
onOpen={handleOpenEntry}
|
|
||||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, contextMenuState?.path ?? currentPath)}
|
|
||||||
onDownload={doDownload}
|
|
||||||
onRename={setRenaming}
|
|
||||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
|
||||||
onDetail={openDetail}
|
|
||||||
onProcess={(entry, type) => {
|
|
||||||
processorHook.setSelectedProcessor(type);
|
|
||||||
processorHook.openProcessorModal(entry);
|
|
||||||
}}
|
|
||||||
onUploadFile={noop}
|
|
||||||
onUploadDirectory={noop}
|
|
||||||
onCreateDir={noop}
|
|
||||||
onShare={doShare}
|
|
||||||
onGetDirectLink={doGetDirectLink}
|
|
||||||
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
|
|
||||||
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<RenameModal
|
|
||||||
entry={renaming}
|
|
||||||
onOk={(entry, newName) => {
|
|
||||||
void doRename(entry, newName);
|
|
||||||
setRenaming(null);
|
|
||||||
}}
|
|
||||||
onCancel={() => setRenaming(null)}
|
|
||||||
/>
|
|
||||||
<FileDetailModal
|
|
||||||
entry={detailEntry}
|
|
||||||
loading={detailLoading}
|
|
||||||
data={detailData}
|
|
||||||
onClose={() => setDetailEntry(null)}
|
|
||||||
/>
|
|
||||||
<MoveCopyModal
|
|
||||||
mode="move"
|
|
||||||
entries={movingEntries}
|
|
||||||
open={movingEntries.length > 0}
|
|
||||||
defaultPath={buildDefaultDestination(movingEntries)}
|
|
||||||
onOk={async (destination) => {
|
|
||||||
if (movingEntries.length > 0) {
|
|
||||||
await doMove(movingEntries, destination);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={() => setMovingEntries([])}
|
|
||||||
/>
|
|
||||||
<MoveCopyModal
|
|
||||||
mode="copy"
|
|
||||||
entries={copyingEntries}
|
|
||||||
open={copyingEntries.length > 0}
|
|
||||||
defaultPath={buildDefaultDestination(copyingEntries)}
|
|
||||||
onOk={async (destination) => {
|
|
||||||
if (copyingEntries.length > 0) {
|
|
||||||
await doCopy(copyingEntries, destination);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={() => setCopyingEntries([])}
|
|
||||||
/>
|
|
||||||
{sharingEntries.length > 0 ? (
|
|
||||||
<ShareModal
|
|
||||||
path={currentPath}
|
|
||||||
entries={sharingEntries}
|
|
||||||
open={sharingEntries.length > 0}
|
|
||||||
onOk={() => setSharingEntries([])}
|
|
||||||
onCancel={() => setSharingEntries([])}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DirectLinkModal
|
|
||||||
entry={directLinkEntry}
|
|
||||||
path={currentPath}
|
|
||||||
open={!!directLinkEntry}
|
|
||||||
onCancel={() => setDirectLinkEntry(null)}
|
|
||||||
/>
|
|
||||||
<ProcessorModal
|
|
||||||
entry={processorHook.processorModal.entry}
|
|
||||||
visible={processorHook.processorModal.visible}
|
|
||||||
loading={processorHook.processorLoading}
|
|
||||||
processorTypes={processorTypes}
|
|
||||||
selectedProcessor={processorHook.selectedProcessor}
|
|
||||||
config={processorHook.processorConfig}
|
|
||||||
savingPath={processorHook.processorSavingPath}
|
|
||||||
overwrite={processorHook.processorOverwrite}
|
|
||||||
onOk={processorHook.handleProcessorOk}
|
|
||||||
onCancel={processorHook.handleProcessorCancel}
|
|
||||||
onSelectedProcessorChange={processorHook.setSelectedProcessor}
|
|
||||||
onConfigChange={processorHook.setProcessorConfig}
|
|
||||||
onSavingPathChange={processorHook.setProcessorSavingPath}
|
|
||||||
onOverwriteChange={processorHook.setProcessorOverwrite}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useFileActions } from './hooks/useFileActions.tsx';
|
|||||||
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||||
import { useContextMenu } from './hooks/useContextMenu';
|
import { useContextMenu } from './hooks/useContextMenu';
|
||||||
import { useProcessor } from './hooks/useProcessor';
|
import { useProcessor } from './hooks/useProcessor';
|
||||||
|
import { useFileSearch } from './hooks/useFileSearch';
|
||||||
import { useThumbnails } from './hooks/useThumbnails';
|
import { useThumbnails } from './hooks/useThumbnails';
|
||||||
import { useUploader } from './hooks/useUploader';
|
import { useUploader } from './hooks/useUploader';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
@@ -23,6 +24,7 @@ import { ShareModal } from './components/Modals/ShareModal';
|
|||||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||||
import { FileDetailModal } from './components/FileDetailModal';
|
import { FileDetailModal } from './components/FileDetailModal';
|
||||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||||
|
import { SearchResultsView } from './components/SearchResultsView';
|
||||||
import type { ViewMode } from './types';
|
import type { ViewMode } from './types';
|
||||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||||
@@ -39,12 +41,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
|
||||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
|
||||||
const { thumbs } = useThumbnails(entries, path);
|
const { thumbs } = useThumbnails(entries, path);
|
||||||
|
|
||||||
// --- State for Modals ---
|
// --- State for Modals ---
|
||||||
@@ -58,6 +58,42 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
const fileSearch = useFileSearch({
|
||||||
|
currentPath: path,
|
||||||
|
navigateTo,
|
||||||
|
openFileWithDefaultApp,
|
||||||
|
openContextMenu,
|
||||||
|
closeContextMenus,
|
||||||
|
activeEntry: ctxMenu?.entry ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSearching,
|
||||||
|
actionPath,
|
||||||
|
loading: searchLoading,
|
||||||
|
mode: searchMode,
|
||||||
|
query: searchQuery,
|
||||||
|
page: searchPage,
|
||||||
|
pageSize: searchPageSize,
|
||||||
|
displayItems: searchItems,
|
||||||
|
selectedPaths: searchSelectedPaths,
|
||||||
|
selectedNames: searchSelectedNames,
|
||||||
|
contextEntries: searchContextEntries,
|
||||||
|
entrySnapshot: searchEntrySnapshot,
|
||||||
|
showPagination: showSearchPagination,
|
||||||
|
totalItems: searchTotalItems,
|
||||||
|
clearSearchParams,
|
||||||
|
updateSearchPage,
|
||||||
|
refreshSearch,
|
||||||
|
openResult: openSearchResult,
|
||||||
|
selectResult: selectSearchResult,
|
||||||
|
openResultContextMenu: openSearchContextMenu,
|
||||||
|
clearSelection: clearSearchSelection,
|
||||||
|
} = fileSearch;
|
||||||
|
|
||||||
|
const entryBasePath = isSearching ? actionPath : path;
|
||||||
|
|
||||||
// --- Effects ---
|
// --- Effects ---
|
||||||
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||||
|
|
||||||
@@ -65,6 +101,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const effectiveRefresh = useCallback(() => {
|
||||||
|
if (isSearching) {
|
||||||
|
refreshSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}, [isSearching, refresh, refreshSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (skeletonTimerRef.current !== null) {
|
if (skeletonTimerRef.current !== null) {
|
||||||
clearTimeout(skeletonTimerRef.current);
|
clearTimeout(skeletonTimerRef.current);
|
||||||
@@ -89,20 +133,43 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
}, [loading]);
|
}, [loading]);
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
|
const clearAllSelection = useCallback(() => {
|
||||||
|
clearSelection();
|
||||||
|
clearSearchSelection();
|
||||||
|
}, [clearSearchSelection, clearSelection]);
|
||||||
|
|
||||||
|
const { doCreateDir: doCreateDirInCurrentDir } = useFileActions({
|
||||||
|
path,
|
||||||
|
refresh,
|
||||||
|
clearSelection,
|
||||||
|
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||||
|
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
|
||||||
|
path: entryBasePath,
|
||||||
|
refresh: effectiveRefresh,
|
||||||
|
clearSelection: clearAllSelection,
|
||||||
|
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||||
|
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||||
|
});
|
||||||
|
|
||||||
|
const processorHook = useProcessor({ path: entryBasePath, processorTypes, refresh: effectiveRefresh });
|
||||||
|
|
||||||
const handleOpenEntry = (entry: VfsEntry) => {
|
const handleOpenEntry = (entry: VfsEntry) => {
|
||||||
if (entry.is_dir) {
|
if (entry.is_dir) {
|
||||||
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
const next = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||||
navigateTo(next.replace(/\/+/g, '/'));
|
navigateTo(next.replace(/\/+/g, '/'));
|
||||||
} else {
|
} else {
|
||||||
openFileWithDefaultApp(entry, path);
|
openFileWithDefaultApp(entry, entryBasePath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (entry: VfsEntry) => {
|
const openDetail = async (entry: VfsEntry) => {
|
||||||
setDetailEntry(entry);
|
setDetailEntry(entry);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||||
const stat = await vfsApi.stat(fullPath);
|
const stat = await vfsApi.stat(fullPath);
|
||||||
setDetailData(stat as Record<string, unknown>);
|
setDetailData(stat as Record<string, unknown>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,17 +183,19 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
||||||
if (!targetEntries || targetEntries.length === 0) return '';
|
if (!targetEntries || targetEntries.length === 0) return '';
|
||||||
if (targetEntries.length > 1) {
|
if (targetEntries.length > 1) {
|
||||||
return path || '/';
|
return entryBasePath || '/';
|
||||||
}
|
}
|
||||||
const entry = targetEntries[0];
|
const entry = targetEntries[0];
|
||||||
const base = path === '/' ? '' : path;
|
const base = entryBasePath === '/' ? '' : entryBasePath;
|
||||||
const segments = [base, entry.name].filter(Boolean);
|
const segments = [base, entry.name].filter(Boolean);
|
||||||
const joined = segments.join('/');
|
const joined = segments.join('/');
|
||||||
if (!joined) {
|
if (!joined) {
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||||
}, [path]);
|
}, [entryBasePath]);
|
||||||
|
const showFsPagination = !isSearching && pagination.total > 0;
|
||||||
|
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -179,13 +248,13 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
<Header
|
<Header
|
||||||
navKey={navKey}
|
navKey={navKey}
|
||||||
path={path}
|
path={path}
|
||||||
loading={loading}
|
loading={isSearching ? searchLoading : loading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onGoUp={goUp}
|
onGoUp={goUp}
|
||||||
onNavigate={navigateTo}
|
onNavigate={navigateTo}
|
||||||
onRefresh={refresh}
|
onRefresh={effectiveRefresh}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onUploadFile={openFilePicker}
|
onUploadFile={openFilePicker}
|
||||||
onUploadDirectory={openDirectoryPicker}
|
onUploadDirectory={openDirectoryPicker}
|
||||||
@@ -208,8 +277,22 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onChange={handleDirectoryInputChange}
|
onChange={handleDirectoryInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||||
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
{isSearching ? (
|
||||||
|
<SearchResultsView
|
||||||
|
viewMode={viewMode}
|
||||||
|
loading={searchLoading}
|
||||||
|
mode={searchMode}
|
||||||
|
query={searchQuery}
|
||||||
|
items={searchItems}
|
||||||
|
selectedPaths={searchSelectedPaths}
|
||||||
|
entrySnapshot={searchEntrySnapshot}
|
||||||
|
onClearSearch={clearSearchParams}
|
||||||
|
onSelect={selectSearchResult}
|
||||||
|
onOpen={(fullPath) => { void openSearchResult(fullPath); }}
|
||||||
|
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
|
||||||
|
/>
|
||||||
|
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||||
<LoadingSkeleton mode={viewMode} />
|
<LoadingSkeleton mode={viewMode} />
|
||||||
) : !loading && entries.length === 0 ? (
|
) : !loading && entries.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||||
@@ -239,14 +322,27 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination.total > 0 && (
|
{showFsPagination && (
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||||
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
|
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSearchPagination && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||||
|
<Pagination
|
||||||
|
current={searchPage}
|
||||||
|
pageSize={searchPageSize}
|
||||||
|
total={Math.max(searchTotalItems, 1)}
|
||||||
|
showSizeChanger={false}
|
||||||
|
size="small"
|
||||||
|
onChange={(nextPage) => updateSearchPage(nextPage)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* --- Modals & Context Menus --- */}
|
{/* --- Modals & Context Menus --- */}
|
||||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDirInCurrentDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||||
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
||||||
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
||||||
<MoveCopyModal
|
<MoveCopyModal
|
||||||
@@ -275,7 +371,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
/>
|
/>
|
||||||
{sharingEntries.length > 0 && (
|
{sharingEntries.length > 0 && (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
path={path}
|
path={entryBasePath}
|
||||||
entries={sharingEntries}
|
entries={sharingEntries}
|
||||||
open={sharingEntries.length > 0}
|
open={sharingEntries.length > 0}
|
||||||
onOk={() => setSharingEntries([])}
|
onOk={() => setSharingEntries([])}
|
||||||
@@ -284,7 +380,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
)}
|
)}
|
||||||
<DirectLinkModal
|
<DirectLinkModal
|
||||||
entry={directLinkEntry}
|
entry={directLinkEntry}
|
||||||
path={path}
|
path={entryBasePath}
|
||||||
open={!!directLinkEntry}
|
open={!!directLinkEntry}
|
||||||
onCancel={() => setDirectLinkEntry(null)}
|
onCancel={() => setDirectLinkEntry(null)}
|
||||||
/>
|
/>
|
||||||
@@ -310,12 +406,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
x={ctxMenu?.x || blankCtxMenu!.x}
|
x={ctxMenu?.x || blankCtxMenu!.x}
|
||||||
y={ctxMenu?.y || blankCtxMenu!.y}
|
y={ctxMenu?.y || blankCtxMenu!.y}
|
||||||
entry={ctxMenu?.entry}
|
entry={ctxMenu?.entry}
|
||||||
entries={entries}
|
entries={isSearching ? searchContextEntries : entries}
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}
|
||||||
processorTypes={processorTypes}
|
processorTypes={processorTypes}
|
||||||
onClose={closeContextMenus}
|
onClose={closeContextMenus}
|
||||||
onOpen={handleOpenEntry}
|
onOpen={handleOpenEntry}
|
||||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, entryBasePath)}
|
||||||
onDownload={doDownload}
|
onDownload={doDownload}
|
||||||
onRename={setRenaming}
|
onRename={setRenaming}
|
||||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||||
|
|||||||
192
web/src/pages/FileExplorerPage/components/SearchResultsView.tsx
Normal file
192
web/src/pages/FileExplorerPage/components/SearchResultsView.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Empty, Flex, Spin, Tag, Typography, theme } from 'antd';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
import type { VfsEntry } from '../../../api/client';
|
||||||
|
import type { ViewMode } from '../types';
|
||||||
|
import type { SearchDisplayItem, SearchMode } from '../hooks/useFileSearch';
|
||||||
|
|
||||||
|
interface SearchResultsViewProps {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
loading: boolean;
|
||||||
|
mode: SearchMode;
|
||||||
|
query: string;
|
||||||
|
items: SearchDisplayItem[];
|
||||||
|
selectedPaths: string[];
|
||||||
|
entrySnapshot: Record<string, VfsEntry>;
|
||||||
|
onClearSearch: () => void;
|
||||||
|
onSelect: (fullPath: string, additive: boolean) => void;
|
||||||
|
onOpen: (fullPath: string) => void;
|
||||||
|
onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
|
||||||
|
viewMode,
|
||||||
|
loading,
|
||||||
|
mode,
|
||||||
|
query,
|
||||||
|
items,
|
||||||
|
selectedPaths,
|
||||||
|
entrySnapshot,
|
||||||
|
onClearSearch,
|
||||||
|
onSelect,
|
||||||
|
onOpen,
|
||||||
|
onContextMenu,
|
||||||
|
}) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const renderSourceLabel = (value?: string) => {
|
||||||
|
switch ((value || '').toLowerCase()) {
|
||||||
|
case 'vector':
|
||||||
|
return t('Vector Search');
|
||||||
|
case 'filename':
|
||||||
|
return t('Name Search');
|
||||||
|
case 'text':
|
||||||
|
return t('Text Chunk');
|
||||||
|
case 'image':
|
||||||
|
return t('Image Description');
|
||||||
|
default:
|
||||||
|
return t('Vector Search');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceColor = (value?: string) => {
|
||||||
|
switch ((value || '').toLowerCase()) {
|
||||||
|
case 'vector':
|
||||||
|
return 'blue';
|
||||||
|
case 'filename':
|
||||||
|
return 'green';
|
||||||
|
case 'image':
|
||||||
|
return 'volcano';
|
||||||
|
case 'text':
|
||||||
|
return 'geekblue';
|
||||||
|
default:
|
||||||
|
return 'purple';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSnippet = (rawSnippet: string | undefined, name: string, fullPath: string) => {
|
||||||
|
const snippet = (rawSnippet || '').trim();
|
||||||
|
if (!snippet) return '';
|
||||||
|
if (snippet === name) return '';
|
||||||
|
if (snippet === fullPath) return '';
|
||||||
|
if (snippet === fullPath.replace(/^\/+/, '')) return '';
|
||||||
|
return snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Typography.Text strong>{t('Search Results')}</Typography.Text>
|
||||||
|
<Tag color={mode === 'filename' ? 'green' : 'blue'}>
|
||||||
|
{mode === 'filename' ? t('Name Search') : t('Smart Search')}
|
||||||
|
</Tag>
|
||||||
|
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
|
||||||
|
{query}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||||
|
<Spin />
|
||||||
|
</Flex>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||||
|
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</Flex>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<div
|
||||||
|
className="fx-grid"
|
||||||
|
style={{ padding: 0, gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{items.map(({ item, fullPath, dir, name }) => {
|
||||||
|
const selected = selectedPaths.includes(fullPath);
|
||||||
|
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||||
|
const isDir = Boolean(entrySnapshot[fullPath]?.is_dir);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fullPath}
|
||||||
|
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
|
||||||
|
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||||
|
onDoubleClick={() => onOpen(fullPath)}
|
||||||
|
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||||
|
<span className="badge score-badge">{scoreText}</span>
|
||||||
|
{isDir
|
||||||
|
? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text>
|
||||||
|
: <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
|
||||||
|
</div>
|
||||||
|
<div className="name ellipsis">{name}</div>
|
||||||
|
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||||
|
{dir}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{items.map(({ item, fullPath, name }) => {
|
||||||
|
const selected = selectedPaths.includes(fullPath);
|
||||||
|
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||||
|
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||||
|
const snippet = normalizeSnippet(item.snippet, name, fullPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fullPath}
|
||||||
|
className={selected ? 'row-selected' : ''}
|
||||||
|
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||||
|
onDoubleClick={() => onOpen(fullPath)}
|
||||||
|
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
background: token.colorFillTertiary,
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex vertical style={{ gap: 6 }}>
|
||||||
|
<Typography.Text strong className="ellipsis">
|
||||||
|
{name}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||||
|
{fullPath}
|
||||||
|
</Typography.Text>
|
||||||
|
{snippet ? (
|
||||||
|
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
||||||
|
{snippet}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{retrieval ? (
|
||||||
|
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
||||||
|
{renderSourceLabel(retrieval)}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
<Tag
|
||||||
|
style={{
|
||||||
|
marginRight: 0,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderColor: token.colorBorderSecondary,
|
||||||
|
color: token.colorText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scoreText}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
314
web/src/pages/FileExplorerPage/hooks/useFileSearch.ts
Normal file
314
web/src/pages/FileExplorerPage/hooks/useFileSearch.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
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 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,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user