mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-12 16:03:27 +08:00
feat(FileExplorer): add move and copy functionality with task queuing
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||
@@ -22,6 +22,7 @@ import UploadModal from './components/Modals/UploadModal';
|
||||
import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
|
||||
@@ -35,7 +36,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
@@ -51,6 +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);
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
@@ -82,6 +85,17 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const buildDefaultDestination = useCallback((entry: VfsEntry | null) => {
|
||||
if (!entry) return '';
|
||||
const base = path === '/' ? '' : path;
|
||||
const segments = [base, entry.name].filter(Boolean);
|
||||
const joined = segments.join('/');
|
||||
if (!joined) {
|
||||
return '/';
|
||||
}
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}, [path]);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -189,6 +203,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
||||
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
||||
<MoveCopyModal
|
||||
mode="move"
|
||||
entry={movingEntry}
|
||||
open={!!movingEntry}
|
||||
defaultPath={buildDefaultDestination(movingEntry)}
|
||||
onOk={async (destination) => {
|
||||
const target = movingEntry;
|
||||
if (target) {
|
||||
await doMove(target, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMovingEntry(null)}
|
||||
/>
|
||||
<MoveCopyModal
|
||||
mode="copy"
|
||||
entry={copyingEntry}
|
||||
open={!!copyingEntry}
|
||||
defaultPath={buildDefaultDestination(copyingEntry)}
|
||||
onOk={async (destination) => {
|
||||
const target = copyingEntry;
|
||||
if (target) {
|
||||
await doCopy(target, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCopyingEntry(null)}
|
||||
/>
|
||||
{sharingEntries.length > 0 && (
|
||||
<ShareModal
|
||||
path={path}
|
||||
@@ -244,6 +284,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
onMove={(entryToMove) => setMovingEntry(entryToMove)}
|
||||
onCopy={(entryToCopy) => setCopyingEntry(entryToCopy)}
|
||||
/>
|
||||
)}
|
||||
<UploadModal
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
|
||||
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -27,6 +28,8 @@ interface ContextMenuProps {
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
onMove: (entry: VfsEntry) => void;
|
||||
onCopy: (entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
@@ -110,6 +113,20 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onRename(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'move',
|
||||
label: t('Move'),
|
||||
icon: <SwapOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onMove(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('Copy'),
|
||||
icon: <CopyOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onCopy(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('Delete'),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Input, message, Space } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import PathSelectorModal from '../../../../components/PathSelectorModal';
|
||||
|
||||
interface MoveCopyModalProps {
|
||||
mode: 'move' | 'copy';
|
||||
entry: VfsEntry | null;
|
||||
open: boolean;
|
||||
defaultPath: string;
|
||||
onOk: (destination: string) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MoveCopyModal({ mode, entry, 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(defaultPath);
|
||||
} else {
|
||||
setValue('');
|
||||
}
|
||||
setLoading(false);
|
||||
setSelectorOpen(false);
|
||||
}, [open, defaultPath]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
message.warning(t('Please input destination path'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOk(trimmed);
|
||||
onCancel();
|
||||
} catch (e) {
|
||||
// 上层已处理提示,这里只需保持对话框
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'move' ? t('Move to') : t('Copy to');
|
||||
const okText = mode === 'move' ? t('Move') : t('Copy');
|
||||
|
||||
const normalizeSelectedPath = (dirPath: string) => {
|
||||
const collapse = (p: string) => p.replace(/\/+/g, '/');
|
||||
const ensureLeading = (p: string) => (p.startsWith('/') ? p : `/${p}`);
|
||||
const normalizeDir = (() => {
|
||||
if (!dirPath || dirPath === '/') return '/';
|
||||
const replaced = dirPath.replace(/\\/g, '/');
|
||||
const trimmed = replaced.endsWith('/') ? replaced.replace(/\/+$/, '') : replaced;
|
||||
return ensureLeading(collapse(trimmed));
|
||||
})();
|
||||
|
||||
if (!entryName) {
|
||||
return normalizeDir || '/';
|
||||
}
|
||||
|
||||
const base = normalizeDir === '/' ? '' : normalizeDir;
|
||||
const combined = `${base}/${entryName}`;
|
||||
return ensureLeading(collapse(combined));
|
||||
};
|
||||
|
||||
const handleBrowse = (selectedPath: string) => {
|
||||
const finalPath = normalizeSelectedPath(selectedPath);
|
||||
setValue(finalPath);
|
||||
setSelectorOpen(false);
|
||||
};
|
||||
|
||||
const selectorInitialPath = useMemo(() => {
|
||||
if (!value) return '/';
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
if (!normalized || normalized === '/') return '/';
|
||||
const trimmed = normalized.endsWith('/') ? normalized.replace(/\/+$/, '') : normalized;
|
||||
const parts = trimmed.split('/');
|
||||
if (parts.length <= 1) return '/';
|
||||
parts.pop();
|
||||
const parent = parts.join('/') || '/';
|
||||
return parent.startsWith('/') ? parent : `/${parent}`;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open && !!entry}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
okText={okText}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={t('Destination path')}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
/>
|
||||
<Button onClick={() => setSelectorOpen(true)}>{t('Select destination')}</Button>
|
||||
</Space.Compact>
|
||||
<PathSelectorModal
|
||||
open={selectorOpen}
|
||||
mode="directory"
|
||||
initialPath={selectorInitialPath}
|
||||
onOk={handleBrowse}
|
||||
onCancel={() => setSelectorOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,17 @@ interface FileActionsParams {
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||
const { t } = useI18n();
|
||||
const normalizeFullPath = useCallback((name: string) => {
|
||||
const base = path === '/' ? '' : path;
|
||||
return `${base}/${name}`.replace(/\/{2,}/g, '/');
|
||||
}, [path]);
|
||||
|
||||
const normalizeDestination = useCallback((dest: string) => {
|
||||
const trimmed = dest.trim();
|
||||
if (!trimmed) return '';
|
||||
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}, []);
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning(t('Please input name'));
|
||||
@@ -57,6 +68,49 @@ 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 doCopy = 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.copy(src, normalized, { overwrite });
|
||||
if (result?.queued) {
|
||||
message.info(t('Copy task queued'));
|
||||
} else {
|
||||
message.success(t('Copy completed'));
|
||||
refresh();
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
throw e;
|
||||
}
|
||||
}, [normalizeDestination, normalizeFullPath, t, refresh]);
|
||||
|
||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning(t('Downloading folders is not supported'));
|
||||
@@ -101,5 +155,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
doDownload,
|
||||
doShare,
|
||||
doGetDirectLink,
|
||||
doMove,
|
||||
doCopy,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user