vfs:copy/move/rm

This commit is contained in:
lovebird 2026-04-07 13:30:27 +02:00
parent 1a1646dad5
commit e5b0cafed6
9 changed files with 255 additions and 12 deletions

View File

@ -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 {

View File

@ -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"));

View File

@ -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<FileBrowserPanelProps> = ({
splitSizeHorizontal,
splitSizeVertical,
onLayoutChange,
showStatusBar = true
showStatusBar = true,
allowCopyAction = true,
}) => {
const { session } = useAuth();
@ -515,6 +520,71 @@ 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 [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 || !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<FileBrowserPanelProps> = ({
isSearchMode,
onSelect,
sorted: displayNodes,
onCopyRequest: handleCopyRequest,
});
// ── Default Actions ──────────────────────────────────────────
@ -688,6 +759,8 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
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<FileBrowserPanelProps> = ({
}}
/>
)}
<FilePickerDialog
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
mode="pick"
title={translate('Copy To')}
confirmLabel={copyBusy ? translate('Copying...') : translate('Copy')}
confirmDisabled={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);
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={
<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>
}
/>
<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)}
</p>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -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<FileBrowserToolbarProps> = ({
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<FileBrowserToolbarProps> = ({
<Download size={18} />
</button>
)}
{allowCopy && onCopy && (
<button onClick={onCopy} title={translate('Copy (F5)')} className="fb-tb-btn" style={TB_BTN}>
<Copy size={18} />
</button>
)}
<div style={TB_SEP} />
</>)}
@ -317,6 +324,12 @@ const FileBrowserToolbar: React.FC<FileBrowserToolbarProps> = ({
<T>Filter</T>
</DropdownMenuItem>
)}
{allowCopy && onCopy && selectedNodes.length > 0 && (
<DropdownMenuItem onClick={onCopy}>
<Copy className="h-4 w-4 mr-2 opacity-60" />
<T>Copy</T>
</DropdownMenuItem>
)}
{/* Sort */}
<DropdownMenuItem onClick={cycleSort}>
{sortIcons[sortBy]}

View File

@ -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<FileBrowserWidgetExtendedProps> = (props) => {
navigate(`${location.pathname}?${params.toString()}`, { replace: true, state: location.state });
}, [location.pathname, location.search, location.state, navigate]);
const lastSelectionRef = useRef<string>('');
// 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<FileBrowserWidgetExtendedProps> = (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);
}

View File

@ -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<FilePickerDialogProps> = ({
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<FilePickerDialogProps> = ({
}
} 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<FilePickerDialogProps> = ({
</Select>
</div>
</div>
{extensionSlot}
<div className="flex justify-between items-center gap-2">
<span className="text-xs text-muted-foreground font-mono truncate">
{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<FilePickerDialogProps> = ({
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
<T>Cancel</T>
</Button>
<Button size="sm" onClick={() => { void commitSelection(); }}>
<Button size="sm" disabled={confirmDisabled} onClick={() => { void commitSelection(); }}>
{computedConfirmLabel}
</Button>
</div>

View File

@ -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 };
};

View File

@ -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 ─────────────────────

View File

@ -27,6 +27,7 @@ export interface UseKeyboardNavigationProps {
searchBufferRef: React.MutableRefObject<string>;
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 };