mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 07:36:53 +08:00
feat: enhance SearchDialog with context menu and file action modals
This commit is contained in:
+309
-18
@@ -1,9 +1,21 @@
|
|||||||
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination } from 'antd';
|
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination, message } from 'antd';
|
||||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||||
|
import { type VfsEntry } from '../api/client';
|
||||||
|
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
|
import { ContextMenu } from '../pages/FileExplorerPage/components/ContextMenu';
|
||||||
|
import { RenameModal } from '../pages/FileExplorerPage/components/Modals/RenameModal';
|
||||||
|
import { MoveCopyModal } from '../pages/FileExplorerPage/components/Modals/MoveCopyModal';
|
||||||
|
import { ShareModal } from '../pages/FileExplorerPage/components/Modals/ShareModal';
|
||||||
|
import { DirectLinkModal } from '../pages/FileExplorerPage/components/Modals/DirectLinkModal';
|
||||||
|
import { FileDetailModal } from '../pages/FileExplorerPage/components/FileDetailModal';
|
||||||
|
import { ProcessorModal } from '../pages/FileExplorerPage/components/Modals/ProcessorModal';
|
||||||
|
import { useFileActions } from '../pages/FileExplorerPage/hooks/useFileActions.tsx';
|
||||||
|
import { useProcessor } from '../pages/FileExplorerPage/hooks/useProcessor';
|
||||||
|
|
||||||
interface SearchDialogProps {
|
interface SearchDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,6 +36,97 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
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 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
statCacheRef.current.clear();
|
||||||
|
setProcessorTypes([]);
|
||||||
|
closeContextMenu();
|
||||||
|
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 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) => {
|
const renderSourceLabel = (value?: string) => {
|
||||||
switch ((value || '').toLowerCase()) {
|
switch ((value || '').toLowerCase()) {
|
||||||
@@ -55,6 +158,104 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
is_image: Boolean((stat as any)?.is_image),
|
||||||
|
};
|
||||||
|
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 performSearch = async (options?: { page?: number; mode?: SearchMode }) => {
|
||||||
const query = search.trim();
|
const query = search.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -71,6 +272,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
requestIdRef.current = requestId;
|
requestIdRef.current = requestId;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
closeContextMenu();
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
if (currentMode === 'filename') {
|
if (currentMode === 'filename') {
|
||||||
setPage(targetPage);
|
setPage(targetPage);
|
||||||
@@ -117,6 +319,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (!search.trim()) {
|
if (!search.trim()) {
|
||||||
|
closeContextMenu();
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
@@ -131,6 +334,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setSearchMode(nextMode);
|
setSearchMode(nextMode);
|
||||||
|
closeContextMenu();
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
||||||
} else {
|
} else {
|
||||||
@@ -139,18 +343,6 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setSearch('');
|
|
||||||
setResults([]);
|
|
||||||
setSearched(false);
|
|
||||||
setSearchMode('vector');
|
|
||||||
setPage(1);
|
|
||||||
setHasMore(false);
|
|
||||||
requestIdRef.current = 0;
|
|
||||||
setLoading(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalItems = searchMode === 'filename'
|
const totalItems = searchMode === 'filename'
|
||||||
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
||||||
: results.length;
|
: results.length;
|
||||||
@@ -246,12 +438,15 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
const filename = parts.pop() || '';
|
const filename = parts.pop() || '';
|
||||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
const dir = parts.length ? '/' + parts.join('/') : '/';
|
||||||
const snippet = item.snippet || '';
|
const snippet = item.snippet || '';
|
||||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||||
const retrievalLabel = renderSourceLabel(retrieval);
|
const retrievalLabel = renderSourceLabel(retrieval);
|
||||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item style={{ padding: '10px 12px', borderRadius: 6, background: '#fafafa', marginBottom: 8 }}>
|
<List.Item
|
||||||
|
style={{ padding: '10px 12px', borderRadius: 6, background: '#fafafa', marginBottom: 8 }}
|
||||||
|
onContextMenu={(event) => { void handleResultContextMenu(event, item); }}
|
||||||
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={<FileTextOutlined style={{ fontSize: 18, color: '#8c8c8c' }} />}
|
avatar={<FileTextOutlined style={{ fontSize: 18, color: '#8c8c8c' }} />}
|
||||||
title={
|
title={
|
||||||
@@ -308,6 +503,102 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{contextMenuState ? (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenuState.x}
|
||||||
|
y={contextMenuState.y}
|
||||||
|
entry={contextMenuState.entry}
|
||||||
|
entries={menuEntries}
|
||||||
|
selectedEntries={selectedEntryNames}
|
||||||
|
processorTypes={processorTypes}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
onOpen={handleOpenEntry}
|
||||||
|
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, contextMenuState?.path ?? currentPath)}
|
||||||
|
onDownload={doDownload}
|
||||||
|
onRename={setRenaming}
|
||||||
|
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||||
|
onDetail={openDetail}
|
||||||
|
onProcess={(entry, type) => {
|
||||||
|
processorHook.setSelectedProcessor(type);
|
||||||
|
processorHook.openProcessorModal(entry);
|
||||||
|
}}
|
||||||
|
onUpload={noop}
|
||||||
|
onCreateDir={noop}
|
||||||
|
onShare={doShare}
|
||||||
|
onGetDirectLink={doGetDirectLink}
|
||||||
|
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
|
||||||
|
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<RenameModal
|
||||||
|
entry={renaming}
|
||||||
|
onOk={(entry, newName) => {
|
||||||
|
void doRename(entry, newName);
|
||||||
|
setRenaming(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setRenaming(null)}
|
||||||
|
/>
|
||||||
|
<FileDetailModal
|
||||||
|
entry={detailEntry}
|
||||||
|
loading={detailLoading}
|
||||||
|
data={detailData}
|
||||||
|
onClose={() => setDetailEntry(null)}
|
||||||
|
/>
|
||||||
|
<MoveCopyModal
|
||||||
|
mode="move"
|
||||||
|
entries={movingEntries}
|
||||||
|
open={movingEntries.length > 0}
|
||||||
|
defaultPath={buildDefaultDestination(movingEntries)}
|
||||||
|
onOk={async (destination) => {
|
||||||
|
if (movingEntries.length > 0) {
|
||||||
|
await doMove(movingEntries, destination);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setMovingEntries([])}
|
||||||
|
/>
|
||||||
|
<MoveCopyModal
|
||||||
|
mode="copy"
|
||||||
|
entries={copyingEntries}
|
||||||
|
open={copyingEntries.length > 0}
|
||||||
|
defaultPath={buildDefaultDestination(copyingEntries)}
|
||||||
|
onOk={async (destination) => {
|
||||||
|
if (copyingEntries.length > 0) {
|
||||||
|
await doCopy(copyingEntries, destination);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setCopyingEntries([])}
|
||||||
|
/>
|
||||||
|
{sharingEntries.length > 0 ? (
|
||||||
|
<ShareModal
|
||||||
|
path={currentPath}
|
||||||
|
entries={sharingEntries}
|
||||||
|
open={sharingEntries.length > 0}
|
||||||
|
onOk={() => setSharingEntries([])}
|
||||||
|
onCancel={() => setSharingEntries([])}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DirectLinkModal
|
||||||
|
entry={directLinkEntry}
|
||||||
|
path={currentPath}
|
||||||
|
open={!!directLinkEntry}
|
||||||
|
onCancel={() => setDirectLinkEntry(null)}
|
||||||
|
/>
|
||||||
|
<ProcessorModal
|
||||||
|
entry={processorHook.processorModal.entry}
|
||||||
|
visible={processorHook.processorModal.visible}
|
||||||
|
loading={processorHook.processorLoading}
|
||||||
|
processorTypes={processorTypes}
|
||||||
|
selectedProcessor={processorHook.selectedProcessor}
|
||||||
|
config={processorHook.processorConfig}
|
||||||
|
savingPath={processorHook.processorSavingPath}
|
||||||
|
overwrite={processorHook.processorOverwrite}
|
||||||
|
onOk={processorHook.handleProcessorOk}
|
||||||
|
onCancel={processorHook.handleProcessorCancel}
|
||||||
|
onSelectedProcessorChange={processorHook.setSelectedProcessor}
|
||||||
|
onConfigChange={processorHook.setProcessorConfig}
|
||||||
|
onSavingPathChange={processorHook.setProcessorSavingPath}
|
||||||
|
onOverwriteChange={processorHook.setProcessorOverwrite}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user