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

View File

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

View File

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

View File

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