fucking
This commit is contained in:
parent
33adb738f3
commit
dd159af703
47
packages/ui/shared/src/fs/index.ts
Normal file
47
packages/ui/shared/src/fs/index.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
495
packages/ui/src/modules/storage/FilePicker.tsx
Normal file
495
packages/ui/src/modules/storage/FilePicker.tsx
Normal 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;
|
||||
@ -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'
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user