mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-23 09:12:33 +08:00
feat: enhance SearchDialog with context menu and file action modals
This commit is contained in:
@@ -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 React, { useRef, useState } from 'react';
|
||||
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 { 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';
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
@@ -24,6 +36,97 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
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 });
|
||||
|
||||
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) => {
|
||||
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 query = search.trim();
|
||||
if (!query) {
|
||||
@@ -71,6 +272,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setLoading(true);
|
||||
closeContextMenu();
|
||||
setSearched(true);
|
||||
if (currentMode === 'filename') {
|
||||
setPage(targetPage);
|
||||
@@ -117,6 +319,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!search.trim()) {
|
||||
closeContextMenu();
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
@@ -131,6 +334,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
setSearchMode(nextMode);
|
||||
closeContextMenu();
|
||||
if (search.trim()) {
|
||||
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
||||
} 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'
|
||||
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
||||
: results.length;
|
||||
@@ -246,12 +438,15 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
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) : '-';
|
||||
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 }}>
|
||||
<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={
|
||||
@@ -308,6 +503,102 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user