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[]; extensionSlot?: React.ReactNode; confirmDisabled?: boolean; 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, extensionSlot, confirmDisabled = false, 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) { if (mode === 'pick' && selectedNode && selectedNode.type !== 'dir') { selectedDir = normalizeDirectory(browsePath); } else { 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)} />
{extensionSlot}
{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;