diff --git a/packages/ui/src/modules/ai/components/ChatSidebar.tsx b/packages/ui/src/modules/ai/components/ChatSidebar.tsx index bf53526f..2e61ebb6 100644 --- a/packages/ui/src/modules/ai/components/ChatSidebar.tsx +++ b/packages/ui/src/modules/ai/components/ChatSidebar.tsx @@ -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'; diff --git a/packages/ui/src/modules/profile/Profile.tsx b/packages/ui/src/modules/profile/Profile.tsx index 4a440689..6cca236b 100644 --- a/packages/ui/src/modules/profile/Profile.tsx +++ b/packages/ui/src/modules/profile/Profile.tsx @@ -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 })) diff --git a/packages/ui/src/modules/storage/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx index 489b0cd6..8601eb8f 100644 --- a/packages/ui/src/modules/storage/FileBrowserPanel.tsx +++ b/packages/ui/src/modules/storage/FileBrowserPanel.tsx @@ -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 = ({ 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; @@ -566,25 +570,65 @@ const FileBrowserPanel: React.FC = ({ }, [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 = ({ if (items.length === 0) return; setCopyBusy(true); setCopyError(null); + setCopyConflict(undefined); setCopyProgress({ open: true, progress: 0, @@ -1156,32 +1201,28 @@ const FileBrowserPanel: React.FC = ({ 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 = ({

{copyProgress.filesDone}/{copyProgress.totalFiles} · {formatSize(copyProgress.bytesTransferred)} + {copyTaskState ? ` · ${copyTaskState}` : ''}

+ {copyError &&

{copyError}

} + + + + + { if (!open && !resolvingConflict) setCopyConflict(undefined); }}> + + + Confirm File Overwrite + + A file conflict was found. Choose how to continue. + + +
+

Overwrite: {copyConflict?.destination}

+

With: {copyConflict?.source}

+
+
+ {copyError && ( +

{copyError}

+ )} + + + + +
diff --git a/packages/ui/src/modules/storage/client-vfs.ts b/packages/ui/src/modules/storage/client-vfs.ts index 6e20cfb0..6dc95ffc 100644 --- a/packages/ui/src/modules/storage/client-vfs.ts +++ b/packages/ui/src/modules/storage/client-vfs.ts @@ -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 => { + 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; +};