vfs:copy/move/delete

This commit is contained in:
lovebird 2026-04-07 17:11:34 +02:00
parent 01870e8b4d
commit 371b0e33e3
5 changed files with 459 additions and 312 deletions

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle><T>Confirm File Overwrite</T></DialogTitle>
<DialogDescription>
<T>A file conflict was found. Choose how to continue.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-xs font-mono">
<p>Overwrite: {conflict?.destination}</p>
<p>With: {conflict?.source}</p>
</div>
<div className="flex justify-end gap-2">
{error && <p className="text-xs text-red-500 mr-auto self-center">{error}</p>}
<Button size="sm" disabled={resolving} onClick={() => onDecision('overwrite')}>Yes</Button>
<Button size="sm" variant="secondary" disabled={resolving} onClick={() => onDecision('overwrite_all')}>All</Button>
<Button size="sm" variant="secondary" disabled={resolving} onClick={() => onDecision('skip')}>Skip</Button>
<Button size="sm" variant="secondary" disabled={resolving} onClick={() => onDecision('skip_all')}>Skip All</Button>
<Button size="sm" variant="destructive" disabled={resolving} onClick={() => onDecision('cancel')}>Cancel</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default CopyConflictDialog;

View File

@ -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 (
<Dialog open={open} onOpenChange={(next) => !busy && onOpenChange(next)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle><T>Copy Progress</T></DialogTitle>
<DialogDescription>
<T>Transfer progress for the current copy operation.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground"><T>Total</T> {progress}%</div>
<Progress value={progress} className="h-2" />
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground"><T>Current file</T> {fileProgress}%</div>
<Progress value={fileProgress} className="h-2" />
</div>
<p className="text-xs text-muted-foreground font-mono">
{filesDone}/{totalFiles} · {formatSize(bytesTransferred)}
{taskState ? ` · ${taskState}` : ''}
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
</DialogContent>
</Dialog>
);
};
export default CopyProgressDialog;

View File

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"><T>Include</T></Label>
<Input
value={includePatterns}
onChange={(e) => onIncludePatternsChange(e.target.value)}
placeholder="**/*"
className="h-8 text-xs font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"><T>Exclude</T></Label>
<Input
value={excludePatterns}
onChange={(e) => onExcludePatternsChange(e.target.value)}
placeholder="**/node_modules/**"
className="h-8 text-xs font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"><T>Conflict handling</T></Label>
<Select value={conflictPreset} onValueChange={(v) => onConflictPresetChange(v as CopyConflictPreset)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask per conflict</SelectItem>
<SelectItem value="overwrite_all">Overwrite all</SelectItem>
<SelectItem value="skip_all">Skip all</SelectItem>
<SelectItem value="if_newer">Only if newer</SelectItem>
<SelectItem value="rename_all">Rename all</SelectItem>
<SelectItem value="error">Stop on first conflict</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-between border rounded-md px-3 py-2">
<Label className="text-xs"><T>Exclude defaults</T></Label>
<Switch checked={excludeDefault} onCheckedChange={onExcludeDefaultChange} />
</div>
{error && (
<p className="text-xs text-red-500 md:col-span-2">{error}</p>
)}
</div>
);
};
export default CopyTransferOptions;

View File

@ -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<FileBrowserPanelProps> = ({
// ── Default Selection Handler (first so we get wrapped goUp) ──
const [pendingFileSelect, setPendingFileSelect] = useState<string | null>(null);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [copyIncludePatterns, setCopyIncludePatterns] = useState<string>('**/*');
const [copyExcludePatterns, setCopyExcludePatterns] = useState<string>('**/node_modules/**');
const [copyExcludeDefault, setCopyExcludeDefault] = useState(true);
const [copyBusy, setCopyBusy] = useState(false);
const [copyError, setCopyError] = useState<string | null>(null);
const [copyTaskId, setCopyTaskId] = useState<string | null>(null);
const [copyTaskState, setCopyTaskState] = useState<VfsTaskStatus['state'] | null>(null);
const [copyConflict, setCopyConflict] = useState<VfsTaskStatus['conflict'] | undefined>(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<INode[]>([]);
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<FileBrowserPanelProps> = ({
isSearchMode,
onSelect,
sorted: displayNodes,
onCopyRequest: handleCopyRequest,
onCopyRequest: copyTransfer.handleCopyRequest,
});
// ── Default Actions ──────────────────────────────────────────
@ -804,7 +708,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
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<FileBrowserPanelProps> = ({
)}
<FilePickerDialog
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
open={copyTransfer.copyDialogOpen}
onOpenChange={copyTransfer.setCopyDialogOpen}
mode="pick"
title={translate('Copy To')}
confirmLabel={copyBusy ? translate('Copying...') : translate('Copy')}
confirmDisabled={copyBusy}
confirmLabel={copyTransfer.copyBusy ? translate('Copying...') : translate('Copy')}
confirmDisabled={copyTransfer.copyBusy}
initialValue={`${mount}:${currentPath || '/'}`}
initialMask="*.*"
onConfirm={async (result: FilePickerResult) => {
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={
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs"><T>Include</T></Label>
<Input
value={copyIncludePatterns}
onChange={(e) => setCopyIncludePatterns(e.target.value)}
placeholder="**/*"
className="h-8 text-xs font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"><T>Exclude</T></Label>
<Input
value={copyExcludePatterns}
onChange={(e) => setCopyExcludePatterns(e.target.value)}
placeholder="**/node_modules/**"
className="h-8 text-xs font-mono"
/>
</div>
<div className="flex items-end justify-between border rounded-md px-3 py-2">
<Label className="text-xs"><T>Exclude defaults</T></Label>
<Switch checked={copyExcludeDefault} onCheckedChange={setCopyExcludeDefault} />
</div>
{copyError && (
<p className="text-xs text-red-500 md:col-span-3">{copyError}</p>
)}
</div>
<CopyTransferOptions
includePatterns={copyTransfer.copyIncludePatterns}
excludePatterns={copyTransfer.copyExcludePatterns}
excludeDefault={copyTransfer.copyExcludeDefault}
conflictPreset={copyTransfer.copyConflictPreset}
onIncludePatternsChange={copyTransfer.setCopyIncludePatterns}
onExcludePatternsChange={copyTransfer.setCopyExcludePatterns}
onExcludeDefaultChange={copyTransfer.setCopyExcludeDefault}
onConflictPresetChange={copyTransfer.setCopyConflictPreset}
error={copyTransfer.copyError}
/>
}
/>
<Dialog open={copyProgress.open} onOpenChange={(open) => !copyBusy && setCopyProgress((prev) => ({ ...prev, open }))}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle><T>Copy Progress</T></DialogTitle>
<DialogDescription>
<T>Transfer progress for the current copy operation.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground"><T>Total</T> {copyProgress.progress}%</div>
<Progress value={copyProgress.progress} className="h-2" />
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground"><T>Current file</T> {copyProgress.fileProgress}%</div>
<Progress value={copyProgress.fileProgress} className="h-2" />
</div>
<p className="text-xs text-muted-foreground font-mono">
{copyProgress.filesDone}/{copyProgress.totalFiles} · {formatSize(copyProgress.bytesTransferred)}
{copyTaskState ? ` · ${copyTaskState}` : ''}
</p>
{copyError && <p className="text-xs text-red-500">{copyError}</p>}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(copyConflict)} onOpenChange={(open) => { if (!open && !resolvingConflict) setCopyConflict(undefined); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle><T>Confirm File Overwrite</T></DialogTitle>
<DialogDescription>
<T>A file conflict was found. Choose how to continue.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-xs font-mono">
<p>Overwrite: {copyConflict?.destination}</p>
<p>With: {copyConflict?.source}</p>
</div>
<div className="flex justify-end gap-2">
{copyError && (
<p className="text-xs text-red-500 mr-auto self-center">{copyError}</p>
)}
<Button size="sm" disabled={resolvingConflict} onClick={async () => {
if (!copyTaskId) return;
setResolvingConflict(true);
try {
const res = await vfsResolveTaskConflict(copyTaskId, 'overwrite');
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); }
}}>Yes</Button>
<Button size="sm" variant="secondary" disabled={resolvingConflict} onClick={async () => {
if (!copyTaskId) return;
setResolvingConflict(true);
try {
const res = await vfsResolveTaskConflict(copyTaskId, 'overwrite_all');
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); }
}}>All</Button>
<Button size="sm" variant="secondary" disabled={resolvingConflict} onClick={async () => {
if (!copyTaskId) return;
setResolvingConflict(true);
try {
const res = await vfsResolveTaskConflict(copyTaskId, 'skip');
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); }
}}>Skip</Button>
<Button size="sm" variant="secondary" disabled={resolvingConflict} onClick={async () => {
if (!copyTaskId) return;
setResolvingConflict(true);
try {
const res = await vfsResolveTaskConflict(copyTaskId, 'skip_all');
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); }
}}>Skip All</Button>
<Button size="sm" variant="destructive" disabled={resolvingConflict} onClick={async () => {
if (!copyTaskId) return;
setResolvingConflict(true);
try {
const res = await vfsResolveTaskConflict(copyTaskId, 'cancel');
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); }
}}>Cancel</Button>
</div>
</DialogContent>
</Dialog>
<CopyProgressDialog
open={copyTransfer.copyProgress.open}
busy={copyTransfer.copyBusy}
progress={copyTransfer.copyProgress.progress}
fileProgress={copyTransfer.copyProgress.fileProgress}
filesDone={copyTransfer.copyProgress.filesDone}
totalFiles={copyTransfer.copyProgress.totalFiles}
bytesTransferred={copyTransfer.copyProgress.bytesTransferred}
taskState={copyTransfer.copyTaskState}
error={copyTransfer.copyError}
onOpenChange={(open) => copyTransfer.setCopyProgress((prev: any) => ({ ...prev, open }))}
/>
<CopyConflictDialog
open={Boolean(copyTransfer.copyConflict)}
conflict={copyTransfer.copyConflict}
resolving={copyTransfer.resolvingConflict}
error={copyTransfer.copyError}
onOpenChange={(open) => {
if (!open && !copyTransfer.resolvingConflict) copyTransfer.setCopyConflict(undefined);
}}
onDecision={(decision) => {
void copyTransfer.resolveConflictDecision(decision);
}}
/>
</div>
);
};

View File

@ -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<string>('**/*');
const [copyExcludePatterns, setCopyExcludePatterns] = useState<string>('**/node_modules/**');
const [copyExcludeDefault, setCopyExcludeDefault] = useState(true);
const [copyConflictPreset, setCopyConflictPreset] = useState<CopyConflictPreset>('ask');
const [copyBusy, setCopyBusy] = useState(false);
const [copyError, setCopyError] = useState<string | null>(null);
const [copyTaskId, setCopyTaskId] = useState<string | null>(null);
const [copyTaskState, setCopyTaskState] = useState<VfsTaskStatus['state'] | null>(null);
const [copyConflict, setCopyConflict] = useState<VfsTaskStatus['conflict'] | undefined>(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<INode[]>([]);
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,
};
}