vfs:copy/move/delete
This commit is contained in:
parent
e5b0cafed6
commit
01870e8b4d
@ -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';
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user