From e5b0cafed65f75adeb5e298ebffa01223905d508 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 7 Apr 2026 13:30:27 +0200 Subject: [PATCH] vfs:copy/move/rm --- packages/ui/shared/src/fs/index.ts | 5 +- packages/ui/src/App.tsx | 2 +- .../src/modules/storage/FileBrowserPanel.tsx | 186 +++++++++++++++++- .../modules/storage/FileBrowserToolbar.tsx | 15 +- .../src/modules/storage/FileBrowserWidget.tsx | 8 +- .../ui/src/modules/storage/FilePicker.tsx | 13 +- packages/ui/src/modules/storage/client-vfs.ts | 20 ++ .../hooks/useDefaultKeyboardHandler.ts | 5 +- .../storage/hooks/useKeyboardNavigation.ts | 13 +- 9 files changed, 255 insertions(+), 12 deletions(-) diff --git a/packages/ui/shared/src/fs/index.ts b/packages/ui/shared/src/fs/index.ts index eb2f4fd6..c4c9e256 100644 --- a/packages/ui/shared/src/fs/index.ts +++ b/packages/ui/shared/src/fs/index.ts @@ -12,7 +12,8 @@ export type VfsTransferOperation = 'copy' | 'move' | 'delete'; export interface VfsCopyRequest { operation?: VfsTransferOperation; - source: VfsPathRef; + source?: VfsPathRef; + sources?: VfsPathRef[]; destination?: VfsPathRef; conflictMode?: VfsCopyConflictMode; conflictStrategy?: VfsCopyConflictStrategy; @@ -32,7 +33,7 @@ export interface VfsCopyResponse { filesCopied: number; filesSkipped: number; bytesCopied: number; - destinationPath: string; + destinationPath?: string; } export interface VfsCopyConflictResponse { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2b44ff06..65dd00db 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -89,7 +89,7 @@ if (enablePlaygrounds) { Tetris = React.lazy(() => import("./apps/tetris/Tetris")); -FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); +FileBrowser = React.lazy(() => import("./modules/storage/FileBrowser")); const VersionMap = React.lazy(() => import("./pages/VersionMap")); const UserCollections = React.lazy(() => import("./pages/UserCollections")); diff --git a/packages/ui/src/modules/storage/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx index 52139369..489b0cd6 100644 --- a/packages/ui/src/modules/storage/FileBrowserPanel.tsx +++ b/packages/ui/src/modules/storage/FileBrowserPanel.tsx @@ -22,10 +22,13 @@ 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 } from '@/i18n'; +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 { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; import { useSelection } from '@/modules/storage/hooks/useSelection'; @@ -78,6 +81,7 @@ export interface FileBrowserPanelProps { splitSizeVertical?: number[]; onLayoutChange?: (sizes: number[], direction: 'horizontal' | 'vertical') => void; showStatusBar?: boolean; + allowCopyAction?: boolean; } // ── Main Component ─────────────────────────────────────────────── @@ -117,7 +121,8 @@ const FileBrowserPanel: React.FC = ({ splitSizeHorizontal, splitSizeVertical, onLayoutChange, - showStatusBar = true + showStatusBar = true, + allowCopyAction = true, }) => { const { session } = useAuth(); @@ -515,6 +520,71 @@ const FileBrowserPanel: React.FC = ({ // ── Default Selection Handler (first so we get wrapped goUp) ── const [pendingFileSelect, setPendingFileSelect] = useState(null); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); + const [copyIncludePatterns, setCopyIncludePatterns] = useState('**/*'); + const [copyExcludePatterns, setCopyExcludePatterns] = useState('**/node_modules/**'); + const [copyExcludeDefault, setCopyExcludeDefault] = useState(true); + const [copyBusy, setCopyBusy] = useState(false); + const [copyError, setCopyError] = useState(null); + 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([]); + + 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 || !copyProgress.open) 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; + 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 || ''), + })); + }); + return unsubscribe; + }, [stream, copyProgress.open, mount]); const { goUp } = useDefaultSelectionHandler({ sorted: displayNodes, @@ -580,6 +650,7 @@ const FileBrowserPanel: React.FC = ({ isSearchMode, onSelect, sorted: displayNodes, + onCopyRequest: handleCopyRequest, }); // ── Default Actions ────────────────────────────────────────── @@ -688,6 +759,8 @@ const FileBrowserPanel: React.FC = ({ handleView={handleView} handleDownload={handleDownload} allowDownload={allowDownload && selected.length > 0} + allowCopy={copyEnabled} + onCopy={handleCopyRequest} handleDownloadDir={handleDownloadDir} allowDownloadDir={allowDownload} sortBy={sortBy} @@ -1056,6 +1129,115 @@ const FileBrowserPanel: React.FC = ({ }} /> )} + + { + 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); + setCopyProgress({ + open: true, + progress: 0, + fileProgress: 0, + filesDone: 0, + totalFiles: items.length, + bytesTransferred: 0, + }); + try { + const res = await vfsCopyOperation({ + operation: 'copy', + sources: items.map((node) => ({ + mount, + path: node.path.replace(/^\/+/, ''), + })), + destination: { mount: result.mount, path: destinationDirectory }, + conflictMode: 'auto', + conflictStrategy: 'if_newer', + includePatterns, + excludePatterns, + excludeDefault: copyExcludeDefault, + }); + if (res.status === 409) { + const conflict = (res.data as any)?.conflict; + throw new Error(`Copy conflict: ${conflict?.destination || 'destination exists'}`); + } + setCopyDialogOpen(false); + fetchDir(currentPath || '/'); + } catch (err: any) { + setCopyError(err?.message || String(err)); + } finally { + setCopyBusy(false); + setTimeout(() => { + setCopyProgress((prev) => ({ ...prev, open: false })); + }, 800); + } + }} + extensionSlot={ +
+
+ + setCopyIncludePatterns(e.target.value)} + placeholder="**/*" + className="h-8 text-xs font-mono" + /> +
+
+ + setCopyExcludePatterns(e.target.value)} + placeholder="**/node_modules/**" + className="h-8 text-xs font-mono" + /> +
+
+ + +
+ {copyError && ( +

{copyError}

+ )} +
+ } + /> + + !copyBusy && setCopyProgress((prev) => ({ ...prev, open }))}> + + + Copy Progress + + Transfer progress for the current copy operation. + + +
+
+
Total {copyProgress.progress}%
+ +
+
+
Current file {copyProgress.fileProgress}%
+ +
+

+ {copyProgress.filesDone}/{copyProgress.totalFiles} · {formatSize(copyProgress.bytesTransferred)} +

+
+
+
); }; diff --git a/packages/ui/src/modules/storage/FileBrowserToolbar.tsx b/packages/ui/src/modules/storage/FileBrowserToolbar.tsx index 1e12fc27..0f00b044 100644 --- a/packages/ui/src/modules/storage/FileBrowserToolbar.tsx +++ b/packages/ui/src/modules/storage/FileBrowserToolbar.tsx @@ -38,6 +38,8 @@ interface FileBrowserToolbarProps { handleView: () => void; handleDownload: () => void; allowDownload: boolean; + allowCopy?: boolean; + onCopy?: () => void; handleDownloadDir?: () => void; allowDownloadDir?: boolean; sortBy: SortKey; @@ -79,7 +81,7 @@ const FileBrowserToolbar: React.FC = ({ canChangeMount, availableMounts, mount, updateMount, mountProp, pathProp, updatePath, breadcrumbs, - selectedFile, selectedNode, selectedNodes, handleView, handleDownload, allowDownload, + selectedFile, selectedNode, selectedNodes, handleView, handleDownload, allowDownload, allowCopy = false, onCopy, handleDownloadDir, allowDownloadDir, sortBy, sortAsc, cycleSort, zoomIn, zoomOut, @@ -227,6 +229,11 @@ const FileBrowserToolbar: React.FC = ({ )} + {allowCopy && onCopy && ( + + )}
)} @@ -317,6 +324,12 @@ const FileBrowserToolbar: React.FC = ({ Filter )} + {allowCopy && onCopy && selectedNodes.length > 0 && ( + + + Copy + + )} {/* Sort */} {sortIcons[sortBy]} diff --git a/packages/ui/src/modules/storage/FileBrowserWidget.tsx b/packages/ui/src/modules/storage/FileBrowserWidget.tsx index 7fac2d4b..88da443e 100644 --- a/packages/ui/src/modules/storage/FileBrowserWidget.tsx +++ b/packages/ui/src/modules/storage/FileBrowserWidget.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { FileBrowserPanel } from '@/modules/storage/FileBrowserPanel'; import type { INode, FileBrowserWidgetExtendedProps } from './types'; @@ -60,6 +60,8 @@ const FileBrowserWidget: React.FC = (props) => { navigate(`${location.pathname}?${params.toString()}`, { replace: true, state: location.state }); }, [location.pathname, location.search, location.state, navigate]); + const lastSelectionRef = useRef(''); + // Map internal selection to simple single-path selection for CMS compatibility const handleSelect = useCallback((selection: INode[] | INode | null) => { let firstNode: INode | null = null; @@ -73,6 +75,10 @@ const FileBrowserWidget: React.FC = (props) => { firstNode = selection; } + const selectionKey = allNodes.map(n => n.path).join(','); + if (selectionKey === lastSelectionRef.current) return; + lastSelectionRef.current = selectionKey; + if (onSelect) { onSelect(firstNode ? firstNode.path : null); } diff --git a/packages/ui/src/modules/storage/FilePicker.tsx b/packages/ui/src/modules/storage/FilePicker.tsx index 1ef4a56c..0eaba820 100644 --- a/packages/ui/src/modules/storage/FilePicker.tsx +++ b/packages/ui/src/modules/storage/FilePicker.tsx @@ -145,6 +145,8 @@ export interface FilePickerDialogProps { confirmLabel?: string; allowClearMask?: boolean; maskPresets?: FileMaskPreset[]; + extensionSlot?: React.ReactNode; + confirmDisabled?: boolean; onConfirm: (result: FilePickerResult) => void; } @@ -160,6 +162,8 @@ export const FilePickerDialog: React.FC = ({ confirmLabel, allowClearMask = true, maskPresets = DEFAULT_MASK_PRESETS, + extensionSlot, + confirmDisabled = false, onConfirm, }) => { const parsed = useMemo(() => parseVfsStoredPath(initialValue), [initialValue]); @@ -215,7 +219,11 @@ export const FilePickerDialog: React.FC = ({ } } else { if (selectedPath) { - selectedDir = selectedPath; + if (mode === 'pick' && selectedNode && selectedNode.type !== 'dir') { + selectedDir = normalizeDirectory(browsePath); + } else { + selectedDir = selectedPath; + } } else { selectedDir = normalizedTyped; } @@ -370,6 +378,7 @@ export const FilePickerDialog: React.FC = ({
+ {extensionSlot}
{selectedPath ? `Selected: ${browseMount}:${selectedPath}` : (mode === 'saveAs' ? 'Type full path or select target' : 'Select file or folder')} @@ -378,7 +387,7 @@ export const FilePickerDialog: React.FC = ({ -
diff --git a/packages/ui/src/modules/storage/client-vfs.ts b/packages/ui/src/modules/storage/client-vfs.ts index ba1558e6..6e20cfb0 100644 --- a/packages/ui/src/modules/storage/client-vfs.ts +++ b/packages/ui/src/modules/storage/client-vfs.ts @@ -1,6 +1,7 @@ import { apiClient, getAuthHeaders, getAuthToken, serverUrl } from '@/lib/db'; import { vfsUrl } from '@/modules/storage/helpers'; import { INode } from '@/modules/storage/types'; +import type { VfsCopyRequest, VfsCopyResponse, VfsCopyConflictResponse } from '@polymech/shared/src/fs'; export interface VfsOptions { accessToken?: string; @@ -109,3 +110,22 @@ export const subscribeToVfsIndexStream = async ( return eventSource; }; + +export const vfsCopyOperation = async ( + payload: VfsCopyRequest +): Promise<{ status: number; data: VfsCopyResponse | VfsCopyConflictResponse }> => { + const headers = await getAuthHeaders(); + const res = await fetch('/api/vfs/copy', { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok && res.status !== 409) { + throw new Error((data && (data.error || data.message)) || `Copy operation failed: HTTP ${res.status}`); + } + return { status: res.status, data }; +}; diff --git a/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts b/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts index 49971893..450122ff 100644 --- a/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts +++ b/packages/ui/src/modules/storage/hooks/useDefaultKeyboardHandler.ts @@ -51,6 +51,7 @@ export interface UseDefaultKeyboardHandlerProps { // External onSelect for pending search selection onSelect?: (nodes: INode[] | INode | null) => void; sorted: INode[]; + onCopyRequest?: () => void; } export function useDefaultKeyboardHandler({ @@ -85,6 +86,7 @@ export function useDefaultKeyboardHandler({ isSearchMode, onSelect, sorted, + onCopyRequest, }: UseDefaultKeyboardHandlerProps) { // ── Search state ───────────────────────────────────────────── @@ -165,7 +167,8 @@ export function useDefaultKeyboardHandler({ cycleSort, searchBufferRef, setSearchDisplay, - clearSelection + clearSelection, + onCopyRequest, }); // ── Focus management on view mode change ───────────────────── diff --git a/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts b/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts index 732b8fee..51d75b40 100644 --- a/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts +++ b/packages/ui/src/modules/storage/hooks/useKeyboardNavigation.ts @@ -27,6 +27,7 @@ export interface UseKeyboardNavigationProps { searchBufferRef: React.MutableRefObject; setSearchDisplay: (val: string) => void; clearSelection: () => void; + onCopyRequest?: () => void; } export function useKeyboardNavigation({ @@ -53,7 +54,8 @@ export function useKeyboardNavigation({ cycleSort, searchBufferRef, setSearchDisplay, - clearSelection + clearSelection, + onCopyRequest }: UseKeyboardNavigationProps) { const moveFocus = useCallback((next: number, shift: boolean, ctrl: boolean) => { @@ -251,6 +253,13 @@ export function useKeyboardNavigation({ } break; } + case 'F5': { + if (onCopyRequest) { + e.preventDefault(); + onCopyRequest(); + } + break; + } case '1': { if (e.altKey) { e.preventDefault(); @@ -324,7 +333,7 @@ export function useKeyboardNavigation({ }, [ viewMode, itemCount, getGridCols, currentGlob, showFolders, setTempGlob, setTempShowFolders, setFilterDialogOpen, moveFocus, focusIdx, searchBufferRef, getNode, setSearchDisplay, goUp, - updatePath, openPreview, selected, setSelected, clearSelection, setViewMode, setDisplayMode, cycleSort + updatePath, openPreview, selected, setSelected, clearSelection, setViewMode, setDisplayMode, cycleSort, onCopyRequest ]); return { handleKeyDown, moveFocus };