vfs:copy/move/delete
This commit is contained in:
parent
01870e8b4d
commit
371b0e33e3
41
packages/ui/src/modules/storage/CopyConflictDialog.tsx
Normal file
41
packages/ui/src/modules/storage/CopyConflictDialog.tsx
Normal 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;
|
||||
|
||||
60
packages/ui/src/modules/storage/CopyProgressDialog.tsx
Normal file
60
packages/ui/src/modules/storage/CopyProgressDialog.tsx
Normal 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;
|
||||
|
||||
78
packages/ui/src/modules/storage/CopyTransferOptions.tsx
Normal file
78
packages/ui/src/modules/storage/CopyTransferOptions.tsx
Normal 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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
227
packages/ui/src/modules/storage/hooks/useCopyTransfer.ts
Normal file
227
packages/ui/src/modules/storage/hooks/useCopyTransfer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user