import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Loader2 } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import ImageLightbox from '@/components/ImageLightbox'; import LightboxText from '@/modules/storage/views/LightboxText'; import LightboxIframe from '@/modules/storage/views/LightboxIframe'; import { renderFileViewer } from '@/modules/storage/FileViewerRegistry'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import type { INode, SortKey } from '@/modules/storage/types'; import { getMimeCategory, vfsUrl, formatSize } from '@/modules/storage/helpers'; import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar'; import FileListView from '@/modules/storage/FileListView'; import FileGridView from '@/modules/storage/FileGridView'; import FileDetailPanel from '@/modules/storage/FileDetailPanel'; import MarkdownRenderer from '@/components/MarkdownRenderer'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { IMAGE_EXTS, VIDEO_EXTS, CODE_EXTS } from '@/modules/storage/helpers'; import { T } from '@/i18n'; import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; import { useSelection } from '@/modules/storage/hooks/useSelection'; import { useFilePreview } from '@/modules/storage/hooks/useFilePreview'; import { useDefaultKeyboardHandler } from '@/modules/storage/hooks/useDefaultKeyboardHandler'; import { useDefaultSelectionHandler } from '@/modules/storage/hooks/useDefaultSelectionHandler'; import { useDefaultActions } from '@/modules/storage/hooks/useDefaultActions'; import { FileTree } from './FileTree'; import SearchDialog from './SearchDialog'; // ── Props ──────────────────────────────────────────────────────── export interface FileBrowserPanelProps { mount?: string; path?: string; glob?: string; mode?: 'simple' | 'advanced'; viewMode?: 'list' | 'thumbs' | 'tree'; sortBy?: SortKey; showToolbar?: boolean; canChangeMount?: boolean; allowFileViewer?: boolean; allowLightbox?: boolean; allowPreview?: boolean; allowDownload?: boolean; jail?: boolean; onPathChange?: (path: string) => void; onMountChange?: (mount: string) => void; /** If set, auto-open this file in lightbox after directory loads */ initialFile?: string; /** If true, automatically loads and renders a readme.md (case-insensitive) in the current directory */ index?: boolean; /** If true, allows the fallback FileBrowserPanel to render when no readme is found. */ allowFallback?: boolean; /** ID for saving user preferences like viewMode locally (e.g. 'pm-filebrowser-left-panel') */ autoSaveId?: string; showFolders?: boolean; showExplorer?: boolean; showPreview?: boolean; showTree?: boolean; onToggleExplorer?: () => void; onTogglePreview?: () => void; onFilterChange?: (glob: string, showFolders: boolean) => void; onSelect?: (nodes: INode[] | INode | null) => void; searchQuery?: string; onSearchQueryChange?: (q: string) => void; autoFocus?: boolean; includeSize?: boolean; splitSizeHorizontal?: number[]; splitSizeVertical?: number[]; onLayoutChange?: (sizes: number[], direction: 'horizontal' | 'vertical') => void; showStatusBar?: boolean; } // ── Main Component ─────────────────────────────────────────────── const FileBrowserPanel: React.FC = ({ mount: mountProp = 'machines', path: pathProp = '/', glob = '*.*', mode = 'simple', viewMode: initialViewMode = 'list', sortBy: initialSort = 'name', showToolbar = true, canChangeMount = false, allowFileViewer = true, allowLightbox = true, allowPreview = true, allowDownload = true, jail = false, initialFile, allowFallback = true, autoFocus = true, includeSize = false, index = true, autoSaveId, showFolders: showFoldersProp, showExplorer = true, showPreview = true, showTree = true, onToggleExplorer, onTogglePreview, onPathChange, onMountChange, onSelect, onFilterChange, searchQuery, onSearchQueryChange, splitSizeHorizontal, splitSizeVertical, onLayoutChange, showStatusBar = true }) => { const { session } = useAuth(); const accessToken = session?.access_token; const [readmeContent, setReadmeContent] = useState(null); const [selectedReadmeContent, setSelectedReadmeContent] = useState(null); // ── Controlled / uncontrolled mode ──────────────────────────── const [internalMount, setInternalMount] = useState(mountProp); const mount = onMountChange ? mountProp : internalMount; const [internalGlob, setInternalGlob] = useState(glob); const [internalShowFolders, setInternalShowFolders] = useState(true); const actualCurrentGlob = onFilterChange ? glob : internalGlob; const showFolders = onFilterChange ? (showFoldersProp ?? true) : internalShowFolders; const updateFilter = useCallback((newGlob: string, newShowFolders: boolean) => { if (onFilterChange) onFilterChange(newGlob, newShowFolders); else { setInternalGlob(newGlob); setInternalShowFolders(newShowFolders); } }, [onFilterChange]); // ── Available mounts ───────────────────────────────────────── const [availableMounts, setAvailableMounts] = useState([]); useEffect(() => { const headers: Record = {}; if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; fetch('/api/vfs/mounts', { headers }) .then(r => r.ok ? r.json() : []) .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name))) .catch(() => { }); }, [accessToken]); // ── VFS Adapter ─────────────────────────────────────────────── const { nodes, sorted, loading, error, currentPath, currentGlob, updatePath, updateMount, fetchDir, canGoUp, goUp: rawGoUp, breadcrumbs, jailRoot, isSearchMode } = useVfsAdapter({ mount, pathProp, glob: actualCurrentGlob, showFolders, accessToken, index, jail, jailPath: pathProp, sortBy: initialSort, sortAsc: true, includeSize, searchQuery, onPathChange, onMountChange: (m) => { setInternalMount(m); if (onMountChange) onMountChange(m); }, onFetched: async (fetchedNodes, isSearch) => { setReadmeContent(null); if (index && !isSearch) { const readmeNode = fetchedNodes.find(n => n.name.toLowerCase() === 'readme.md'); if (readmeNode) { const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; const base = vfsUrl('get', mount, readmeNode.path); const fileUrl = tokenParam ? `${base}?${tokenParam}` : base; const fileRes = await fetch(fileUrl, { cache: 'no-cache' }); if (fileRes.ok) { const content = await fileRes.text(); setReadmeContent(content); } } } } }); // ── View Mode & Zoom ───────────────────────────────────────── const [internalViewMode, setInternalViewMode] = useState<'list' | 'thumbs' | 'tree'>(() => { if (autoSaveId) { const saved = localStorage.getItem(`${autoSaveId}-viewMode`); if (saved === 'list' || saved === 'thumbs' || saved === 'tree') return saved; } return initialViewMode; }); const [internalMode, setInternalMode] = useState<'simple' | 'advanced'>(() => { if (autoSaveId) { const saved = localStorage.getItem(`${autoSaveId}-mode`); if (saved === 'simple' || saved === 'advanced') return saved; } return mode; }); const setViewMode = useCallback((m: 'list' | 'thumbs' | 'tree') => { setInternalViewMode(m); if (autoSaveId) localStorage.setItem(`${autoSaveId}-viewMode`, m); }, [autoSaveId]); const setDisplayMode = useCallback((m: 'simple' | 'advanced') => { setInternalMode(m); if (autoSaveId) localStorage.setItem(`${autoSaveId}-mode`, m); }, [autoSaveId]); const [splitDirection, setSplitDirectionState] = useState<'horizontal' | 'vertical'>(() => { if (autoSaveId) { const saved = localStorage.getItem(`${autoSaveId}-splitDir`); if (saved === 'horizontal' || saved === 'vertical') return saved; } return typeof window !== 'undefined' && window.innerWidth < 768 ? 'vertical' : 'horizontal'; }); const setSplitDirection = useCallback((m: 'horizontal' | 'vertical') => { setSplitDirectionState(m); if (autoSaveId) localStorage.setItem(`${autoSaveId}-splitDir`, m); }, [autoSaveId]); const viewMode = internalViewMode; const currentMode = internalMode; const activeSplitSize = splitDirection === 'horizontal' ? splitSizeHorizontal : splitSizeVertical; const [thumbSize, setThumbSize] = useState(() => { const v = localStorage.getItem('fb-thumb-size'); return v ? Math.max(60, Math.min(200, Number(v))) : 80; }); const [fontSize, setFontSize] = useState(() => { const v = localStorage.getItem('fb-font-size'); return v ? Math.max(10, Math.min(18, Number(v))) : 14; }); const zoomIn = () => { if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); }; const zoomOut = () => { if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); }; // ── Selection & Refs ───────────────────────────────────────── const listRef = useRef(null); const containerRef = useRef(null); const { focusIdx, setFocusIdx, selected, setSelected, itemCount, getNode, handleItemClick, clearSelection } = useSelection({ sorted, canGoUp, onSelect }); // Dummy Sort controls for now since useVfsAdapter uses static sortBy const [sortBy, setSortBy] = useState(initialSort); const [sortAsc, setSortAsc] = useState(true); const cycleSort = () => { const keys: SortKey[] = ['name', 'ext', 'date', 'type']; const i = keys.indexOf(sortBy); if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); } }; // ── Previews ───────────────────────────────────────────────── const { lightboxNode, setLightboxNode, textLightboxNode, setTextLightboxNode, iframeLightboxNode, setIframeLightboxNode, openPreview, closeAllPreviews } = useFilePreview({ allowLightbox, allowFileViewer }); // ── Filter Dialog State ──────────────────────────────────────── const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [tempGlob, setTempGlob] = useState(currentGlob); const [tempShowFolders, setTempShowFolders] = useState(showFolders); const applyTempFilter = () => { updateFilter(tempGlob, tempShowFolders); setFilterDialogOpen(false); setTimeout(() => containerRef.current?.focus(), 0); }; const mediaGlob = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])).map(ext => `*.${ext}`).join(','); const codeGlob = Array.from(CODE_EXTS).map(ext => `*.${ext}`).join(','); const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; // ── Standalone scroll & grid helpers (shared by keyboard + selection hooks) ── const scrollItemIntoView = useCallback((idx: number) => { if (!listRef.current) return; const items = listRef.current.querySelectorAll('[data-fb-idx]'); const el = items[idx] as HTMLElement | undefined; el?.scrollIntoView({ block: 'nearest' }); }, []); const getGridCols = useCallback((): number => { if (viewMode !== 'thumbs' || !listRef.current) return 1; const style = getComputedStyle(listRef.current); const cols = style.gridTemplateColumns.split(' ').length; return Math.max(1, cols); }, [viewMode]); // ── Default Selection Handler (first so we get wrapped goUp) ── const [pendingFileSelect, setPendingFileSelect] = useState(null); const { goUp } = useDefaultSelectionHandler({ sorted, canGoUp, rawGoUp, currentPath, loading, viewMode, autoFocus, index, isSearchMode, initialFile, allowFallback, setFocusIdx, setSelected, onSelect, pendingFileSelect, setPendingFileSelect, scrollItemIntoView, containerRef, listRef, }); // ── Default Keyboard Handler (uses wrapped goUp) ───────────── const { searchOpen, setSearchOpen, searchDisplay, searchBufferRef, pendingSearchSelection, setPendingSearchSelection, handleKeyDown } = useDefaultKeyboardHandler({ focusIdx, setFocusIdx, selected, setSelected, itemCount, getNode, clearSelection, canGoUp, goUp, updatePath, openPreview, viewMode, setViewMode, setDisplayMode, currentGlob, showFolders, cycleSort, setTempGlob, setTempShowFolders, setFilterDialogOpen, containerRef, scrollItemIntoView, getGridCols, autoFocus, allowFallback, currentPath, onSearchQueryChange, searchQuery, isSearchMode, onSelect, sorted, }); // ── Default Actions ────────────────────────────────────────── const { selectedFile, getFileUrl, handleView, handleDownload, handleDownloadDir, mediaNodes, lightboxIdx, lightboxPrev, lightboxNext, closeLightbox, closeTextLightbox, closeIframeLightbox, handleDoubleClick, handleLinkClick } = useDefaultActions({ mount, mountProp, pathProp, accessToken, selected, sorted, canGoUp, setFocusIdx, setSelected, lightboxNode, setLightboxNode, textLightboxNode, setTextLightboxNode, iframeLightboxNode, setIframeLightboxNode, openPreview, updatePath, setPendingFileSelect, containerRef, getNode, goUp, }); return (
{/* ═══ Toolbar ═══════════════════════════════════ */} {showToolbar && ( 0} handleDownloadDir={handleDownloadDir} allowDownloadDir={allowDownload} sortBy={sortBy} sortAsc={sortAsc} cycleSort={cycleSort} zoomIn={zoomIn} zoomOut={zoomOut} viewMode={viewMode} setViewMode={setViewMode} displayMode={currentMode} setDisplayMode={setDisplayMode} splitDirection={splitDirection} setSplitDirection={setSplitDirection} showExplorer={showExplorer} onToggleExplorer={onToggleExplorer} showPreview={showPreview} onTogglePreview={onTogglePreview} onFilterOpen={() => { setTempGlob(currentGlob); setTempShowFolders(showFolders); setFilterDialogOpen(true); }} onSearchOpen={() => setSearchOpen(true)} fontSize={fontSize} isSearchMode={isSearchMode} onClearSearch={() => onSearchQueryChange && onSearchQueryChange('')} /> )} {/* ═══ Content ═══════════════════════════════════ */} {loading ? (
Loading…
) : error ? (
{error}
) : itemCount === 0 ? (
Empty directory
) : (
{ if (onLayoutChange) onLayoutChange(sizes, splitDirection); }} {...(activeSplitSize && activeSplitSize.length > 0 ? {} : { autoSaveId: autoSaveId ? `${autoSaveId}-split-${splitDirection}` : `pm-filebrowser-panel-layout-${splitDirection}` })} className={`flex-1 flex overflow-hidden ${splitDirection === 'vertical' ? 'flex-col min-h-0' : 'flex-row min-w-0'}`} > {showExplorer && (
{viewMode === 'tree' ? (
{ const clean = node.path.replace(/^\/+/, ''); const base = vfsUrl('ls', mount, clean); const url = `${base}?includeSize=true`; const headers: Record = {}; if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; const res = await fetch(url, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }} onSelectionChange={(nodes) => { setSelected(nodes); }} onSelect={(n) => { setSelected([n]); }} onActivate={(n) => { if (getMimeCategory(n) === 'dir') { updatePath(n.path || n.name); } else { openPreview(n); } }} />
) : viewMode === 'list' ? (
) : (
)}
)} {/* Right Pane conditionally renders if preview or fallback exists */} {showPreview && ((!showExplorer && selected.length === 1) || (allowPreview && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir') || (selected.length === 0 && readmeContent) || (allowPreview && selectedReadmeContent) || (allowPreview && allowFallback && selected.length === 1 && getMimeCategory(selected[0]) === 'dir')) && ( <> {showExplorer && } 1 ? activeSplitSize[1] : (showExplorer ? 40 : 100)} minSize={15} className="relative min-w-0 bg-card/30">
{((!showExplorer && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir') || (allowPreview && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir')) ? (
{renderFileViewer({ selected: selected[0], url: getFileUrl(selected[0]), fileName: selected[0].name, inline: true, isOpen: true, onClose: () => { }, onLinkClick: (href, e) => handleLinkClick(href, e, selected[0].parent || '/') })}
) : ((selected.length === 0 && readmeContent) || (allowPreview && selectedReadmeContent)) ? (
{ const basePath = (selectedReadmeContent && selected.length === 1) ? (selected[0].parent || '/') : currentPath; handleLinkClick(href, e, basePath); }} />
) : (allowPreview && allowFallback && selected.length === 1 && getMimeCategory(selected[0]) === 'dir') ? (
) : null}
)}
{/* Detail panel (advanced, desktop only) */} {/* Detail panel (advanced, desktop only) */} {mode === 'advanced' && ( )}
)} {showStatusBar &&
sum + (n.size || 0), 0))}${selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}`} style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} > {sorted.length} {sorted.length !== 1 ? 'items' : 'item'} {' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))} {selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''} {mount}:{currentPath || '/'}
} {/* ═══ Lightboxes ════════════════════════════════ */} dir === 'prev' ? lightboxPrev() : lightboxNext()} showPrompt={false} /> {/* ── Dialogs ───────────────────────────────────────────── */} {filterDialogOpen && ( { if (!open) { setFilterDialogOpen(false); setTimeout(() => containerRef.current?.focus(), 0); } }}> Filter Current View Enter a list of comma-separated wildcard matcher expressions (e.g., *.jpg, *.png) or use a preset below.
setTempGlob(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyTempFilter(); } }} autoFocus placeholder="*.*" />
setTempGlob('*.*')}> All Files (*.*) setTempGlob(mediaGlob)}> Media setTempGlob(codeGlob)}> Code
)} {/* Search dialog */} {searchOpen && ( { const isDir = getMimeCategory(node) === 'dir'; if (isDir) { updatePath(node.path.startsWith('/') ? node.path : `/${node.path}`); } else { const parentPath = node.parent || '/'; const currentTarget = parentPath.startsWith('/') ? parentPath : `/${parentPath}`; const normalizedCurrent = currentPath.replace(/\/+$/, '') || '/'; const normalizedTarget = currentTarget.replace(/\/+$/, '') || '/'; if (normalizedTarget !== normalizedCurrent) { setPendingSearchSelection(node.name); updatePath(currentTarget); } else { const idx = sorted.findIndex(n => n.name === node.name); if (idx >= 0) { const focusIndex = canGoUp ? idx + 1 : idx; setFocusIdx(focusIndex); const itemNode = sorted[idx]; if (itemNode) { const newlySelected = [itemNode]; setSelected(newlySelected); if (onSelect) { onSelect(newlySelected); } requestAnimationFrame(() => scrollItemIntoView(focusIndex)); } } } } }} onClose={() => { setSearchOpen(false); setTimeout(() => { if (viewMode === 'tree') { listRef.current?.focus(); } else { containerRef.current?.focus(); } }, 0); }} /> )}
); }; export { FileBrowserPanel }; export default FileBrowserPanel;