From 35abd080be2c82c49afc9a72f3dbf09fde23ea01 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 3 Jan 2026 21:16:53 +0800 Subject: [PATCH] feat: implement file search functionality in FileExplorerPage --- web/src/global.css | 24 + web/src/layout/SearchDialog.tsx | 562 ++---------------- .../FileExplorerPage/FileExplorerPage.tsx | 136 ++++- .../components/SearchResultsView.tsx | 192 ++++++ .../FileExplorerPage/hooks/useFileSearch.ts | 314 ++++++++++ 5 files changed, 682 insertions(+), 546 deletions(-) create mode 100644 web/src/pages/FileExplorerPage/components/SearchResultsView.tsx create mode 100644 web/src/pages/FileExplorerPage/hooks/useFileSearch.ts diff --git a/web/src/global.css b/web/src/global.css index 01aed00..bbca890 100644 --- a/web/src/global.css +++ b/web/src/global.css @@ -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; } diff --git a/web/src/layout/SearchDialog.tsx b/web/src/layout/SearchDialog.tsx index 0e0dd9c..91592e8 100644 --- a/web/src/layout/SearchDialog.tsx +++ b/web/src/layout/SearchDialog.tsx @@ -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 = ({ open, onClose }) => { const [search, setSearch] = useState(''); - const [loading, setLoading] = useState(false); - const [results, setResults] = useState([]); - const [searched, setSearched] = useState(false); const [searchMode, setSearchMode] = useState('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>(new Map()); - - const [contextMenuState, setContextMenuState] = useState<{ entry: VfsEntry; x: number; y: number; path: string } | null>(null); - const [menuEntries, setMenuEntries] = useState([]); - const [selectedEntryNames, setSelectedEntryNames] = useState([]); - const [currentPath, setCurrentPath] = useState('/'); - const [processorTypes, setProcessorTypes] = useState([]); - const [renaming, setRenaming] = useState(null); - const [sharingEntries, setSharingEntries] = useState([]); - const [directLinkEntry, setDirectLinkEntry] = useState(null); - const [detailEntry, setDetailEntry] = useState(null); - const [detailData, setDetailData] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [movingEntries, setMovingEntries] = useState([]); - const [copyingEntries, setCopyingEntries] = useState([]); - - 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 => { - 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 ( = ({ 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 = ({ open, onClose }) => { prefix={} 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 = ({ 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(); + }} /> - - {!searched ? null : ( - - {t('Search Results')} - {loading ? ( - - - - ) : results.length === 0 ? ( - - - - ) : ( -
-
- { - 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 ( - { void handleResultContextMenu(event, item); }} - > - } - title={ - { - navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } }); - handleClose(); - }} - style={{ fontSize: 16 }} - > - {fullPath} - - } - description={( - - {snippet ? ( - - {snippet} - - ) : null} - - {retrieval ? ( - - {retrievalLabel} - - ) : null} - - {t('Relevance')}: {scoreText} - - - - )} - /> - - ); - }} - /> -
- {searchMode === 'filename' && results.length > 0 ? ( - { - void performSearch({ page: nextPage }); - }} - /> - ) : null} -
- )} -
- )} - {contextMenuState ? ( - 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} - { - void doRename(entry, newName); - setRenaming(null); - }} - onCancel={() => setRenaming(null)} - /> - setDetailEntry(null)} - /> - 0} - defaultPath={buildDefaultDestination(movingEntries)} - onOk={async (destination) => { - if (movingEntries.length > 0) { - await doMove(movingEntries, destination); - } - }} - onCancel={() => setMovingEntries([])} - /> - 0} - defaultPath={buildDefaultDestination(copyingEntries)} - onOk={async (destination) => { - if (copyingEntries.length > 0) { - await doCopy(copyingEntries, destination); - } - }} - onCancel={() => setCopyingEntries([])} - /> - {sharingEntries.length > 0 ? ( - 0} - onOk={() => setSharingEntries([])} - onCancel={() => setSharingEntries([])} - /> - ) : null} - setDirectLinkEntry(null)} - /> -
); }; diff --git a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx index 7be6a3f..1cc70f5 100644 --- a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx +++ b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx @@ -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([]); const [copyingEntries, setCopyingEntries] = useState([]); + // --- 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); } 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() {
setCreatingDir(true)} onUploadFile={openFilePicker} onUploadDirectory={openDirectoryPicker} @@ -208,8 +277,22 @@ const FileExplorerPage = memo(function FileExplorerPage() { onChange={handleDirectoryInputChange} /> -
0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}> - {showSkeleton && loading && (entries.length === 0 || path !== routePath) ? ( +
+ {isSearching ? ( + { void openSearchResult(fullPath); }} + onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }} + /> + ) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? ( ) : !loading && entries.length === 0 ? (
@@ -239,14 +322,27 @@ const FileExplorerPage = memo(function FileExplorerPage() { )}
- {pagination.total > 0 && ( + {showFsPagination && (
)} + {showSearchPagination && ( +
+ updateSearchPage(nextPage)} + /> +
+ )} + {/* --- Modals & Context Menus --- */} - { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} /> + { doCreateDirInCurrentDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} /> { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} /> setDetailEntry(null)} /> {sharingEntries.length > 0 && ( 0} onOk={() => setSharingEntries([])} @@ -284,7 +380,7 @@ const FileExplorerPage = memo(function FileExplorerPage() { )} 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)} diff --git a/web/src/pages/FileExplorerPage/components/SearchResultsView.tsx b/web/src/pages/FileExplorerPage/components/SearchResultsView.tsx new file mode 100644 index 0000000..c6fe044 --- /dev/null +++ b/web/src/pages/FileExplorerPage/components/SearchResultsView.tsx @@ -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; + onClearSearch: () => void; + onSelect: (fullPath: string, additive: boolean) => void; + onOpen: (fullPath: string) => void; + onContextMenu: (e: React.MouseEvent, fullPath: string) => void; +} + +export const SearchResultsView: React.FC = ({ + 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 ( +
+ + + {t('Search Results')} + + {mode === 'filename' ? t('Name Search') : t('Smart Search')} + + { ev.preventDefault(); onClearSearch(); }}> + {query} + + + + + {loading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : viewMode === 'grid' ? ( +
+ {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 ( +
onSelect(fullPath, ev.ctrlKey || ev.metaKey)} + onDoubleClick={() => onOpen(fullPath)} + onContextMenu={(ev) => onContextMenu(ev, fullPath)} + style={{ userSelect: 'none' }} + > +
+ {scoreText} + {isDir + ? 📁 + : 📄} +
+
{name}
+ + {dir} + +
+ ); + })} +
+ ) : ( +
+ {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 ( +
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', + }} + > + + + {name} + + + {fullPath} + + {snippet ? ( + + {snippet} + + ) : null} + + {retrieval ? ( + + {renderSourceLabel(retrieval)} + + ) : null} + + {scoreText} + + + +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts b/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts new file mode 100644 index 0000000..490916a --- /dev/null +++ b/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts @@ -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([]); + const [hasMore, setHasMore] = useState(false); + const requestIdRef = useRef(0); + + const entryCacheRef = useRef>(new Map()); + const [entrySnapshot, setEntrySnapshot] = useState>({}); + const [selectedPaths, setSelectedPaths] = useState([]); + const [actionPath, setActionPath] = useState('/'); + + const normalizeFullPath = useCallback((fullPath: string) => { + const raw = (fullPath || '').replace(/\/+$/, ''); + if (!raw) return '/'; + const leading = raw.startsWith('/') ? raw : `/${raw}`; + return leading.replace(/\/{2,}/g, '/'); + }, []); + + const splitPath = useCallback((fullPath: string) => { + const normalized = normalizeFullPath(fullPath); + const parts = normalized.split('/').filter(Boolean); + const name = parts.pop() || ''; + const dir = parts.length > 0 ? `/${parts.join('/')}` : '/'; + return { fullPath: normalized, dir, name }; + }, [normalizeFullPath]); + + const runSearch = useCallback(async (requestId: number) => { + try { + const res = await vfsApi.searchFiles( + query, + mode === 'filename' ? PAGE_SIZE : 10, + mode, + mode === 'filename' ? page : undefined, + mode === 'filename' ? PAGE_SIZE : undefined, + ); + if (requestId !== requestIdRef.current) return; + setResults(res.items || []); + if (mode === 'filename') { + setHasMore(Boolean(res.pagination?.has_more)); + } else { + setHasMore(false); + } + } catch (e) { + if (requestId !== requestIdRef.current) return; + const msg = e instanceof Error ? e.message : t('Load failed'); + message.error(msg); + setResults([]); + setHasMore(false); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, [mode, page, query, t]); + + useEffect(() => { + if (!isSearching) { + setResults([]); + setHasMore(false); + setLoading(false); + requestIdRef.current += 1; + setSelectedPaths([]); + entryCacheRef.current.clear(); + setEntrySnapshot({}); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoading(true); + setSelectedPaths([]); + closeContextMenus(); + void runSearch(requestId); + }, [closeContextMenus, isSearching, runSearch]); + + const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise => { + const normalized = normalizeFullPath(fullPath); + const cached = entryCacheRef.current.get(normalized); + if (cached) { + return { ...cached, name: cached.name || defaultName }; + } + try { + const stat = await vfsApi.stat(normalized); + const entry: VfsEntry = { + name: (stat as any)?.name || defaultName, + is_dir: Boolean((stat as any)?.is_dir), + size: Number((stat as any)?.size ?? 0), + mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0), + type: (stat as any)?.type, + has_thumbnail: Boolean((stat as any)?.has_thumbnail), + }; + entryCacheRef.current.set(normalized, entry); + setEntrySnapshot(prev => ({ ...prev, [normalized]: entry })); + return entry; + } catch { + const fallback: VfsEntry = { name: defaultName, is_dir: false, size: 0, mtime: 0 }; + entryCacheRef.current.set(normalized, fallback); + setEntrySnapshot(prev => ({ ...prev, [normalized]: fallback })); + return fallback; + } + }, [normalizeFullPath]); + + const refreshSearch = useCallback(() => { + if (!isSearching) return; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoading(true); + void runSearch(requestId); + }, [isSearching, runSearch]); + + useEffect(() => { + if (!isSearching) { + setActionPath(currentPath || '/'); + return; + } + if (actionPath === '/' && currentPath) { + setActionPath(currentPath); + } + }, [actionPath, currentPath, isSearching]); + + const displayItems = useMemo(() => { + if (!isSearching) return []; + return (results || []).map((item) => { + const { fullPath, dir, name } = splitPath(item.path || ''); + return { item, fullPath, dir, name }; + }).filter(it => it.fullPath && it.fullPath !== '/' && it.name); + }, [isSearching, results, splitPath]); + + const itemByPath = useMemo(() => { + const map = new Map(); + for (const it of displayItems) { + map.set(it.fullPath, it); + } + return map; + }, [displayItems]); + + useEffect(() => { + if (!isSearching) return; + selectedPaths.forEach((p) => { + const info = itemByPath.get(p); + if (info) { + void ensureEntry(info.fullPath, info.name); + } + }); + }, [ensureEntry, isSearching, itemByPath, selectedPaths]); + + const updateSearchPage = useCallback((nextPage: number) => { + const params = new URLSearchParams(location.search); + if (nextPage <= 1) params.delete('page'); + else params.set('page', String(nextPage)); + const next = params.toString(); + navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true }); + }, [location.pathname, location.search, navigate]); + + const clearSearchParams = useCallback(() => { + const params = new URLSearchParams(location.search); + params.delete('q'); + params.delete('mode'); + params.delete('page'); + const next = params.toString(); + navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true }); + }, [location.pathname, location.search, navigate]); + + const openResult = useCallback(async (fullPath: string) => { + const info = itemByPath.get(fullPath); + if (!info) return; + setActionPath(info.dir); + const entry = await ensureEntry(info.fullPath, info.name); + if (entry.is_dir) { + navigateTo(info.fullPath); + return; + } + openFileWithDefaultApp(entry, info.dir); + }, [ensureEntry, itemByPath, navigateTo, openFileWithDefaultApp]); + + const selectResult = useCallback((fullPath: string, additive: boolean) => { + const info = itemByPath.get(fullPath); + if (!info) return; + if (actionPath !== info.dir) { + setActionPath(info.dir); + setSelectedPaths([fullPath]); + return; + } + if (!additive) { + setSelectedPaths([fullPath]); + return; + } + setSelectedPaths((prev) => { + const exists = prev.includes(fullPath); + return exists ? prev.filter(p => p !== fullPath) : [...prev, fullPath]; + }); + }, [actionPath, itemByPath]); + + const openResultContextMenu = useCallback(async (e: React.MouseEvent, fullPath: string) => { + e.preventDefault(); + e.stopPropagation(); + const info = itemByPath.get(fullPath); + if (!info) return; + setActionPath(info.dir); + setSelectedPaths((prev) => { + if (actionPath !== info.dir) { + return [fullPath]; + } + return prev.includes(fullPath) ? prev : [fullPath]; + }); + const entry = await ensureEntry(info.fullPath, info.name); + openContextMenu(e, entry); + }, [actionPath, ensureEntry, itemByPath, openContextMenu]); + + const selectedNames = useMemo(() => { + const names: string[] = []; + for (const p of selectedPaths) { + const info = itemByPath.get(p); + if (info && info.dir === actionPath) { + names.push(info.name); + } + } + return names; + }, [actionPath, itemByPath, selectedPaths]); + + const contextEntries = useMemo(() => { + if (!isSearching) return []; + const map = new Map(); + for (const p of selectedPaths) { + const info = itemByPath.get(p); + if (!info || info.dir !== actionPath) continue; + const cached = entrySnapshot[info.fullPath]; + map.set(info.name, cached || { name: info.name, is_dir: false, size: 0, mtime: 0 }); + } + if (activeEntry) { + map.set(activeEntry.name, activeEntry); + } + return Array.from(map.values()); + }, [actionPath, activeEntry, entrySnapshot, isSearching, itemByPath, selectedPaths]); + + const totalItems = useMemo(() => { + if (mode !== 'filename') return results.length; + if (hasMore) return page * PAGE_SIZE + 1; + return (page - 1) * PAGE_SIZE + results.length; + }, [hasMore, mode, page, results.length]); + + const showPagination = isSearching && mode === 'filename' && results.length > 0; + + const clearSelection = useCallback(() => setSelectedPaths([]), []); + + return { + isSearching, + query, + mode, + page, + pageSize: PAGE_SIZE, + loading, + displayItems, + selectedPaths, + selectedNames, + contextEntries, + entrySnapshot, + actionPath, + showPagination, + totalItems, + clearSearchParams, + updateSearchPage, + refreshSearch, + openResult, + selectResult, + openResultContextMenu, + clearSelection, + }; +} +