vfs:copy/move/rm
This commit is contained in:
parent
1a1646dad5
commit
e5b0cafed6
@ -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 {
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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]}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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 ─────────────────────
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user