From dd159af7033234465bb30d5b48e1043a15f4834d Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 7 Apr 2026 12:27:01 +0200 Subject: [PATCH] fucking --- packages/ui/shared/src/fs/index.ts | 47 ++ .../src/apps/filebrowser/FileBrowserApp.tsx | 4 +- .../storage}/FileBrowser.tsx | 0 .../storage}/FileBrowserContext.tsx | 0 .../storage}/FileBrowserPanel.tsx | 0 .../src/modules/storage/FileBrowserWidget.tsx | 2 +- .../ui/src/modules/storage/FilePicker.tsx | 495 ++++++++++++++++++ .../storage}/FileTree.tsx | 0 .../storage}/LayoutToolbar.tsx | 0 .../storage}/PanelSide.tsx | 0 .../storage}/SearchDialog.tsx | 0 packages/ui/src/modules/storage/client-vfs.ts | 35 +- packages/ui/src/modules/storage/index.ts | 2 + .../ui/src/modules/types/AppTypeWidgets.tsx | 149 +----- packages/ui/src/pages/PlaygroundVfs.tsx | 95 +++- 15 files changed, 679 insertions(+), 150 deletions(-) create mode 100644 packages/ui/shared/src/fs/index.ts rename packages/ui/src/{apps/filebrowser => modules/storage}/FileBrowser.tsx (100%) rename packages/ui/src/{apps/filebrowser => modules/storage}/FileBrowserContext.tsx (100%) rename packages/ui/src/{apps/filebrowser => modules/storage}/FileBrowserPanel.tsx (100%) create mode 100644 packages/ui/src/modules/storage/FilePicker.tsx rename packages/ui/src/{apps/filebrowser => modules/storage}/FileTree.tsx (100%) rename packages/ui/src/{apps/filebrowser => modules/storage}/LayoutToolbar.tsx (100%) rename packages/ui/src/{apps/filebrowser => modules/storage}/PanelSide.tsx (100%) rename packages/ui/src/{apps/filebrowser => modules/storage}/SearchDialog.tsx (100%) diff --git a/packages/ui/shared/src/fs/index.ts b/packages/ui/shared/src/fs/index.ts new file mode 100644 index 00000000..4b5803a3 --- /dev/null +++ b/packages/ui/shared/src/fs/index.ts @@ -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; +} diff --git a/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx index 8e81b404..e6c6dafe 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx @@ -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; diff --git a/packages/ui/src/apps/filebrowser/FileBrowser.tsx b/packages/ui/src/modules/storage/FileBrowser.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/FileBrowser.tsx rename to packages/ui/src/modules/storage/FileBrowser.tsx diff --git a/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx b/packages/ui/src/modules/storage/FileBrowserContext.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/FileBrowserContext.tsx rename to packages/ui/src/modules/storage/FileBrowserContext.tsx diff --git a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx rename to packages/ui/src/modules/storage/FileBrowserPanel.tsx diff --git a/packages/ui/src/modules/storage/FileBrowserWidget.tsx b/packages/ui/src/modules/storage/FileBrowserWidget.tsx index 2430132e..7fac2d4b 100644 --- a/packages/ui/src/modules/storage/FileBrowserWidget.tsx +++ b/packages/ui/src/modules/storage/FileBrowserWidget.tsx @@ -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 = (props) => { diff --git a/packages/ui/src/modules/storage/FilePicker.tsx b/packages/ui/src/modules/storage/FilePicker.tsx new file mode 100644 index 00000000..1ef4a56c --- /dev/null +++ b/packages/ui/src/modules/storage/FilePicker.tsx @@ -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([]); + const listId = useMemo(() => `filepicker-history-${Math.random().toString(36).slice(2)}`, []); + + useEffect(() => { + setHistory(readPathHistory()); + }, []); + + return ( + <> + onChange(e.target.value)} + placeholder={placeholder} + className="h-8 text-xs font-mono" + list={listId} + onFocus={onFocus} + onBlur={onBlur} + /> + + {history.map((h) => ( + + + ); +}; + +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 = ({ + 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(null); + const [selectedPath, setSelectedPath] = useState(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(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 => { + 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 ( + + + + {computedTitle} + + Select a target path and optional mask, then confirm to continue. + +

{manualPath}

+
+
+ { + 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} + /> +
+
+
+
+ + { + setManualPath(value); + }} + placeholder="home:/folder/file.ext" + onFocus={() => setIsPathInputFocused(true)} + onBlur={() => setIsPathInputFocused(false)} + /> +
+
+ + +
+
+
+ + {selectedPath ? `Selected: ${browseMount}:${selectedPath}` : (mode === 'saveAs' ? 'Type full path or select target' : 'Select file or folder')} + +
+ + +
+
+
+
+ { + if (pendingResult) { + finalizeSelection({ ...pendingResult, overwrite: true }); + setPendingResult(null); + } + setConfirmOverwriteOpen(false); + }} + onCancel={() => { + setConfirmOverwriteOpen(false); + setPendingResult(null); + }} + /> +
+ ); +}; + +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 = ({ + value, + onChange, + disabled, + readonly, + mode = 'pick', + placeholder, + dialogTitle, + dialogConfirmLabel, + initialFileName, + initialOverwrite, + initialMask, + onResultChange, +}) => { + const [open, setOpen] = useState(false); + return ( +
+
+ + + {value && !readonly && !disabled && ( + + )} +
+ { + onChange(result.encoded); + if (onResultChange) onResultChange(result); + }} + /> +
+ ); +}; + +export default FilePickerField; diff --git a/packages/ui/src/apps/filebrowser/FileTree.tsx b/packages/ui/src/modules/storage/FileTree.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/FileTree.tsx rename to packages/ui/src/modules/storage/FileTree.tsx diff --git a/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx b/packages/ui/src/modules/storage/LayoutToolbar.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/LayoutToolbar.tsx rename to packages/ui/src/modules/storage/LayoutToolbar.tsx diff --git a/packages/ui/src/apps/filebrowser/PanelSide.tsx b/packages/ui/src/modules/storage/PanelSide.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/PanelSide.tsx rename to packages/ui/src/modules/storage/PanelSide.tsx diff --git a/packages/ui/src/apps/filebrowser/SearchDialog.tsx b/packages/ui/src/modules/storage/SearchDialog.tsx similarity index 100% rename from packages/ui/src/apps/filebrowser/SearchDialog.tsx rename to packages/ui/src/modules/storage/SearchDialog.tsx diff --git a/packages/ui/src/modules/storage/client-vfs.ts b/packages/ui/src/modules/storage/client-vfs.ts index 8851e21c..ba1558e6 100644 --- a/packages/ui/src/modules/storage/client-vfs.ts +++ b/packages/ui/src/modules/storage/client-vfs.ts @@ -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 => { + 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' diff --git a/packages/ui/src/modules/storage/index.ts b/packages/ui/src/modules/storage/index.ts index 4e39c375..345b2395 100644 --- a/packages/ui/src/modules/storage/index.ts +++ b/packages/ui/src/modules/storage/index.ts @@ -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'; diff --git a/packages/ui/src/modules/types/AppTypeWidgets.tsx b/packages/ui/src/modules/types/AppTypeWidgets.tsx index b374dbb1..2fdb5c69 100644 --- a/packages/ui/src/modules/types/AppTypeWidgets.tsx +++ b/packages/ui/src/modules/types/AppTypeWidgets.tsx @@ -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(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 ( -
-
- onBlur(id, value)} - onFocus={() => onFocus(id, value)} - /> - - {value && !readonly && !disabled && ( - - )} -
- - - - - Browse Files - -

- {browseMount}:{browsePath} -

-
-
- - Loading… -
- } - > - { - 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} - /> - -
-
- - -
- - +
onBlur(id, value)} onFocus={() => onFocus(id, value)}> + onChange(nextValue)} + disabled={disabled} + readonly={readonly} + mode="pick" + placeholder="home:/path" + />
); }; diff --git a/packages/ui/src/pages/PlaygroundVfs.tsx b/packages/ui/src/pages/PlaygroundVfs.tsx index 0989ffe6..dac6e6d1 100644 --- a/packages/ui/src/pages/PlaygroundVfs.tsx +++ b/packages/ui/src/pages/PlaygroundVfs.tsx @@ -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('home:/'); + const [saveAsPath, setSaveAsPath] = React.useState('home:/untitled.txt'); + const [saveAsMeta, setSaveAsMeta] = React.useState(null); + const [isWriting, setIsWriting] = React.useState(false); + const [writeStatus, setWriteStatus] = React.useState(''); + 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 ( -
- +
+
+
File Picker Test
+
+
+
Pick existing file/folder
+ setPickedPath(v || '')} mode="pick" /> +
+
+
Save As (overwrite, initial selection, masks)
+ 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); + }} + /> +
+
+
+ picked: {pickedPath || '-'}
+ saveAs: {saveAsPath || '-'}
+ overwrite: {saveAsMeta ? String(saveAsMeta.overwrite) : '-'} | mask: {saveAsMeta?.mask || '-'} +
+
+ + {writeStatus && {writeStatus}} +
+
+
+ +
); };