feat(FileExplorer): add move and copy functionality with task queuing

This commit is contained in:
shiyu
2025-09-22 18:15:05 +08:00
parent 330e8fd72b
commit 17ebb8d4f4
10 changed files with 694 additions and 26 deletions
+27 -13
View File
@@ -219,31 +219,41 @@ async def api_mkdir(
@router.post("/move") @router.post("/move")
async def api_move( async def api_move(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
body: MoveRequest body: MoveRequest,
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
): ):
src = body.src if body.src.startswith('/') else '/' + body.src src = body.src if body.src.startswith('/') else '/' + body.src
dst = body.dst if body.dst.startswith('/') else '/' + body.dst dst = body.dst if body.dst.startswith('/') else '/' + body.dst
await move_path(src, dst) debug_info = await move_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
return success({"moved": True, "src": src, "dst": dst}) queued = bool(debug_info.get("queued"))
response = {
"moved": not queued,
"queued": queued,
"src": src,
"dst": dst,
"overwrite": overwrite,
}
if queued:
response["task_id"] = debug_info.get("task_id")
response["task_name"] = debug_info.get("task_name")
return success(response)
@router.post("/rename") @router.post("/rename")
async def api_rename( async def api_rename(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
body: MoveRequest, body: MoveRequest,
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"), overwrite: bool = Query(False, description="是否允许覆盖已存在目标")
debug: bool = Query(False, description="返回调试信息")
): ):
src = body.src if body.src.startswith('/') else '/' + body.src src = body.src if body.src.startswith('/') else '/' + body.src
dst = body.dst if body.dst.startswith('/') else '/' + body.dst dst = body.dst if body.dst.startswith('/') else '/' + body.dst
from services.virtual_fs import rename_path from services.virtual_fs import rename_path
debug_info = await rename_path(src, dst, overwrite=overwrite, return_debug=debug) await rename_path(src, dst, overwrite=overwrite, return_debug=False)
return success({ return success({
"renamed": True, "renamed": True,
"src": src, "src": src,
"dst": dst, "dst": dst,
"overwrite": overwrite, "overwrite": overwrite,
**({"debug": debug_info} if debug else {})
}) })
@@ -252,19 +262,23 @@ async def api_copy(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
body: MoveRequest, body: MoveRequest,
overwrite: bool = Query(False, description="是否覆盖已存在目标"), overwrite: bool = Query(False, description="是否覆盖已存在目标"),
debug: bool = Query(False, description="返回调试信息")
): ):
from services.virtual_fs import copy_path from services.virtual_fs import copy_path
src = body.src if body.src.startswith('/') else '/' + body.src src = body.src if body.src.startswith('/') else '/' + body.src
dst = body.dst if body.dst.startswith('/') else '/' + body.dst dst = body.dst if body.dst.startswith('/') else '/' + body.dst
debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=debug) debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
return success({ queued = bool(debug_info.get("queued"))
"copied": True, response = {
"copied": not queued,
"queued": queued,
"src": src, "src": src,
"dst": dst, "dst": dst,
"overwrite": overwrite, "overwrite": overwrite,
**({"debug": debug_info} if debug else {}) }
}) if queued:
response["task_id"] = debug_info.get("task_id")
response["task_name"] = debug_info.get("task_name")
return success(response)
@router.post("/upload/{full_path:path}") @router.post("/upload/{full_path:path}")
+5
View File
@@ -108,6 +108,11 @@ class TaskQueueService:
result_path = await run_http_download(task) result_path = await run_http_download(task)
task.result = {"path": result_path} task.result = {"path": result_path}
elif task.name == "cross_mount_transfer":
from services.virtual_fs import run_cross_mount_transfer_task
result = await run_cross_mount_transfer_task(task)
task.result = result
else: else:
raise ValueError(f"Unknown task name: {task.name}") raise ValueError(f"Unknown task name: {task.name}")
+394 -9
View File
@@ -1,4 +1,6 @@
from typing import Dict, Tuple, Any, Union, AsyncIterator, List from __future__ import annotations
from typing import Dict, Tuple, Any, Union, AsyncIterator, List, TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
import mimetypes import mimetypes
from fastapi.responses import Response from fastapi.responses import Response
@@ -6,6 +8,9 @@ import time
import hmac import hmac
import hashlib import hashlib
import base64 import base64
from pathlib import Path
import shutil
import aiofiles
from models import StorageAdapter from models import StorageAdapter
from .adapters.registry import runtime_registry from .adapters.registry import runtime_registry
@@ -17,6 +22,36 @@ from services.logging import LogService
from services.config import ConfigCenter from services.config import ConfigCenter
CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer")
if TYPE_CHECKING:
from services.task_queue import Task
def _build_absolute_path(mount_path: str, rel_path: str) -> str:
rel_norm = rel_path.lstrip('/')
mount_norm = mount_path.rstrip('/')
if not mount_norm:
return '/' + rel_norm if rel_norm else '/'
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
def _join_rel(base: str, name: str) -> str:
if not base:
return name.lstrip('/')
if not name:
return base
return f"{base.rstrip('/')}/{name.lstrip('/')}"
def _parent_rel(rel: str) -> str:
if not rel:
return ''
if '/' not in rel:
return ''
return rel.rsplit('/', 1)[0]
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]: async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
norm = path if path.startswith('/') else '/' + path norm = path if path.startswith('/') else '/' + path
adapters = await StorageAdapter.filter(enabled=True) adapters = await StorageAdapter.filter(enabled=True)
@@ -239,22 +274,40 @@ async def delete_path(path: str):
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path}) await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): async def move_path(
src: str,
dst: str,
overwrite: bool = False,
return_debug: bool = True,
allow_cross: bool = False,
):
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src) adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst) adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = { debug_info = {
"src": src, "dst": dst, "src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d, "rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d, "root_s": root_s, "root_d": root_d,
"overwrite": overwrite "overwrite": overwrite,
"operation": "move",
"queued": False,
} }
if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter move not supported")
if not rel_s: if not rel_s:
raise HTTPException(400, detail="Cannot move or rename mount root") raise HTTPException(400, detail="Cannot move or rename mount root")
if not rel_d: if not rel_d:
raise HTTPException(400, detail="Invalid destination") raise HTTPException(400, detail="Invalid destination")
if adapter_model_s.id != adapter_model_d.id:
if not allow_cross:
raise HTTPException(400, detail="Cross-adapter move not supported")
queue_info = await _enqueue_cross_mount_transfer(
operation="move",
src=src,
dst=dst,
overwrite=overwrite,
)
debug_info.update(queue_info)
return debug_info if return_debug else None
exists_func = getattr(adapter_s, "exists", None) exists_func = getattr(adapter_s, "exists", None)
stat_func = getattr(adapter_s, "stat_path", None) stat_func = getattr(adapter_s, "stat_path", None)
delete_func = await _ensure_method(adapter_s, "delete") delete_func = await _ensure_method(adapter_s, "delete")
@@ -433,22 +486,40 @@ async def stat_file(path: str):
return await stat_func(root, rel) return await stat_func(root, rel)
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True): async def copy_path(
src: str,
dst: str,
overwrite: bool = False,
return_debug: bool = True,
allow_cross: bool = False,
):
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src) adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst) adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
debug_info = { debug_info = {
"src": src, "dst": dst, "src": src, "dst": dst,
"rel_s": rel_s, "rel_d": rel_d, "rel_s": rel_s, "rel_d": rel_d,
"root_s": root_s, "root_d": root_d, "root_s": root_s, "root_d": root_d,
"overwrite": overwrite "overwrite": overwrite,
"operation": "copy",
"queued": False,
} }
if adapter_model_s.id != adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter copy not supported")
if not rel_s: if not rel_s:
raise HTTPException(400, detail="Cannot copy mount root") raise HTTPException(400, detail="Cannot copy mount root")
if not rel_d: if not rel_d:
raise HTTPException(400, detail="Invalid destination") raise HTTPException(400, detail="Invalid destination")
if adapter_model_s.id != adapter_model_d.id:
if not allow_cross:
raise HTTPException(400, detail="Cross-adapter copy not supported")
queue_info = await _enqueue_cross_mount_transfer(
operation="copy",
src=src,
dst=dst,
overwrite=overwrite,
)
debug_info.update(queue_info)
return debug_info if return_debug else None
exists_func = getattr(adapter_s, "exists", None) exists_func = getattr(adapter_s, "exists", None)
stat_func = getattr(adapter_s, "stat_path", None) stat_func = getattr(adapter_s, "stat_path", None)
delete_func = getattr(adapter_s, "delete", None) delete_func = getattr(adapter_s, "delete", None)
@@ -494,6 +565,320 @@ async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: b
return debug_info if return_debug else None return debug_info if return_debug else None
async def _enqueue_cross_mount_transfer(operation: str, src: str, dst: str, overwrite: bool) -> Dict[str, Any]:
if operation not in {"move", "copy"}:
raise HTTPException(400, detail="Unsupported transfer operation")
adapter_s, adapter_model_s, _, _ = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
if adapter_model_s.id == adapter_model_d.id:
raise HTTPException(400, detail="Cross-adapter transfer requested but adapters are identical")
dst_exists = False
exists_func = getattr(adapter_d, "exists", None)
if callable(exists_func):
dst_exists = await exists_func(root_d, rel_d)
else:
try:
await stat_file(dst)
dst_exists = True
except FileNotFoundError:
dst_exists = False
except HTTPException as exc:
if exc.status_code == 404:
dst_exists = False
else:
raise
if dst_exists and not overwrite:
raise HTTPException(409, detail="Destination already exists")
payload = {
"operation": operation,
"src": src,
"dst": dst,
"overwrite": overwrite,
}
from services.task_queue import task_queue_service
task = await task_queue_service.add_task("cross_mount_transfer", payload)
return {
"queued": True,
"task_id": task.id,
"task_name": "cross_mount_transfer",
"dst_exists": dst_exists,
"cross_adapter": True,
}
async def run_cross_mount_transfer_task(task: "Task") -> Dict[str, Any]:
from services.task_queue import task_queue_service
params = task.task_info or {}
operation = params.get("operation")
src = params.get("src")
dst = params.get("dst")
overwrite = bool(params.get("overwrite", False))
if operation not in {"move", "copy"}:
raise ValueError(f"Unsupported cross mount operation: {operation}")
if not src or not dst:
raise ValueError("Missing src or dst for cross mount transfer")
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
await task_queue_service.update_meta(task.id, {
"operation": operation,
"src": src,
"dst": dst,
})
if adapter_model_s.id == adapter_model_d.id:
if operation == "move":
await move_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
else:
await copy_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
return {
"mode": "direct",
"operation": operation,
"src": src,
"dst": dst,
"files": 0,
"bytes": 0,
}
if not rel_s:
raise ValueError("Cannot transfer mount root")
if not rel_d:
raise ValueError("Invalid destination")
dst_exists = False
exists_func = getattr(adapter_d, "exists", None)
if callable(exists_func):
dst_exists = await exists_func(root_d, rel_d)
else:
try:
await stat_file(dst)
dst_exists = True
except FileNotFoundError:
dst_exists = False
except HTTPException as exc:
if exc.status_code != 404:
raise
if dst_exists and not overwrite:
raise ValueError("Destination already exists")
if dst_exists and overwrite:
await delete_path(dst)
try:
src_stat = await stat_file(src)
except HTTPException as exc:
if exc.status_code == 404:
raise FileNotFoundError(src) from exc
raise
src_is_dir = bool(src_stat.get("is_dir"))
files_to_transfer: List[Dict[str, Any]] = []
dirs_to_create: List[str] = []
await task_queue_service.update_progress(task.id, {
"stage": "preparing",
"percent": 0.0,
"detail": "Collecting source entries",
})
if src_is_dir:
if rel_d:
dirs_to_create.append(rel_d)
list_dir = await _ensure_method(adapter_s, "list_dir")
stack: List[Tuple[str, str, str]] = [(rel_s, rel_d, '')]
page_size = 200
while stack:
current_rel, current_dst_rel, current_relative = stack.pop()
page = 1
while True:
entries, total = await list_dir(root_s, current_rel, page, page_size, "name", "asc")
if not entries and (total or 0) == 0:
break
for entry in entries:
name = entry.get("name")
if not name:
continue
child_rel = _join_rel(current_rel, name)
child_dst_rel = _join_rel(current_dst_rel, name)
child_relative = _join_rel(current_relative, name)
if entry.get("is_dir"):
dirs_to_create.append(child_dst_rel)
stack.append((child_rel, child_dst_rel, child_relative))
else:
files_to_transfer.append({
"src_rel": child_rel,
"dst_rel": child_dst_rel,
"relative_rel": child_relative or name,
"size": entry.get("size"),
"name": name,
})
if total is None or page * page_size >= (total or 0):
break
page += 1
else:
relative_rel = rel_s or (src_stat.get("name") or "file")
files_to_transfer.append({
"src_rel": rel_s,
"dst_rel": rel_d,
"relative_rel": relative_rel,
"size": src_stat.get("size"),
"name": src_stat.get("name") or rel_s.split('/')[-1],
})
parent_dir = _parent_rel(rel_d)
if parent_dir:
dirs_to_create.append(parent_dir)
CROSS_TRANSFER_TEMP_ROOT.mkdir(parents=True, exist_ok=True)
temp_dir = CROSS_TRANSFER_TEMP_ROOT / task.id
temp_dir.mkdir(parents=True, exist_ok=True)
bytes_downloaded = 0
total_dynamic_bytes = sum((f["size"] or 0) for f in files_to_transfer)
try:
for job in files_to_transfer:
src_abs = _build_absolute_path(adapter_model_s.path, job["src_rel"])
data = await read_file(src_abs)
temp_path = temp_dir / job["relative_rel"]
temp_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(temp_path, "wb") as f:
await f.write(data)
actual_size = len(data)
job["temp_path"] = temp_path
prev_size = job.get("size") or 0
if prev_size <= 0:
total_dynamic_bytes += actual_size
job_size = actual_size
else:
job_size = prev_size
job["size"] = job_size
bytes_downloaded += actual_size
percent = None
total_for_percent = total_dynamic_bytes if total_dynamic_bytes else bytes_downloaded
if total_for_percent:
percent = min(100.0, round(bytes_downloaded / total_for_percent * 100, 2))
await task_queue_service.update_progress(task.id, {
"stage": "downloading",
"percent": percent,
"bytes_done": bytes_downloaded,
"bytes_total": total_dynamic_bytes or None,
"detail": f"Downloaded {job['name']}",
})
mkdir_func = await _ensure_method(adapter_d, "mkdir")
ensured_dirs: set[str] = set()
async def ensure_dir(rel_path: str):
if not rel_path or rel_path in ensured_dirs:
return
parent = _parent_rel(rel_path)
if parent:
await ensure_dir(parent)
try:
await mkdir_func(root_d, rel_path)
except FileExistsError:
pass
except HTTPException as exc:
if exc.status_code not in {409, 400}:
raise
except Exception:
# Assume directory already exists
pass
ensured_dirs.add(rel_path)
for dir_rel in sorted({d for d in dirs_to_create if d}, key=lambda x: x.count('/')):
await ensure_dir(dir_rel)
uploaded_bytes = 0
total_bytes = sum((f["size"] or 0) for f in files_to_transfer)
async def iter_temp_file(path: Path, chunk_size: int = 512 * 1024):
async with aiofiles.open(path, "rb") as f:
while True:
chunk = await f.read(chunk_size)
if not chunk:
break
yield chunk
for job in files_to_transfer:
parent_dir = _parent_rel(job["dst_rel"])
if parent_dir:
await ensure_dir(parent_dir)
dst_abs = _build_absolute_path(adapter_model_d.path, job["dst_rel"])
temp_path: Path = job["temp_path"]
await write_file_stream(dst_abs, iter_temp_file(temp_path), overwrite=overwrite)
uploaded_bytes += job["size"] or 0
percent = None
if total_bytes:
percent = min(100.0, round(uploaded_bytes / total_bytes * 100, 2))
await task_queue_service.update_progress(task.id, {
"stage": "uploading",
"percent": percent,
"bytes_done": uploaded_bytes,
"bytes_total": total_bytes or None,
"detail": f"Uploaded {job['name']}",
})
if operation == "move":
await delete_path(src)
await task_queue_service.update_progress(task.id, {
"stage": "completed",
"percent": 100.0,
"bytes_done": total_bytes,
"bytes_total": total_bytes,
"detail": "Completed",
})
await task_queue_service.update_meta(task.id, {
"files": len(files_to_transfer),
"directories": len({d for d in dirs_to_create if d}),
"bytes": total_bytes,
"operation": operation,
})
await LogService.action(
"virtual_fs",
f"Cross-adapter {operation} from {src} to {dst}",
details={
"src": src,
"dst": dst,
"operation": operation,
"files": len(files_to_transfer),
"bytes": total_bytes,
},
)
return {
"mode": "cross",
"operation": operation,
"src": src,
"dst": dst,
"files": len(files_to_transfer),
"bytes": total_bytes,
}
finally:
try:
if temp_dir.exists():
shutil.rmtree(temp_dir)
except Exception:
await LogService.info(
"virtual_fs",
"Failed to cleanup cross transfer temp dir",
details={"task_id": task.id, "temp_dir": str(temp_dir)},
)
async def process_file( async def process_file(
path: str, path: str,
processor_type: str, processor_type: str,
+12 -1
View File
@@ -50,7 +50,18 @@ export const vfsApi = {
}, },
mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }), mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }),
deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }), deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }),
move: (src: string, dst: string) => request('/fs/move', { method: 'POST', json: { src, dst } }), move: (src: string, dst: string, options?: { overwrite?: boolean }) => {
const params = new URLSearchParams();
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
const query = params.toString();
return request(`/fs/move${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
},
copy: (src: string, dst: string, options?: { overwrite?: boolean }) => {
const params = new URLSearchParams();
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
const query = params.toString();
return request(`/fs/copy${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
},
rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }), rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }),
thumb: (path: string, w=256, h=256, fit='cover') => thumb: (path: string, w=256, h=256, fit='cover') =>
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`), request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
+9
View File
@@ -115,6 +115,15 @@ export const en = {
'Grid': 'Grid', 'Grid': 'Grid',
'List': 'List', 'List': 'List',
'Mount Point': 'Mount Point', 'Mount Point': 'Mount Point',
'Move': 'Move',
'Move to': 'Move to',
'Copy to': 'Copy to',
'Destination path': 'Destination path',
'Move task queued': 'Move task queued',
'Move completed': 'Move completed',
'Copy task queued': 'Copy task queued',
'Copy completed': 'Copy completed',
'Please input destination path': 'Please input destination path',
// Context menu // Context menu
'Upload File': 'Upload File', 'Upload File': 'Upload File',
+9
View File
@@ -117,6 +117,15 @@ export const zh = {
'Grid': '网格', 'Grid': '网格',
'List': '列表', 'List': '列表',
'Mount Point': '挂载点', 'Mount Point': '挂载点',
'Move': '移动',
'Move to': '移动到',
'Copy to': '复制到',
'Destination path': '目标路径',
'Move task queued': '移动任务已排队',
'Move completed': '移动完成',
'Copy task queued': '复制任务已排队',
'Copy completed': '复制完成',
'Please input destination path': '请输入目标路径',
// Context menu // Context menu
'Upload File': '上传文件', 'Upload File': '上传文件',
@@ -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 { useParams } from 'react-router';
import { theme, Pagination } from 'antd'; import { theme, Pagination } from 'antd';
import { useFileExplorer } from './hooks/useFileExplorer'; import { useFileExplorer } from './hooks/useFileExplorer';
@@ -22,6 +22,7 @@ import UploadModal from './components/Modals/UploadModal';
import { ShareModal } from './components/Modals/ShareModal'; import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal'; import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { FileDetailModal } from './components/FileDetailModal'; import { FileDetailModal } from './components/FileDetailModal';
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
import type { ViewMode } from './types'; import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client'; import { vfsApi, type VfsEntry } from '../../api/client';
@@ -35,7 +36,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
// --- Hooks --- // --- Hooks ---
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey); const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection(); 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 { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu(); const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh); const uploader = useUploader(path, refresh);
@@ -51,6 +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 [copyingEntry, setCopyingEntry] = useState<VfsEntry | null>(null);
// --- Effects --- // --- Effects ---
useEffect(() => { 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) => { const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -189,6 +203,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} /> <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)} /> <RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(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 && ( {sharingEntries.length > 0 && (
<ShareModal <ShareModal
path={path} path={path}
@@ -244,6 +284,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onCreateDir={() => setCreatingDir(true)} onCreateDir={() => setCreatingDir(true)}
onShare={doShare} onShare={doShare}
onGetDirectLink={doGetDirectLink} onGetDirectLink={doGetDirectLink}
onMove={(entryToMove) => setMovingEntry(entryToMove)}
onCopy={(entryToCopy) => setCopyingEntry(entryToCopy)}
/> />
)} )}
<UploadModal <UploadModal
@@ -5,7 +5,8 @@ import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import { useI18n } from '../../../i18n'; import { useI18n } from '../../../i18n';
import { import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined, FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
interface ContextMenuProps { interface ContextMenuProps {
@@ -27,6 +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;
onCopy: (entry: VfsEntry) => void;
} }
export const ContextMenu: React.FC<ContextMenuProps> = (props) => { 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', disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
onClick: () => actions.onRename(targetEntries[0]), 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', key: 'delete',
label: t('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) { export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
const { t } = useI18n(); 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) => { const doCreateDir = useCallback(async (name: string) => {
if (!name.trim()) { if (!name.trim()) {
message.warning(t('Please input name')); message.warning(t('Please input name'));
@@ -57,6 +68,49 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
} }
}, [path, refresh]); }, [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) => { const doDownload = useCallback(async (entry: VfsEntry) => {
if (entry.is_dir) { if (entry.is_dir) {
message.warning(t('Downloading folders is not supported')); message.warning(t('Downloading folders is not supported'));
@@ -101,5 +155,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
doDownload, doDownload,
doShare, doShare,
doGetDirectLink, doGetDirectLink,
doMove,
doCopy,
}; };
} }