This commit is contained in:
lovebird 2026-04-07 12:27:01 +02:00
parent 33adb738f3
commit dd159af703
15 changed files with 679 additions and 150 deletions

View File

@ -0,0 +1,47 @@
export type VfsMountName = string;
export type VfsSubpath = string;
export interface VfsPathRef {
mount: VfsMountName;
path: VfsSubpath;
}
export type VfsCopyConflictMode = 'auto' | 'manual';
export type VfsCopyConflictStrategy = 'error' | 'overwrite' | 'skip' | 'rename';
export interface VfsCopyRequest {
source: VfsPathRef;
destination: VfsPathRef;
conflictMode?: VfsCopyConflictMode;
conflictStrategy?: VfsCopyConflictStrategy;
}
export interface VfsCopyConflict {
source: string;
destination: string;
suggestedPath?: string;
}
export interface VfsCopyResponse {
success: boolean;
filesCopied: number;
filesSkipped: number;
bytesCopied: number;
destinationPath: string;
}
export interface VfsCopyConflictResponse {
error: string;
conflict: VfsCopyConflict;
}
export interface VfsCopyProgressEvent {
sourceMount: string;
destinationMount: string;
sourcePath: string;
destinationPath: string;
progress: number;
filesDone: number;
totalFiles: number;
bytesCopied: number;
}

View File

@ -4,8 +4,8 @@ import { queryClient } from '@/lib/queryClient';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@/hooks/useAuth';
import { Toaster } from '@/components/ui/sonner';
import { FileBrowserProvider } from './FileBrowserContext';
import FileBrowser from './FileBrowser';
import { FileBrowserProvider } from '@/modules/storage/FileBrowserContext';
import FileBrowser from '@/modules/storage/FileBrowser';
interface FileBrowserAppProps {
allowPanels?: boolean;

View File

@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { FileBrowserPanel } from '@/apps/filebrowser/FileBrowserPanel';
import { FileBrowserPanel } from '@/modules/storage/FileBrowserPanel';
import type { INode, FileBrowserWidgetExtendedProps } from './types';
const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {

View File

@ -0,0 +1,495 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { FolderOpen, X } from 'lucide-react';
import { T, translate } from '@/i18n';
import { FileBrowserWidget } from './FileBrowserWidget';
import type { INode } from './types';
import { ConfirmationDialog } from '@/components/ConfirmationDialog';
import { vfsUrl } from './helpers';
import { getAuthHeaders } from '@/lib/db';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export type FilePickerMode = 'pick' | 'saveAs';
export type FilePickerResult = {
mount: string;
directory: string;
fileName: string;
fullPath: string;
overwrite: boolean;
mask: string;
encoded: string;
};
export type FileMaskPreset = {
label: string;
value: string;
};
const DEFAULT_MASK_PRESETS: FileMaskPreset[] = [
{ label: 'All Files (*.*)', value: '*.*' },
{ label: 'Markdown (*.md)', value: '*.md' },
{ label: 'Text (*.txt)', value: '*.txt' },
{ label: 'JSON (*.json)', value: '*.json' },
];
export function parseVfsStoredPath(val?: string): { mount: string; path: string } {
if (!val || typeof val !== 'string') {
return { mount: 'home', path: '/' };
}
const i = val.indexOf(':');
if (i <= 0) {
return { mount: 'home', path: val.startsWith('/') ? val : `/${val}` };
}
return {
mount: val.slice(0, i) || 'home',
path: val.slice(i + 1) || '/',
};
}
function normalizeDirectory(path?: string): string {
if (!path) return '/';
const withSlash = path.startsWith('/') ? path : `/${path}`;
return withSlash.replace(/\/+$/, '') || '/';
}
function splitDirectoryAndName(path?: string): { directory: string; fileName: string } {
const normalized = normalizeDirectory(path);
const parts = normalized.split('/').filter(Boolean);
if (parts.length === 0) return { directory: '/', fileName: '' };
const last = parts[parts.length - 1] || '';
if (last.includes('.')) {
const dir = `/${parts.slice(0, -1).join('/')}`;
return { directory: normalizeDirectory(dir), fileName: last };
}
return { directory: normalized, fileName: '' };
}
function hasFileLikeSegment(path: string): boolean {
const parts = normalizeDirectory(path).split('/').filter(Boolean);
if (parts.length === 0) return false;
const last = parts[parts.length - 1] || '';
return last.includes('.');
}
const PATH_HISTORY_KEY = 'storage-filepicker-path-history-v1';
const PATH_HISTORY_LIMIT = 20;
function readPathHistory(): string[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(PATH_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
} catch {
return [];
}
}
function pushPathHistory(pathValue: string) {
if (typeof window === 'undefined') return;
const value = pathValue.trim();
if (!value) return;
const existing = readPathHistory();
const next = [value, ...existing.filter((p) => p !== value)].slice(0, PATH_HISTORY_LIMIT);
localStorage.setItem(PATH_HISTORY_KEY, JSON.stringify(next));
}
const PathHistoryInput: React.FC<{
value: string;
onChange: (value: string) => void;
placeholder?: string;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ value, onChange, placeholder, onFocus, onBlur }) => {
const [history, setHistory] = useState<string[]>([]);
const listId = useMemo(() => `filepicker-history-${Math.random().toString(36).slice(2)}`, []);
useEffect(() => {
setHistory(readPathHistory());
}, []);
return (
<>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-8 text-xs font-mono"
list={listId}
onFocus={onFocus}
onBlur={onBlur}
/>
<datalist id={listId}>
{history.map((h) => (
<option key={h} value={h} />
))}
</datalist>
</>
);
};
export interface FilePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: FilePickerMode;
initialValue?: string;
initialFileName?: string;
initialOverwrite?: boolean;
initialMask?: string;
title?: string;
confirmLabel?: string;
allowClearMask?: boolean;
maskPresets?: FileMaskPreset[];
onConfirm: (result: FilePickerResult) => void;
}
export const FilePickerDialog: React.FC<FilePickerDialogProps> = ({
open,
onOpenChange,
mode = 'pick',
initialValue,
initialFileName,
initialOverwrite = false,
initialMask = '*.*',
title,
confirmLabel,
allowClearMask = true,
maskPresets = DEFAULT_MASK_PRESETS,
onConfirm,
}) => {
const parsed = useMemo(() => parseVfsStoredPath(initialValue), [initialValue]);
const initialSplit = useMemo(() => splitDirectoryAndName(parsed.path), [parsed.path]);
const [browseMount, setBrowseMount] = useState(parsed.mount);
const [browsePath, setBrowsePath] = useState(initialSplit.directory);
const [selectedNode, setSelectedNode] = useState<INode | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [manualPath, setManualPath] = useState(`${parsed.mount}:${initialFileName ? `${initialSplit.directory}/${initialFileName}` : parsed.path}`);
const [isPathInputFocused, setIsPathInputFocused] = useState(false);
const [confirmOverwriteOpen, setConfirmOverwriteOpen] = useState(false);
const [pendingResult, setPendingResult] = useState<FilePickerResult | null>(null);
const [mask, setMask] = useState(initialMask || '*.*');
useEffect(() => {
if (!open) return;
const p = parseVfsStoredPath(initialValue);
const split = splitDirectoryAndName(p.path);
setBrowseMount(p.mount);
setBrowsePath(split.directory);
setSelectedNode(null);
setSelectedPath(null);
const initialCombinedPath = initialFileName ? `${split.directory}/${initialFileName}` : p.path;
setManualPath(`${p.mount}:${initialCombinedPath}`);
setIsPathInputFocused(false);
setConfirmOverwriteOpen(false);
setPendingResult(null);
setMask(initialMask || '*.*');
}, [open, initialFileName, initialMask, initialOverwrite, initialValue]);
const buildResult = (): FilePickerResult => {
let selectedDir = mode === 'saveAs'
? normalizeDirectory(selectedNode?.type === 'dir' ? (selectedNode.path || browsePath) : browsePath)
: normalizeDirectory(browsePath);
let finalName = mode === 'saveAs'
? ((selectedNode && selectedNode.type !== 'dir' ? selectedNode.name : '') || initialFileName || 'untitled.txt')
: (selectedNode && selectedNode.type !== 'dir' ? selectedNode.name : '');
const typed = manualPath.trim();
if (typed) {
const parsedTyped = parseVfsStoredPath(typed.includes(':') ? typed : `${browseMount}:${typed}`);
const normalizedTyped = normalizeDirectory(parsedTyped.path);
if (parsedTyped.mount) {
setBrowseMount(parsedTyped.mount);
}
if (mode === 'saveAs') {
if (hasFileLikeSegment(normalizedTyped)) {
const split = splitDirectoryAndName(normalizedTyped);
selectedDir = split.directory;
if (split.fileName) finalName = split.fileName;
} else {
selectedDir = normalizedTyped;
}
} else {
if (selectedPath) {
selectedDir = selectedPath;
} else {
selectedDir = normalizedTyped;
}
}
if (parsedTyped.mount && parsedTyped.mount !== browseMount) {
selectedDir = normalizedTyped;
}
}
const fullPath = finalName ? `${selectedDir === '/' ? '' : selectedDir}/${finalName}` : selectedDir;
const finalMount = (() => {
const typedMount = parseVfsStoredPath(typed || `${browseMount}:${selectedDir}`).mount;
return typedMount || browseMount;
})();
return {
mount: finalMount,
directory: selectedDir,
fileName: finalName,
fullPath,
overwrite: false,
mask,
encoded: `${finalMount}:${fullPath}`,
};
};
const fileExists = async (candidate: FilePickerResult): Promise<boolean> => {
const url = vfsUrl('get', candidate.mount, candidate.fullPath);
const headers = await getAuthHeaders();
try {
const headRes = await fetch(url, { method: 'HEAD', headers });
if (headRes.status === 200) return true;
if (headRes.status === 404) return false;
if (headRes.status === 401 || headRes.status === 403) return false;
} catch {
// Fallback to GET below
}
try {
const getRes = await fetch(url, { cache: 'no-cache', headers });
return getRes.status === 200;
} catch {
return false;
}
};
const finalizeSelection = (result: FilePickerResult) => {
onConfirm(result);
pushPathHistory(result.encoded);
onOpenChange(false);
};
const commitSelection = async () => {
const result = buildResult();
if (mode === 'saveAs') {
const exists = await fileExists(result);
if (exists && !initialOverwrite) {
setPendingResult(result);
setConfirmOverwriteOpen(true);
return;
}
}
finalizeSelection(result);
};
const computedTitle = title || (mode === 'saveAs' ? translate('Save As') : translate('Browse Files'));
const computedConfirmLabel = confirmLabel || (mode === 'saveAs' ? translate('Save') : translate('Select'));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-w-[96vw] p-0 gap-0 flex flex-col max-h-[92vh]">
<DialogHeader className="p-4 pb-2 shrink-0">
<DialogTitle>{computedTitle}</DialogTitle>
<DialogDescription className="sr-only">
Select a target path and optional mask, then confirm to continue.
</DialogDescription>
<p className="text-xs text-muted-foreground font-mono">{manualPath}</p>
</DialogHeader>
<div className="flex-1 min-h-0 px-2 pb-2" style={{ height: 460 }}>
<FileBrowserWidget
key={browseMount}
mount={browseMount}
path={browsePath}
glob={mask || '*.*'}
onMountChange={(m: string) => {
setBrowseMount(m);
if (!isPathInputFocused) setManualPath(`${m}:${browsePath}`);
setSelectedNode(null);
setSelectedPath(null);
}}
onPathChange={(p: string) => {
setBrowsePath(p);
if (!isPathInputFocused) setManualPath(`${browseMount}:${p}`);
setSelectedNode(null);
setSelectedPath(null);
}}
onSelect={(p: string | null) => setSelectedPath(p)}
onSelectNode={(node) => {
setSelectedNode(node);
}}
viewMode="list"
mode="simple"
showToolbar={true}
sortBy="name"
canChangeMount={true}
allowFileViewer={false}
allowLightbox={false}
allowDownload={false}
jail={false}
minHeight="420px"
showStatusBar={true}
/>
</div>
<div className="p-3 border-t space-y-3 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-[1fr_240px] gap-3 items-end">
<div className="space-y-1">
<Label className="text-xs"><T>Path</T></Label>
<PathHistoryInput
value={manualPath}
onChange={(value) => {
setManualPath(value);
}}
placeholder="home:/folder/file.ext"
onFocus={() => setIsPathInputFocused(true)}
onBlur={() => setIsPathInputFocused(false)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"><T>Mask / Filter</T></Label>
<Select
value={mask}
onValueChange={setMask}
>
<SelectTrigger className="h-8 text-xs font-mono">
<SelectValue placeholder="*.*" />
</SelectTrigger>
<SelectContent className="z-[2200]">
{maskPresets.map((preset) => (
<SelectItem key={`${preset.label}-${preset.value}`} value={preset.value} className="text-xs font-mono">
{preset.label}
</SelectItem>
))}
{!maskPresets.some((preset) => preset.value === mask) && (
<SelectItem value={mask} className="text-xs font-mono">
{mask}
</SelectItem>
)}
{allowClearMask && (
<SelectItem value="*.*" className="text-xs font-mono">
Reset to *.*
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
<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')}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
<T>Cancel</T>
</Button>
<Button size="sm" onClick={() => { void commitSelection(); }}>
{computedConfirmLabel}
</Button>
</div>
</div>
</div>
</DialogContent>
<ConfirmationDialog
open={confirmOverwriteOpen}
onOpenChange={setConfirmOverwriteOpen}
title="Overwrite existing file?"
description={pendingResult ? `The file "${pendingResult.encoded}" already exists. Do you want to overwrite it?` : 'The selected file already exists.'}
confirmLabel="Overwrite"
cancelLabel="Cancel"
variant="destructive"
onConfirm={() => {
if (pendingResult) {
finalizeSelection({ ...pendingResult, overwrite: true });
setPendingResult(null);
}
setConfirmOverwriteOpen(false);
}}
onCancel={() => {
setConfirmOverwriteOpen(false);
setPendingResult(null);
}}
/>
</Dialog>
);
};
export interface FilePickerFieldProps {
value?: string;
onChange: (value?: string) => void;
disabled?: boolean;
readonly?: boolean;
mode?: FilePickerMode;
placeholder?: string;
dialogTitle?: string;
dialogConfirmLabel?: string;
initialFileName?: string;
initialOverwrite?: boolean;
initialMask?: string;
onResultChange?: (result: FilePickerResult) => void;
}
export const FilePickerField: React.FC<FilePickerFieldProps> = ({
value,
onChange,
disabled,
readonly,
mode = 'pick',
placeholder,
dialogTitle,
dialogConfirmLabel,
initialFileName,
initialOverwrite,
initialMask,
onResultChange,
}) => {
const [open, setOpen] = useState(false);
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input
readOnly
value={value || ''}
placeholder={placeholder || 'home:/path'}
className="flex-1 font-mono text-[10px] h-8"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
disabled={disabled || readonly}
onClick={() => setOpen(true)}
>
<FolderOpen className="h-3 w-3 mr-1" />
<span className="text-xs">{translate('Browse')}</span>
</Button>
{value && !readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onChange(undefined)}
title={translate('Clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<FilePickerDialog
open={open}
onOpenChange={setOpen}
mode={mode}
title={dialogTitle}
confirmLabel={dialogConfirmLabel}
initialValue={value}
initialFileName={initialFileName}
initialOverwrite={initialOverwrite}
initialMask={initialMask}
onConfirm={(result) => {
onChange(result.encoded);
if (onResultChange) onResultChange(result);
}}
/>
</div>
);
};
export default FilePickerField;

View File

@ -1,4 +1,4 @@
import { apiClient, getAuthToken, serverUrl } from '@/lib/db';
import { apiClient, getAuthHeaders, getAuthToken, serverUrl } from '@/lib/db';
import { vfsUrl } from '@/modules/storage/helpers';
import { INode } from '@/modules/storage/types';
@ -23,6 +23,39 @@ export const fetchVfsSearch = async (mount: string, path: string, query: string,
return apiClient<{ results: INode[] }>(url, { headers, cache: 'no-cache' });
};
export const writeVfsFile = async (
mount: string,
path: string,
content: string,
options: VfsOptions = {}
): Promise<{ success: boolean; path: string }> => {
const url = vfsUrl('write', mount, path);
const headers = await getAuthHeaders();
const res = await fetch(url, {
method: 'PUT',
headers,
body: content,
});
if (!res.ok) {
throw new Error(`Failed to write VFS file: HTTP ${res.status}`);
}
return res.json();
};
export const readVfsFileText = async (
mount: string,
path: string,
options: VfsOptions = {}
): Promise<string> => {
const url = vfsUrl('read', mount, path);
const headers = await getAuthHeaders();
const res = await fetch(url, { headers, cache: 'no-cache' });
if (!res.ok) {
throw new Error(`Failed to read VFS file: HTTP ${res.status}`);
}
return res.text();
};
export const vfsIndex = async (mount: string, path: string, fullText: boolean): Promise<{ message: string }> => {
const cleanPath = path.replace(/^\//, '');
let endpoint = mount === 'home'

View File

@ -2,3 +2,5 @@ export { FileBrowserWidget } from './FileBrowserWidget';
export type { FileBrowserWidgetExtendedProps, INode, SortKey, MimeCategory } from './types';
export { getMimeCategory, formatSize, formatDate, vfsUrl, sortNodes } from './helpers';
export { NodeIcon, ThumbPreview } from './ThumbPreview';
export { FilePickerDialog, FilePickerField, parseVfsStoredPath } from './FilePicker';
export type { FilePickerResult, FilePickerMode, FileMaskPreset } from './FilePicker';

View File

@ -2,7 +2,7 @@
* RJSF widgets that store app entity IDs / VFS paths (no inline rendering of linked content).
* ui:widget keys are listed in builder/appPickerWidgetOptions.ts.
*/
import React, { lazy, Suspense, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { GroupPicker } from '@/components/admin/GroupPicker';
import type { WidgetProps } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
@ -18,27 +18,10 @@ import { CategoryPickerField } from '@/components/widgets/CategoryPickerField';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
import { UserPicker } from '@/components/admin/UserPicker';
import { FilePickerField } from '@/modules/storage/FilePicker';
import { getUiOptions } from '@rjsf/utils';
const FileBrowserWidget = lazy(() =>
import('@/modules/storage/FileBrowserWidget').then((m) => ({ default: m.default }))
);
function parseVfsStored(val: string | undefined): { mount: string; path: string } {
if (!val || typeof val !== 'string') {
return { mount: 'home', path: '/' };
}
const i = val.indexOf(':');
if (i <= 0) {
return { mount: 'home', path: val.startsWith('/') ? val : `/${val}` };
}
return {
mount: val.slice(0, i) || 'home',
path: val.slice(i + 1) || '/',
};
}
export const PagePickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const [open, setOpen] = useState(false);
@ -219,127 +202,17 @@ export const PostPickerWidget = (props: WidgetProps) => {
export const FilePickerWidget = (props: WidgetProps) => {
const { id, value, disabled, readonly, onChange, onBlur, onFocus } = props;
const [open, setOpen] = useState(false);
const parsed = parseVfsStored(value as string | undefined);
const [browseMount, setBrowseMount] = useState(parsed.mount);
const [browsePath, setBrowsePath] = useState(parsed.path);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
useEffect(() => {
if (open) {
const p = parseVfsStored(value as string | undefined);
setBrowseMount(p.mount);
setBrowsePath(p.path);
setSelectedFilePath(null);
}
}, [open, value]);
const commitSelection = () => {
let finalPath = browsePath;
if (selectedFilePath && selectedFilePath !== browsePath) {
finalPath = selectedFilePath;
}
const encoded = `${browseMount}:${finalPath}`;
onChange(encoded);
setOpen(false);
setSelectedFilePath(null);
};
const display = (value as string) || '';
return (
<div className="space-y-1">
<div className="flex gap-2">
<Input
id={id}
readOnly
value={display}
placeholder="home:/path"
className="flex-1 font-mono text-[10px] h-8"
disabled={disabled}
onBlur={() => onBlur(id, value)}
onFocus={() => onFocus(id, value)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
disabled={disabled || readonly}
onClick={() => setOpen(true)}
>
<FolderOpen className="h-3 w-3 mr-1" />
<span className="text-xs">{translate('Browse')}</span>
</Button>
{value && !readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onChange(undefined)}
title={translate('Clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl max-w-[95vw] p-0 gap-0 flex flex-col max-h-[90vh]">
<DialogHeader className="p-4 pb-2 shrink-0">
<DialogTitle>
<T>Browse Files</T>
</DialogTitle>
<p className="text-xs text-muted-foreground font-mono">
{browseMount}:{browsePath}
</p>
</DialogHeader>
<div className="flex-1 min-h-0 px-2 pb-2" style={{ height: 420 }}>
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
<T>Loading</T>
</div>
}
>
<FileBrowserWidget
key={browseMount}
mount={browseMount}
path={browsePath}
onMountChange={(m: string) => {
setBrowseMount(m);
setSelectedFilePath(null);
}}
onPathChange={(p: string) => {
setBrowsePath(p);
setSelectedFilePath(null);
}}
onSelect={(p: string | null) => setSelectedFilePath(p)}
viewMode="list"
mode="simple"
showToolbar={true}
glob="*.*"
sortBy="name"
canChangeMount={true}
allowFileViewer={false}
allowLightbox={false}
allowDownload={false}
jail={false}
minHeight="380px"
showStatusBar={true}
/>
</Suspense>
</div>
<div className="p-3 border-t flex justify-end gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
<T>Cancel</T>
</Button>
<Button size="sm" onClick={commitSelection}>
<T>Select</T>
</Button>
</div>
</DialogContent>
</Dialog>
<div onBlur={() => onBlur(id, value)} onFocus={() => onFocus(id, value)}>
<FilePickerField
value={(value as string) || ''}
onChange={(nextValue) => onChange(nextValue)}
disabled={disabled}
readonly={readonly}
mode="pick"
placeholder="home:/path"
/>
</div>
);
};

View File

@ -1,15 +1,94 @@
import React from 'react';
import FileBrowser from '@/apps/filebrowser/FileBrowser';
import FileBrowser from '@/modules/storage/FileBrowser';
import { FilePickerField, parseVfsStoredPath, type FilePickerResult } from '@/modules/storage';
import { Button } from '@/components/ui/button';
import { readVfsFileText, writeVfsFile } from '@/modules/storage/client-vfs';
const PlaygroundVfs = () => {
const [pickedPath, setPickedPath] = React.useState<string>('home:/');
const [saveAsPath, setSaveAsPath] = React.useState<string>('home:/untitled.txt');
const [saveAsMeta, setSaveAsMeta] = React.useState<FilePickerResult | null>(null);
const [isWriting, setIsWriting] = React.useState(false);
const [writeStatus, setWriteStatus] = React.useState<string>('');
const [browserRefreshKey, setBrowserRefreshKey] = React.useState(0);
const handleWriteDummy = React.useCallback(async (targetPath?: string, targetMeta?: FilePickerResult | null) => {
setIsWriting(true);
setWriteStatus('');
try {
const effectivePath = targetPath || saveAsPath;
const parsed = parseVfsStoredPath(effectivePath);
const cleanPath = parsed.path.replace(/^\/+/, '');
const now = new Date().toISOString();
const payload = [
'# Save As playground',
`timestamp: ${now}`,
`target: ${parsed.mount}:${parsed.path}`,
`overwrite confirmed: ${String(targetMeta?.overwrite ?? saveAsMeta?.overwrite ?? false)}`,
'',
'dummy content',
].join('\n');
await writeVfsFile(parsed.mount, cleanPath, payload);
const roundtrip = await readVfsFileText(parsed.mount, cleanPath);
const firstLine = roundtrip.split('\n')[0] || '(empty)';
setWriteStatus(`Written and verified: ${parsed.mount}:${parsed.path} | first line: ${firstLine}`);
setBrowserRefreshKey((k) => k + 1);
} catch (err: any) {
setWriteStatus(`Write failed: ${err?.message || String(err)}`);
} finally {
setIsWriting(false);
}
}, [saveAsMeta?.overwrite, saveAsPath]);
return (
<div style={{ height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
<FileBrowser
allowPanels={false}
mode="simple"
disableRoutingSync={true}
initialMount="home"
/>
<div style={{ height: 'calc(100vh - 56px)', overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: 12, padding: 12 }}>
<div className="border rounded-md p-3 bg-card/40 space-y-3">
<div className="text-sm font-semibold">File Picker Test</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Pick existing file/folder</div>
<FilePickerField value={pickedPath} onChange={(v) => setPickedPath(v || '')} mode="pick" />
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Save As (overwrite, initial selection, masks)</div>
<FilePickerField
value={saveAsPath}
onChange={(v) => setSaveAsPath(v || '')}
mode="saveAs"
initialFileName="report.md"
initialOverwrite={false}
initialMask="*.md,*.txt,*.json"
onResultChange={(result) => {
setSaveAsMeta(result);
setSaveAsPath(result.encoded);
// In playground, Save As should materialize a file immediately.
void handleWriteDummy(result.encoded, result);
}}
/>
</div>
</div>
<div className="text-[11px] font-mono text-muted-foreground break-all">
picked: {pickedPath || '-'}<br />
saveAs: {saveAsPath || '-'}<br />
overwrite: {saveAsMeta ? String(saveAsMeta.overwrite) : '-'} | mask: {saveAsMeta?.mask || '-'}
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => { void handleWriteDummy(); }} disabled={isWriting || !saveAsPath}>
{isWriting ? 'Writing…' : 'Write dummy data (Save As target)'}
</Button>
{writeStatus && <span className="text-xs font-mono text-muted-foreground">{writeStatus}</span>}
</div>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<FileBrowser
key={browserRefreshKey}
allowPanels={false}
mode="simple"
disableRoutingSync={true}
initialMount="home"
/>
</div>
</div>
);
};