mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-25 17:23:59 +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 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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
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