From 371b0e33e30ca6eaaa45d880da4f9a4cf196ed03 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 7 Apr 2026 17:11:34 +0200 Subject: [PATCH] vfs:copy/move/delete --- .../modules/storage/CopyConflictDialog.tsx | 41 ++ .../modules/storage/CopyProgressDialog.tsx | 60 +++ .../modules/storage/CopyTransferOptions.tsx | 78 ++++ .../src/modules/storage/FileBrowserPanel.tsx | 365 +++--------------- .../modules/storage/hooks/useCopyTransfer.ts | 227 +++++++++++ 5 files changed, 459 insertions(+), 312 deletions(-) create mode 100644 packages/ui/src/modules/storage/CopyConflictDialog.tsx create mode 100644 packages/ui/src/modules/storage/CopyProgressDialog.tsx create mode 100644 packages/ui/src/modules/storage/CopyTransferOptions.tsx create mode 100644 packages/ui/src/modules/storage/hooks/useCopyTransfer.ts diff --git a/packages/ui/src/modules/storage/CopyConflictDialog.tsx b/packages/ui/src/modules/storage/CopyConflictDialog.tsx new file mode 100644 index 00000000..e7bfd0a1 --- /dev/null +++ b/packages/ui/src/modules/storage/CopyConflictDialog.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { T } from '@/i18n'; + +export const CopyConflictDialog: React.FC<{ + open: boolean; + conflict?: { source: string; destination: string }; + resolving: boolean; + error?: string | null; + onDecision: (decision: 'overwrite' | 'overwrite_all' | 'skip' | 'skip_all' | 'cancel') => void; + onOpenChange?: (open: boolean) => void; +}> = ({ open, conflict, resolving, error, onDecision, onOpenChange }) => { + return ( + + + + Confirm File Overwrite + + A file conflict was found. Choose how to continue. + + +
+

Overwrite: {conflict?.destination}

+

With: {conflict?.source}

+
+
+ {error &&

{error}

} + + + + + +
+
+
+ ); +}; + +export default CopyConflictDialog; + diff --git a/packages/ui/src/modules/storage/CopyProgressDialog.tsx b/packages/ui/src/modules/storage/CopyProgressDialog.tsx new file mode 100644 index 00000000..ffe92c08 --- /dev/null +++ b/packages/ui/src/modules/storage/CopyProgressDialog.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Progress } from '@/components/ui/progress'; +import { T } from '@/i18n'; +import { formatSize } from '@/modules/storage/helpers'; + +export const CopyProgressDialog: React.FC<{ + open: boolean; + busy: boolean; + progress: number; + fileProgress: number; + filesDone: number; + totalFiles: number; + bytesTransferred: number; + taskState?: string | null; + error?: string | null; + onOpenChange: (open: boolean) => void; +}> = ({ + open, + busy, + progress, + fileProgress, + filesDone, + totalFiles, + bytesTransferred, + taskState, + error, + onOpenChange, +}) => { + return ( + !busy && onOpenChange(next)}> + + + Copy Progress + + Transfer progress for the current copy operation. + + +
+
+
Total {progress}%
+ +
+
+
Current file {fileProgress}%
+ +
+

+ {filesDone}/{totalFiles} · {formatSize(bytesTransferred)} + {taskState ? ` · ${taskState}` : ''} +

+ {error &&

{error}

} +
+
+
+ ); +}; + +export default CopyProgressDialog; + diff --git a/packages/ui/src/modules/storage/CopyTransferOptions.tsx b/packages/ui/src/modules/storage/CopyTransferOptions.tsx new file mode 100644 index 00000000..53e9de4f --- /dev/null +++ b/packages/ui/src/modules/storage/CopyTransferOptions.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { T } from '@/i18n'; +import type { CopyConflictPreset } from './hooks/useCopyTransfer'; + +export const CopyTransferOptions: React.FC<{ + includePatterns: string; + excludePatterns: string; + excludeDefault: boolean; + conflictPreset: CopyConflictPreset; + onIncludePatternsChange: (v: string) => void; + onExcludePatternsChange: (v: string) => void; + onExcludeDefaultChange: (v: boolean) => void; + onConflictPresetChange: (v: CopyConflictPreset) => void; + error?: string | null; +}> = ({ + includePatterns, + excludePatterns, + excludeDefault, + conflictPreset, + onIncludePatternsChange, + onExcludePatternsChange, + onExcludeDefaultChange, + onConflictPresetChange, + error, +}) => { + return ( +
+
+ + onIncludePatternsChange(e.target.value)} + placeholder="**/*" + className="h-8 text-xs font-mono" + /> +
+
+ + onExcludePatternsChange(e.target.value)} + placeholder="**/node_modules/**" + className="h-8 text-xs font-mono" + /> +
+
+ + +
+
+ + +
+ {error && ( +

{error}

+ )} +
+ ); +}; + +export default CopyTransferOptions; + diff --git a/packages/ui/src/modules/storage/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx index 8601eb8f..1d37b4c7 100644 --- a/packages/ui/src/modules/storage/FileBrowserPanel.tsx +++ b/packages/ui/src/modules/storage/FileBrowserPanel.tsx @@ -22,13 +22,15 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Progress } from '@/components/ui/progress'; import { IMAGE_EXTS, VIDEO_EXTS, CODE_EXTS } from '@/modules/storage/helpers'; import { T, translate } from '@/i18n'; import { useOptionalStream } from '@/contexts/StreamContext'; import type { AppEvent } from '@/types-server'; import { FilePickerDialog, type FilePickerResult } from './FilePicker'; -import { vfsStartTransferTask, vfsResolveTaskConflict, vfsGetTaskStatus, type VfsTaskStatus } from './client-vfs'; +import { useCopyTransfer } from './hooks/useCopyTransfer'; +import CopyTransferOptions from './CopyTransferOptions'; +import CopyConflictDialog from './CopyConflictDialog'; +import CopyProgressDialog from './CopyProgressDialog'; import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; import { useSelection } from '@/modules/storage/hooks/useSelection'; @@ -520,115 +522,17 @@ const FileBrowserPanel: React.FC = ({ // ── Default Selection Handler (first so we get wrapped goUp) ── const [pendingFileSelect, setPendingFileSelect] = useState(null); - const [copyDialogOpen, setCopyDialogOpen] = useState(false); - const [copyIncludePatterns, setCopyIncludePatterns] = useState('**/*'); - const [copyExcludePatterns, setCopyExcludePatterns] = useState('**/node_modules/**'); - const [copyExcludeDefault, setCopyExcludeDefault] = useState(true); - const [copyBusy, setCopyBusy] = useState(false); - const [copyError, setCopyError] = useState(null); - const [copyTaskId, setCopyTaskId] = useState(null); - const [copyTaskState, setCopyTaskState] = useState(null); - const [copyConflict, setCopyConflict] = useState(undefined); - const [resolvingConflict, setResolvingConflict] = useState(false); - const [copyProgress, setCopyProgress] = useState<{ - open: boolean; - progress: number; - fileProgress: number; - filesDone: number; - totalFiles: number; - bytesTransferred: number; - sourcePath?: string; - destinationPath?: string; - }>({ - open: false, - progress: 0, - fileProgress: 0, - filesDone: 0, - totalFiles: 0, - bytesTransferred: 0, - }); - const [frozenCopySources, setFrozenCopySources] = useState([]); - const selectedCopyCandidates = useMemo( () => selected.filter((n) => getMimeCategory(n) !== 'other'), [selected] ); - const copyEnabled = allowCopyAction && selectedCopyCandidates.length > 0 && !copyBusy; - - const parsePatternCsv = useCallback((raw: string): string[] => { - return raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - }, []); - - const handleCopyRequest = useCallback(() => { - if (!copyEnabled) return; - setFrozenCopySources(selectedCopyCandidates); - setCopyError(null); - setCopyDialogOpen(true); - }, [copyEnabled, selectedCopyCandidates]); - - useEffect(() => { - if (!stream || !copyTaskId) return; - const unsubscribe = stream.subscribe((event: AppEvent) => { - if (event.kind !== 'system' || event.type !== 'vfs-task') return; - if (String((event.data as any)?.id || '') !== copyTaskId) return; - - const task = event.data as unknown as VfsTaskStatus; - setCopyTaskState(task.state); - setCopyConflict(task.conflict); - setCopyProgress((prev) => ({ - ...prev, - progress: Number(task.progress || prev.progress || 0), - fileProgress: task.state === 'paused_for_conflict' - ? prev.fileProgress - : Number(task.progress || prev.fileProgress || 0), - filesDone: Number(task.filesDone || prev.filesDone || 0), - totalFiles: Number(task.totalFiles || prev.totalFiles || 0), - bytesTransferred: Number(task.bytesCopied || prev.bytesTransferred || 0), - })); - - if (task.state === 'completed' || task.state === 'failed' || task.state === 'cancelled') { - setCopyBusy(false); - setResolvingConflict(false); - if (task.state === 'completed') { - fetchDir(currentPath || '/'); - setTimeout(() => setCopyProgress((prev) => ({ ...prev, open: false })), 800); - } else { - setCopyError(task.error || `Task ${task.state}`); - } - setCopyTaskId(null); - } - }); - return unsubscribe; - }, [stream, copyTaskId, fetchDir, currentPath]); - - useEffect(() => { - if (!copyTaskId || copyTaskState !== 'paused_for_conflict' || copyConflict) return; - let active = true; - const t = window.setTimeout(async () => { - try { - const status = await vfsGetTaskStatus(copyTaskId); - if (!active) return; - setCopyTaskState(status.state); - setCopyConflict(status.conflict); - setCopyProgress((prev) => ({ - ...prev, - progress: Number(status.progress || prev.progress || 0), - filesDone: Number(status.filesDone || prev.filesDone || 0), - totalFiles: Number(status.totalFiles || prev.totalFiles || 0), - bytesTransferred: Number(status.bytesCopied || prev.bytesTransferred || 0), - })); - } catch { - // no-op fallback - } - }, 350); - return () => { - active = false; - window.clearTimeout(t); - }; - }, [copyTaskId, copyTaskState, copyConflict]); + const copyTransfer = useCopyTransfer({ + mount, + selectedSources: selectedCopyCandidates, + stream, + onCompleted: () => fetchDir(currentPath || '/'), + }); + const copyEnabled = allowCopyAction && copyTransfer.copyEnabled; const { goUp } = useDefaultSelectionHandler({ sorted: displayNodes, @@ -694,7 +598,7 @@ const FileBrowserPanel: React.FC = ({ isSearchMode, onSelect, sorted: displayNodes, - onCopyRequest: handleCopyRequest, + onCopyRequest: copyTransfer.handleCopyRequest, }); // ── Default Actions ────────────────────────────────────────── @@ -804,7 +708,7 @@ const FileBrowserPanel: React.FC = ({ handleDownload={handleDownload} allowDownload={allowDownload && selected.length > 0} allowCopy={copyEnabled} - onCopy={handleCopyRequest} + onCopy={copyTransfer.handleCopyRequest} handleDownloadDir={handleDownloadDir} allowDownloadDir={allowDownload} sortBy={sortBy} @@ -1175,217 +1079,54 @@ const FileBrowserPanel: React.FC = ({ )} { - const destinationDirectory = (result.fullPath || '/').replace(/^\/+/, ''); - const includePatterns = parsePatternCsv(copyIncludePatterns); - const excludePatterns = parsePatternCsv(copyExcludePatterns); - const items = frozenCopySources; - if (items.length === 0) return; - setCopyBusy(true); - setCopyError(null); - setCopyConflict(undefined); - setCopyProgress({ - open: true, - progress: 0, - fileProgress: 0, - filesDone: 0, - totalFiles: items.length, - bytesTransferred: 0, - }); - try { - const started = await vfsStartTransferTask({ - operation: 'copy', - sources: items.map((node) => ({ - mount, - path: node.path.replace(/^\/+/, ''), - })), - destination: { mount: result.mount, path: destinationDirectory }, - conflictMode: 'manual', - conflictStrategy: 'error', - includePatterns, - excludePatterns, - excludeDefault: copyExcludeDefault, - }); - setCopyTaskId(started.taskId); - setCopyTaskState('running'); - setCopyDialogOpen(false); - } catch (err: any) { - setCopyError(err?.message || String(err)); - setCopyBusy(false); - setCopyTaskId(null); - setCopyTaskState(null); - } finally { - } - }} + onConfirm={copyTransfer.handleCopyConfirm} extensionSlot={ -
-
- - setCopyIncludePatterns(e.target.value)} - placeholder="**/*" - className="h-8 text-xs font-mono" - /> -
-
- - setCopyExcludePatterns(e.target.value)} - placeholder="**/node_modules/**" - className="h-8 text-xs font-mono" - /> -
-
- - -
- {copyError && ( -

{copyError}

- )} -
+ } /> - !copyBusy && setCopyProgress((prev) => ({ ...prev, open }))}> - - - Copy Progress - - Transfer progress for the current copy operation. - - -
-
-
Total {copyProgress.progress}%
- -
-
-
Current file {copyProgress.fileProgress}%
- -
-

- {copyProgress.filesDone}/{copyProgress.totalFiles} · {formatSize(copyProgress.bytesTransferred)} - {copyTaskState ? ` · ${copyTaskState}` : ''} -

- {copyError &&

{copyError}

} -
-
-
- - { if (!open && !resolvingConflict) setCopyConflict(undefined); }}> - - - Confirm File Overwrite - - A file conflict was found. Choose how to continue. - - -
-

Overwrite: {copyConflict?.destination}

-

With: {copyConflict?.source}

-
-
- {copyError && ( -

{copyError}

- )} - - - - - -
-
-
+ copyTransfer.setCopyProgress((prev: any) => ({ ...prev, open }))} + /> + { + if (!open && !copyTransfer.resolvingConflict) copyTransfer.setCopyConflict(undefined); + }} + onDecision={(decision) => { + void copyTransfer.resolveConflictDecision(decision); + }} + /> ); }; diff --git a/packages/ui/src/modules/storage/hooks/useCopyTransfer.ts b/packages/ui/src/modules/storage/hooks/useCopyTransfer.ts new file mode 100644 index 00000000..e25bfd40 --- /dev/null +++ b/packages/ui/src/modules/storage/hooks/useCopyTransfer.ts @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { INode } from '@/modules/storage/types'; +import type { AppEvent } from '@/types-server'; +import type { FilePickerResult } from '@/modules/storage/FilePicker'; +import { vfsGetTaskStatus, vfsResolveTaskConflict, vfsStartTransferTask, type VfsTaskStatus } from '@/modules/storage/client-vfs'; + +export type CopyConflictPreset = 'ask' | 'overwrite_all' | 'skip_all' | 'if_newer' | 'rename_all' | 'error'; + +type StreamLike = { + subscribe: (cb: (event: AppEvent) => void) => () => void; +}; + +export function useCopyTransfer(params: { + mount: string; + selectedSources: INode[]; + stream?: StreamLike | null; + onCompleted: () => void; +}) { + const { mount, selectedSources, stream, onCompleted } = params; + + const [copyDialogOpen, setCopyDialogOpen] = useState(false); + const [copyIncludePatterns, setCopyIncludePatterns] = useState('**/*'); + const [copyExcludePatterns, setCopyExcludePatterns] = useState('**/node_modules/**'); + const [copyExcludeDefault, setCopyExcludeDefault] = useState(true); + const [copyConflictPreset, setCopyConflictPreset] = useState('ask'); + + const [copyBusy, setCopyBusy] = useState(false); + const [copyError, setCopyError] = useState(null); + const [copyTaskId, setCopyTaskId] = useState(null); + const [copyTaskState, setCopyTaskState] = useState(null); + const [copyConflict, setCopyConflict] = useState(undefined); + const [resolvingConflict, setResolvingConflict] = useState(false); + const [copyProgress, setCopyProgress] = useState({ + open: false, + progress: 0, + fileProgress: 0, + filesDone: 0, + totalFiles: 0, + bytesTransferred: 0, + }); + const [frozenCopySources, setFrozenCopySources] = useState([]); + + const copyEnabled = useMemo( + () => selectedSources.length > 0 && !copyBusy, + [selectedSources.length, copyBusy] + ); + + const parsePatternCsv = useCallback((raw: string): string[] => { + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + }, []); + + const handleCopyRequest = useCallback(() => { + if (!copyEnabled) return; + setFrozenCopySources(selectedSources); + setCopyError(null); + setCopyDialogOpen(true); + }, [copyEnabled, selectedSources]); + + useEffect(() => { + if (!stream || !copyTaskId) return; + const unsubscribe = stream.subscribe((event: AppEvent) => { + if (event.kind !== 'system' || event.type !== 'vfs-task') return; + if (String((event.data as any)?.id || '') !== copyTaskId) return; + + const task = event.data as unknown as VfsTaskStatus; + setCopyTaskState(task.state); + setCopyConflict(task.conflict); + setCopyProgress((prev) => ({ + ...prev, + progress: Number(task.progress || prev.progress || 0), + fileProgress: task.state === 'paused_for_conflict' + ? prev.fileProgress + : Number(task.progress || prev.fileProgress || 0), + filesDone: Number(task.filesDone || prev.filesDone || 0), + totalFiles: Number(task.totalFiles || prev.totalFiles || 0), + bytesTransferred: Number(task.bytesCopied || prev.bytesTransferred || 0), + })); + + if (task.state === 'completed' || task.state === 'failed' || task.state === 'cancelled') { + setCopyBusy(false); + setResolvingConflict(false); + if (task.state === 'completed') { + onCompleted(); + setTimeout(() => setCopyProgress((prev) => ({ ...prev, open: false })), 800); + } else { + setCopyError(task.error || `Task ${task.state}`); + } + setCopyTaskId(null); + } + }); + return unsubscribe; + }, [stream, copyTaskId, onCompleted]); + + useEffect(() => { + if (!copyTaskId || copyTaskState !== 'paused_for_conflict' || copyConflict) return; + let active = true; + const t = window.setTimeout(async () => { + try { + const status = await vfsGetTaskStatus(copyTaskId); + if (!active) return; + setCopyTaskState(status.state); + setCopyConflict(status.conflict); + setCopyProgress((prev) => ({ + ...prev, + progress: Number(status.progress || prev.progress || 0), + filesDone: Number(status.filesDone || prev.filesDone || 0), + totalFiles: Number(status.totalFiles || prev.totalFiles || 0), + bytesTransferred: Number(status.bytesCopied || prev.bytesTransferred || 0), + })); + } catch { + // no-op fallback + } + }, 350); + return () => { + active = false; + window.clearTimeout(t); + }; + }, [copyTaskId, copyTaskState, copyConflict]); + + const handleCopyConfirm = useCallback(async (result: FilePickerResult) => { + const destinationDirectory = (result.fullPath || '/').replace(/^\/+/, ''); + const includePatterns = parsePatternCsv(copyIncludePatterns); + const excludePatterns = parsePatternCsv(copyExcludePatterns); + const items = frozenCopySources; + if (items.length === 0) return; + + const conflictMode = copyConflictPreset === 'ask' ? 'manual' : 'auto'; + const conflictStrategy = copyConflictPreset === 'ask' + ? 'error' + : copyConflictPreset === 'rename_all' + ? 'rename' + : copyConflictPreset; + + setCopyBusy(true); + setCopyError(null); + setCopyConflict(undefined); + setCopyProgress({ + open: true, + progress: 0, + fileProgress: 0, + filesDone: 0, + totalFiles: items.length, + bytesTransferred: 0, + }); + + try { + const started = await vfsStartTransferTask({ + operation: 'copy', + sources: items.map((node) => ({ + mount, + path: node.path.replace(/^\/+/, ''), + })), + destination: { mount: result.mount, path: destinationDirectory }, + conflictMode, + conflictStrategy: conflictStrategy as any, + includePatterns, + excludePatterns, + excludeDefault: copyExcludeDefault, + }); + setCopyTaskId(started.taskId); + setCopyTaskState('running'); + setCopyDialogOpen(false); + } catch (err: any) { + setCopyError(err?.message || String(err)); + setCopyBusy(false); + setCopyTaskId(null); + setCopyTaskState(null); + } + }, [ + mount, + parsePatternCsv, + copyIncludePatterns, + copyExcludePatterns, + frozenCopySources, + copyConflictPreset, + copyExcludeDefault, + ]); + + const resolveConflictDecision = useCallback(async (decision: string) => { + if (!copyTaskId) return; + setResolvingConflict(true); + try { + const res = await vfsResolveTaskConflict(copyTaskId, decision); + if (res.success) { + setCopyConflict(undefined); + const status = await vfsGetTaskStatus(copyTaskId).catch(() => null); + if (status) { + setCopyTaskState(status.state); + setCopyConflict(status.conflict); + } + } else { + setCopyError('Conflict decision was not accepted. Please try again.'); + } + } finally { + setResolvingConflict(false); + } + }, [copyTaskId]); + + return { + copyEnabled, + copyDialogOpen, + setCopyDialogOpen, + copyIncludePatterns, + setCopyIncludePatterns, + copyExcludePatterns, + setCopyExcludePatterns, + copyExcludeDefault, + setCopyExcludeDefault, + copyConflictPreset, + setCopyConflictPreset, + copyBusy, + copyError, + copyTaskState, + copyConflict, + resolvingConflict, + copyProgress, + handleCopyRequest, + handleCopyConfirm, + resolveConflictDecision, + setCopyConflict, + setCopyProgress, + }; +} +