mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-28 10:41:33 +08:00
feat(FileExplorer): support moving and copying multiple entries in context menu and modals
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user