vfs:copy/move/delete

This commit is contained in:
lovebird 2026-04-07 15:40:20 +02:00
parent e5b0cafed6
commit 01870e8b4d
4 changed files with 241 additions and 29 deletions

View File

@ -8,11 +8,10 @@ import { Plus, X, Copy, Check, Braces } from 'lucide-react';
import { T, translate } from '@/i18n';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ProviderSelector } from '@/components/filters/ProviderSelector';
import CollapsibleSection from '@/components/CollapsibleSection';
import ChatLogBrowser, { CompactTreeView } from '@/components/ChatLogBrowser';
import FileBrowser from '@/apps/filebrowser/FileBrowser';
import FileBrowser from '@/modules/storage/FileBrowser';
import ToolSections from './ToolSections';
import type { ChatMessage, FileContext } from '../types';
import type { INode } from '@/modules/storage/types';

View File

@ -20,7 +20,7 @@ import { ApiKeysSettings } from "@/modules/profile/components/ApiKeysSettings";
import { ProfileGallery } from "@/modules/profile/components/ProfileGallery";
// Lazy Loaded Modules
const FileBrowser = React.lazy(() => import('@/apps/filebrowser/FileBrowser'));
const FileBrowser = React.lazy(() => import('@/modules/storage/FileBrowser'));
const LazyPurchasesList = React.lazy(() =>
import("@polymech/ecommerce").then(m => ({ default: m.PurchasesList }))

View File

@ -28,7 +28,7 @@ import { T, translate } from '@/i18n';
import { useOptionalStream } from '@/contexts/StreamContext';
import type { AppEvent } from '@/types-server';
import { FilePickerDialog, type FilePickerResult } from './FilePicker';
import { vfsCopyOperation } from './client-vfs';
import { vfsStartTransferTask, vfsResolveTaskConflict, vfsGetTaskStatus, type VfsTaskStatus } from './client-vfs';
import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter';
import { useSelection } from '@/modules/storage/hooks/useSelection';
@ -526,6 +526,10 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
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;
@ -566,25 +570,65 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
}, [copyEnabled, selectedCopyCandidates]);
useEffect(() => {
if (!stream || !copyProgress.open) return;
if (!stream || !copyTaskId) return;
const unsubscribe = stream.subscribe((event: AppEvent) => {
if (event.kind !== 'system' || event.type !== 'vfs-copy') return;
if (String(event.data?.operation || 'copy') !== 'copy') return;
const srcMount = String(event.data?.sourceMount || '');
if (srcMount !== mount) return;
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(event.data?.progress || prev.progress || 0),
fileProgress: Number(event.data?.fileProgress || prev.fileProgress || 0),
filesDone: Number(event.data?.filesDone || prev.filesDone || 0),
totalFiles: Number(event.data?.totalFiles || prev.totalFiles || 0),
bytesTransferred: Number(event.data?.bytesTransferred || event.data?.bytesCopied || prev.bytesTransferred || 0),
sourcePath: String(event.data?.sourcePath || prev.sourcePath || ''),
destinationPath: String(event.data?.destinationPath || prev.destinationPath || ''),
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, copyProgress.open, mount]);
}, [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 { goUp } = useDefaultSelectionHandler({
sorted: displayNodes,
@ -1147,6 +1191,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
if (items.length === 0) return;
setCopyBusy(true);
setCopyError(null);
setCopyConflict(undefined);
setCopyProgress({
open: true,
progress: 0,
@ -1156,32 +1201,28 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
bytesTransferred: 0,
});
try {
const res = await vfsCopyOperation({
const started = await vfsStartTransferTask({
operation: 'copy',
sources: items.map((node) => ({
mount,
path: node.path.replace(/^\/+/, ''),
})),
destination: { mount: result.mount, path: destinationDirectory },
conflictMode: 'auto',
conflictStrategy: 'if_newer',
conflictMode: 'manual',
conflictStrategy: 'error',
includePatterns,
excludePatterns,
excludeDefault: copyExcludeDefault,
});
if (res.status === 409) {
const conflict = (res.data as any)?.conflict;
throw new Error(`Copy conflict: ${conflict?.destination || 'destination exists'}`);
}
setCopyTaskId(started.taskId);
setCopyTaskState('running');
setCopyDialogOpen(false);
fetchDir(currentPath || '/');
} catch (err: any) {
setCopyError(err?.message || String(err));
} finally {
setCopyBusy(false);
setTimeout(() => {
setCopyProgress((prev) => ({ ...prev, open: false }));
}, 800);
setCopyTaskId(null);
setCopyTaskState(null);
} finally {
}
}}
extensionSlot={
@ -1234,7 +1275,114 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
</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>

View File

@ -129,3 +129,68 @@ export const vfsCopyOperation = async (
}
return { status: res.status, data };
};
export interface VfsTaskStatus {
id: string;
ownerId: string;
operation: 'copy' | 'move' | 'delete';
state: 'running' | 'paused_for_conflict' | 'completed' | 'failed' | 'cancelled';
progress: number;
filesDone: number;
totalFiles: number;
bytesCopied: number;
error?: string;
conflict?: {
source: string;
destination: string;
suggestedPath: string;
};
}
export const vfsStartTransferTask = async (payload: VfsCopyRequest): Promise<{ taskId: string }> => {
const headers = await getAuthHeaders();
const res = await fetch('/api/vfs/transfer', {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error((data && (data.error || data.message)) || `Failed to start transfer: HTTP ${res.status}`);
}
return data;
};
export const vfsResolveTaskConflict = async (taskId: string, decision: string): Promise<{ success: boolean }> => {
const headers = await getAuthHeaders();
const res = await fetch(`/api/vfs/tasks/${encodeURIComponent(taskId)}/resolve`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({ decision }),
});
const data = await res.json();
if (!res.ok) {
throw new Error((data && (data.error || data.message)) || `Failed to resolve conflict: HTTP ${res.status}`);
}
return data;
};
export const vfsGetTaskStatus = async (taskId: string): Promise<VfsTaskStatus> => {
const headers = await getAuthHeaders();
const res = await fetch(`/api/vfs/tasks/${encodeURIComponent(taskId)}`, {
method: 'GET',
headers,
cache: 'no-cache',
});
const data = await res.json();
if (!res.ok) {
throw new Error((data && (data.error || data.message)) || `Failed to load task status: HTTP ${res.status}`);
}
return data as VfsTaskStatus;
};