feat(FileExplorer): support moving and copying multiple entries in context menu and modals

This commit is contained in:
shiyu
2025-09-22 19:32:45 +08:00
parent 81095f11df
commit 9d6c63aff4
4 changed files with 109 additions and 64 deletions

View File

@@ -52,8 +52,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null); const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<any>(null); const [detailData, setDetailData] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [movingEntry, setMovingEntry] = useState<VfsEntry | null>(null); const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
const [copyingEntry, setCopyingEntry] = useState<VfsEntry | null>(null); const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
// --- Effects --- // --- Effects ---
useEffect(() => { useEffect(() => {
@@ -85,8 +85,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
} }
}; };
const buildDefaultDestination = useCallback((entry: VfsEntry | null) => { const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
if (!entry) return ''; if (!targetEntries || targetEntries.length === 0) return '';
if (targetEntries.length > 1) {
return path || '/';
}
const entry = targetEntries[0];
const base = path === '/' ? '' : path; const base = path === '/' ? '' : path;
const segments = [base, entry.name].filter(Boolean); const segments = [base, entry.name].filter(Boolean);
const joined = segments.join('/'); const joined = segments.join('/');
@@ -205,29 +209,27 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} /> <FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
<MoveCopyModal <MoveCopyModal
mode="move" mode="move"
entry={movingEntry} entries={movingEntries}
open={!!movingEntry} open={movingEntries.length > 0}
defaultPath={buildDefaultDestination(movingEntry)} defaultPath={buildDefaultDestination(movingEntries)}
onOk={async (destination) => { onOk={async (destination) => {
const target = movingEntry; if (movingEntries.length > 0) {
if (target) { await doMove(movingEntries, destination);
await doMove(target, destination);
} }
}} }}
onCancel={() => setMovingEntry(null)} onCancel={() => setMovingEntries([])}
/> />
<MoveCopyModal <MoveCopyModal
mode="copy" mode="copy"
entry={copyingEntry} entries={copyingEntries}
open={!!copyingEntry} open={copyingEntries.length > 0}
defaultPath={buildDefaultDestination(copyingEntry)} defaultPath={buildDefaultDestination(copyingEntries)}
onOk={async (destination) => { onOk={async (destination) => {
const target = copyingEntry; if (copyingEntries.length > 0) {
if (target) { await doCopy(copyingEntries, destination);
await doCopy(target, destination);
} }
}} }}
onCancel={() => setCopyingEntry(null)} onCancel={() => setCopyingEntries([])}
/> />
{sharingEntries.length > 0 && ( {sharingEntries.length > 0 && (
<ShareModal <ShareModal
@@ -284,8 +286,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onCreateDir={() => setCreatingDir(true)} onCreateDir={() => setCreatingDir(true)}
onShare={doShare} onShare={doShare}
onGetDirectLink={doGetDirectLink} onGetDirectLink={doGetDirectLink}
onMove={(entryToMove) => setMovingEntry(entryToMove)} onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
onCopy={(entryToCopy) => setCopyingEntry(entryToCopy)} onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
/> />
)} )}
<UploadModal <UploadModal

View File

@@ -28,8 +28,8 @@ interface ContextMenuProps {
onCreateDir: () => void; onCreateDir: () => void;
onShare: (entries: VfsEntry[]) => void; onShare: (entries: VfsEntry[]) => void;
onGetDirectLink: (entry: VfsEntry) => void; onGetDirectLink: (entry: VfsEntry) => void;
onMove: (entry: VfsEntry) => void; onMove: (entries: VfsEntry[]) => void;
onCopy: (entry: VfsEntry) => void; onCopy: (entries: VfsEntry[]) => void;
} }
export const ContextMenu: React.FC<ContextMenuProps> = (props) => { export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
@@ -117,15 +117,15 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
key: 'move', key: 'move',
label: t('Move'), label: t('Move'),
icon: <SwapOutlined />, icon: <SwapOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount', disabled: targetEntries.length === 0 || targetEntries.some(t => t.type === 'mount'),
onClick: () => actions.onMove(targetEntries[0]), onClick: () => actions.onMove(targetEntries),
}, },
{ {
key: 'copy', key: 'copy',
label: t('Copy'), label: t('Copy'),
icon: <CopyOutlined />, icon: <CopyOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount', disabled: targetEntries.length === 0 || targetEntries.some(t => t.type === 'mount'),
onClick: () => actions.onCopy(targetEntries[0]), onClick: () => actions.onCopy(targetEntries),
}, },
{ {
key: 'delete', key: 'delete',

View File

@@ -6,20 +6,20 @@ import PathSelectorModal from '../../../../components/PathSelectorModal';
interface MoveCopyModalProps { interface MoveCopyModalProps {
mode: 'move' | 'copy'; mode: 'move' | 'copy';
entry: VfsEntry | null; entries: VfsEntry[];
open: boolean; open: boolean;
defaultPath: string; defaultPath: string;
onOk: (destination: string) => Promise<void> | void; onOk: (destination: string) => Promise<void> | void;
onCancel: () => void; onCancel: () => void;
} }
export function MoveCopyModal({ mode, entry, open, defaultPath, onOk, onCancel }: MoveCopyModalProps) { export function MoveCopyModal({ mode, entries, open, defaultPath, onOk, onCancel }: MoveCopyModalProps) {
const { t } = useI18n(); const { t } = useI18n();
const [value, setValue] = useState(defaultPath); const [value, setValue] = useState(defaultPath);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectorOpen, setSelectorOpen] = useState(false); const [selectorOpen, setSelectorOpen] = useState(false);
const entryName = useMemo(() => entry?.name ?? '', [entry]); const entryName = useMemo(() => entries.length === 1 ? entries[0]?.name ?? '' : '', [entries]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -91,7 +91,7 @@ export function MoveCopyModal({ mode, entry, open, defaultPath, onOk, onCancel }
return ( return (
<Modal <Modal
title={title} title={title}
open={open && !!entry} open={open && entries.length > 0}
onOk={handleOk} onOk={handleOk}
onCancel={onCancel} onCancel={onCancel}
confirmLoading={loading} confirmLoading={loading}

View File

@@ -68,48 +68,91 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
} }
}, [path, refresh]); }, [path, refresh]);
const doMove = useCallback(async (entry: VfsEntry, destination: string, overwrite: boolean = false) => { const buildEntryDestination = useCallback((base: string, name: string) => {
const normalized = normalizeDestination(destination); const normalizedBase = base.replace(/\/+$/, '') || '/';
if (!normalized) { const prefix = normalizedBase === '/' ? '' : normalizedBase;
message.warning(t('Please input destination path')); const combined = `${prefix}/${name}`.replace(/\/{2,}/g, '/');
return; return combined.startsWith('/') ? combined : `/${combined}`;
} }, []);
const src = normalizeFullPath(entry.name);
try {
const result = await vfsApi.move(src, normalized, { overwrite });
if (result?.queued) {
message.info(t('Move task queued'));
} else {
message.success(t('Move completed'));
refresh();
}
clearSelection();
} catch (e: any) {
message.error(e.message);
throw e;
}
}, [normalizeDestination, normalizeFullPath, t, refresh, clearSelection]);
const doCopy = useCallback(async (entry: VfsEntry, destination: string, overwrite: boolean = false) => { const doMove = useCallback(async (entriesToMove: VfsEntry[], destination: string, overwrite: boolean = false) => {
if (!entriesToMove || entriesToMove.length === 0) return;
const normalized = normalizeDestination(destination); const normalized = normalizeDestination(destination);
if (!normalized) { if (!normalized) {
message.warning(t('Please input destination path')); message.warning(t('Please input destination path'));
return; return;
} }
const src = normalizeFullPath(entry.name);
try { const multiple = entriesToMove.length > 1;
const result = await vfsApi.copy(src, normalized, { overwrite }); const targetDir = multiple ? (normalized === '/' ? '/' : normalized.replace(/\/+$/, '') || '/') : normalized;
if (result?.queued) { let completedCount = 0;
message.info(t('Copy task queued')); let queuedCount = 0;
} else {
message.success(t('Copy completed')); for (const entry of entriesToMove) {
refresh(); const src = normalizeFullPath(entry.name);
const dst = multiple ? buildEntryDestination(targetDir, entry.name) : normalized;
try {
const result = await vfsApi.move(src, dst, { overwrite });
if (result?.queued) {
queuedCount += 1;
} else {
completedCount += 1;
}
} catch (e: any) {
message.error(e.message);
throw e;
} }
} catch (e: any) {
message.error(e.message);
throw e;
} }
}, [normalizeDestination, normalizeFullPath, t, refresh]);
if (completedCount > 0) {
message.success(t('Move completed'));
}
if (queuedCount > 0) {
message.info(t('Move task queued'));
}
clearSelection();
refresh();
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, clearSelection, refresh]);
const doCopy = useCallback(async (entriesToCopy: VfsEntry[], destination: string, overwrite: boolean = false) => {
if (!entriesToCopy || entriesToCopy.length === 0) return;
const normalized = normalizeDestination(destination);
if (!normalized) {
message.warning(t('Please input destination path'));
return;
}
const multiple = entriesToCopy.length > 1;
const targetDir = multiple ? (normalized === '/' ? '/' : normalized.replace(/\/+$/, '') || '/') : normalized;
let completedCount = 0;
let queuedCount = 0;
for (const entry of entriesToCopy) {
const src = normalizeFullPath(entry.name);
const dst = multiple ? buildEntryDestination(targetDir, entry.name) : normalized;
try {
const result = await vfsApi.copy(src, dst, { overwrite });
if (result?.queued) {
queuedCount += 1;
} else {
completedCount += 1;
}
} catch (e: any) {
message.error(e.message);
throw e;
}
}
if (completedCount > 0) {
message.success(t('Copy completed'));
}
if (queuedCount > 0) {
message.info(t('Copy task queued'));
}
refresh();
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
const doDownload = useCallback(async (entry: VfsEntry) => { const doDownload = useCallback(async (entry: VfsEntry) => {
if (entry.is_dir) { if (entry.is_dir) {