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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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}
- )}
-
+
}
/>
-
-
-
+ 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,
+ };
+}
+