feat: implement file search functionality in FileExplorerPage

This commit is contained in:
shiyu
2026-01-03 21:16:53 +08:00
parent 2fa93a1eeb
commit 35abd080be
5 changed files with 682 additions and 546 deletions

View File

@@ -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 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 .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; }
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }

View File

@@ -1,21 +1,8 @@
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination, message } from 'antd';
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { vfsApi, type SearchResultItem } from '../api/vfs';
import { type VfsEntry } from '../api/client';
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
import { Modal, Input, Flex, Segmented } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useState } from 'react';
import { useI18n } from '../i18n';
import { 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';
import { useLocation, useNavigate } from 'react-router';
interface SearchDialogProps {
open: boolean;
@@ -23,329 +10,32 @@ interface SearchDialogProps {
}
type SearchMode = 'vector' | 'filename';
const PAGE_SIZE = 10;
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
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 [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const requestIdRef = useRef(0);
const { t } = useI18n();
const navigate = useNavigate();
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const statCacheRef = useRef<Map<string, VfsEntry>>(new Map());
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 });
const location = useLocation();
const isOnFiles = location.pathname.startsWith('/files');
useEffect(() => {
if (!open) {
statCacheRef.current.clear();
setProcessorTypes([]);
closeContextMenu();
if (!open) return;
if (!isOnFiles) {
setSearch('');
setSearchMode('vector');
return;
}
let cancelled = false;
(async () => {
try {
const list = await processorsApi.list();
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 params = new URLSearchParams(location.search);
setSearch(params.get('q') || '');
setSearchMode(params.get('mode') === 'filename' ? 'filename' : 'vector');
}, [open, isOnFiles, location.search]);
const handleClose = useCallback(() => {
setSearch('');
setResults([]);
setSearched(false);
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();
}, [closeContextMenu, 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;
}, [onClose]);
return (
<Modal
@@ -375,7 +65,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
{ label: t('Name Search'), value: 'filename' },
]}
value={searchMode}
onChange={handleModeChange}
onChange={(value) => setSearchMode(value as SearchMode)}
style={{
minWidth: 160,
height: 40,
@@ -390,18 +80,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
prefix={<SearchOutlined />}
placeholder={t('Search files / tags / types')}
value={search}
onChange={e => {
const value = e.target.value;
setSearch(value);
if (!value.trim()) {
setResults([]);
setSearched(false);
setHasMore(false);
setPage(1);
requestIdRef.current += 1;
setLoading(false);
}
}}
onChange={e => setSearch(e.target.value)}
style={{ fontSize: 18, height: 40, flex: 1, minWidth: 240 }}
styles={{
input: {
@@ -409,197 +88,28 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
},
}}
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>
{!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>
{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>
);
};

View File

@@ -7,6 +7,7 @@ import { useFileActions } from './hooks/useFileActions.tsx';
import { useAppWindows } from '../../contexts/AppWindowsContext';
import { useContextMenu } from './hooks/useContextMenu';
import { useProcessor } from './hooks/useProcessor';
import { useFileSearch } from './hooks/useFileSearch';
import { useThumbnails } from './hooks/useThumbnails';
import { useUploader } from './hooks/useUploader';
import { Header } from './components/Header';
@@ -23,6 +24,7 @@ import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { FileDetailModal } from './components/FileDetailModal';
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
import { SearchResultsView } from './components/SearchResultsView';
import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client';
import { LoadingSkeleton } from './components/LoadingSkeleton';
@@ -39,12 +41,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
// --- Hooks ---
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
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 { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
const processorHook = useProcessor({ path, processorTypes, refresh });
const { thumbs } = useThumbnails(entries, path);
// --- State for Modals ---
@@ -58,6 +58,42 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const [movingEntries, setMovingEntries] = 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 ---
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
@@ -65,6 +101,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
const effectiveRefresh = useCallback(() => {
if (isSearching) {
refreshSearch();
return;
}
refresh();
}, [isSearching, refresh, refreshSearch]);
useEffect(() => {
if (skeletonTimerRef.current !== null) {
clearTimeout(skeletonTimerRef.current);
@@ -89,20 +133,43 @@ const FileExplorerPage = memo(function FileExplorerPage() {
}, [loading]);
// --- 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) => {
if (entry.is_dir) {
const next = (path === '/' ? '' : path) + '/' + entry.name;
const next = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
navigateTo(next.replace(/\/+/g, '/'));
} else {
openFileWithDefaultApp(entry, path);
}
openFileWithDefaultApp(entry, entryBasePath);
}
};
const openDetail = async (entry: VfsEntry) => {
setDetailEntry(entry);
setDetailLoading(true);
try {
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
const stat = await vfsApi.stat(fullPath);
setDetailData(stat as Record<string, unknown>);
} catch (error) {
@@ -116,17 +183,19 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
if (!targetEntries || targetEntries.length === 0) return '';
if (targetEntries.length > 1) {
return path || '/';
return entryBasePath || '/';
}
const entry = targetEntries[0];
const base = path === '/' ? '' : path;
const base = entryBasePath === '/' ? '' : entryBasePath;
const segments = [base, entry.name].filter(Boolean);
const joined = segments.join('/');
if (!joined) {
return '/';
}
return joined.startsWith('/') ? joined : `/${joined}`;
}, [path]);
}, [entryBasePath]);
const showFsPagination = !isSearching && pagination.total > 0;
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@@ -179,13 +248,13 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<Header
navKey={navKey}
path={path}
loading={loading}
loading={isSearching ? searchLoading : loading}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={refresh}
onRefresh={effectiveRefresh}
onCreateDir={() => setCreatingDir(true)}
onUploadFile={openFilePicker}
onUploadDirectory={openDirectoryPicker}
@@ -208,8 +277,22 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onChange={handleDirectoryInputChange}
/>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
{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} />
) : !loading && entries.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
@@ -239,14 +322,27 @@ const FileExplorerPage = memo(function FileExplorerPage() {
)}
</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 }}>
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
</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 --- */}
<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)} />
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
<MoveCopyModal
@@ -275,7 +371,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
/>
{sharingEntries.length > 0 && (
<ShareModal
path={path}
path={entryBasePath}
entries={sharingEntries}
open={sharingEntries.length > 0}
onOk={() => setSharingEntries([])}
@@ -284,7 +380,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
)}
<DirectLinkModal
entry={directLinkEntry}
path={path}
path={entryBasePath}
open={!!directLinkEntry}
onCancel={() => setDirectLinkEntry(null)}
/>
@@ -310,12 +406,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
x={ctxMenu?.x || blankCtxMenu!.x}
y={ctxMenu?.y || blankCtxMenu!.y}
entry={ctxMenu?.entry}
entries={entries}
selectedEntries={selectedEntries}
entries={isSearching ? searchContextEntries : entries}
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}
processorTypes={processorTypes}
onClose={closeContextMenus}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, entryBasePath)}
onDownload={doDownload}
onRename={setRenaming}
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}

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

View 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,
};
}