mono/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx
2026-03-21 20:18:25 +01:00

870 lines
41 KiB
TypeScript

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<FileBrowserPanelProps> = ({
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<string | null>(null);
const [selectedReadmeContent, setSelectedReadmeContent] = useState<string | null>(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<string[]>([]);
useEffect(() => {
const headers: Record<string, string> = {};
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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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<SortKey>(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<string | null>(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 (
<div
ref={containerRef}
data-testid="file-browser-panel"
tabIndex={viewMode === 'tree' ? undefined : 0}
className="fb-panel-container"
onKeyDown={handleKeyDown}
style={{
display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0,
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
}}
>
<style>{`
@media (max-width: 767px) {
.fb-detail-pane { display: none !important; }
}
`}</style>
{/* ═══ Toolbar ═══════════════════════════════════ */}
{showToolbar && (
<FileBrowserToolbar
canGoUp={canGoUp}
goUp={goUp}
canChangeMount={!jail && canChangeMount}
availableMounts={availableMounts}
mount={mount}
updateMount={updateMount}
mountProp={mountProp}
pathProp={pathProp}
updatePath={updatePath}
breadcrumbs={breadcrumbs}
selectedNode={selected.length === 1 ? selected[0] : null}
selectedNodes={selected}
selectedFile={selectedFile}
handleView={handleView}
handleDownload={handleDownload}
allowDownload={allowDownload && selected.length > 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 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
<Loader2 size={16} className="animate-spin" />
<span style={{ fontSize: 14 }}><T>Loading</T></span>
</div>
) : error ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
<T>{error}</T>
</div>
) : itemCount === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
<T>Empty directory</T>
</div>
) : (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0 }}>
<ResizablePanelGroup
direction={splitDirection}
onLayout={(sizes) => {
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 && (
<ResizablePanel defaultSize={activeSplitSize ? activeSplitSize[0] : 60} minSize={15} className="relative min-w-0 bg-white dark:bg-slate-800/50">
<div className="w-full h-full flex flex-col min-h-[50px] min-w-0">
{viewMode === 'tree' ? (
<div className="flex-1 min-h-0 overflow-hidden pt-1">
<FileTree
data={sorted}
canGoUp={canGoUp}
onGoUp={goUp}
selectedId={selected.length === 1 ? selected[0].path : undefined}
fontSize={fontSize}
fetchChildren={async (node: INode) => {
const clean = node.path.replace(/^\/+/, '');
const base = vfsUrl('ls', mount, clean);
const url = `${base}?includeSize=true`;
const headers: Record<string, string> = {};
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);
}
}}
/>
</div>
) : viewMode === 'list' ? (
<div className="flex-1 min-h-0 overflow-hidden pt-1 flex flex-col w-full h-full">
<FileListView
listRef={listRef}
sorted={sorted}
canGoUp={canGoUp}
goUp={goUp}
focusIdx={focusIdx}
setFocusIdx={setFocusIdx}
selected={selected}
onItemClick={handleItemClick}
onItemDoubleClick={handleDoubleClick}
fontSize={fontSize}
mode={currentMode}
searchBuffer={isSearchMode ? (searchQuery || searchDisplay) : searchDisplay}
isSearchMode={isSearchMode}
/>
</div>
) : (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden w-full h-full pt-1">
<FileGridView
listRef={listRef}
sorted={sorted}
canGoUp={canGoUp}
goUp={goUp}
focusIdx={focusIdx}
setFocusIdx={setFocusIdx}
selected={selected}
onItemClick={handleItemClick}
onItemDoubleClick={handleDoubleClick}
thumbSize={thumbSize}
mount={mount}
tokenParam={tokenParam}
fontSize={fontSize}
isSearchMode={isSearchMode}
/>
</div>
)}
</div>
</ResizablePanel>
)}
{/* 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 && <ResizableHandle withHandle />}
<ResizablePanel defaultSize={activeSplitSize && activeSplitSize.length > 1 ? activeSplitSize[1] : (showExplorer ? 40 : 100)} minSize={15} className="relative min-w-0 bg-card/30">
<div className="w-full h-full flex flex-col min-h-[50px] min-w-0">
{((!showExplorer && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir') || (allowPreview && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir')) ? (
<div className="fb-readme-pane shrink-0 border-t md:border-t-0 border-border overflow-hidden w-full h-full relative flex flex-1 flex-col min-h-0">
{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 || '/')
})}
</div>
) : ((selected.length === 0 && readmeContent) || (allowPreview && selectedReadmeContent)) ? (
<div className="fb-readme-pane shrink-0 p-6 border-t md:border-t-0 border-border overflow-y-auto flex-1 w-full h-full">
<MarkdownRenderer
content={selectedReadmeContent || readmeContent || ''}
baseUrl={
selectedReadmeContent && selected.length === 1
? vfsUrl('get', mount, selected[0].path) + '/'
: vfsUrl('get', mount, currentPath) + '/'
}
onLinkClick={(href, e) => {
const basePath = (selectedReadmeContent && selected.length === 1)
? (selected[0].parent || '/')
: currentPath;
handleLinkClick(href, e, basePath);
}}
/>
</div>
) : (allowPreview && allowFallback && selected.length === 1 && getMimeCategory(selected[0]) === 'dir') ? (
<div className="fb-readme-pane flex shrink-0 border-t md:border-t-0 border-border flex-1 flex-col min-h-0 overflow-hidden relative w-full h-full">
<FileBrowserPanel
mount={mount}
path={selected[0].path}
viewMode="thumbs"
showToolbar={false}
mode="simple"
jail={true}
allowFallback={false}
index={false}
autoFocus={false}
showStatusBar={false}
/>
</div>
) : null}
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
{/* Detail panel (advanced, desktop only) */}
{/* Detail panel (advanced, desktop only) */}
{mode === 'advanced' && (
<FileDetailPanel file={selected.length === 1 ? selected[0] : null} fileUrl={selected.length === 1 ? getFileUrl(selected[0]) : ''} />
)}
</div>
)}
{showStatusBar && <div style={{
padding: '4px 10px', fontSize: 13, borderTop: '2px solid var(--border)',
color: 'var(--muted-foreground)', display: 'flex', justifyContent: 'space-between',
background: 'var(--muted)', width: '100%', overflow: 'hidden', gap: '8px'
}}>
<span
title={`${sorted.length} ${sorted.length !== 1 ? 'items' : 'item'} · ${formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}${selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}`}
style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{sorted.length} <T>{sorted.length !== 1 ? 'items' : 'item'}</T>
{' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}
{selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}
</span>
<span
title={`${mount}:${currentPath || '/'}`}
style={{
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
flexShrink: 1, textAlign: 'right', minWidth: 50, maxWidth: '50%'
}}
>
{mount}:{currentPath || '/'}
</span>
</div>}
{/* ═══ Lightboxes ════════════════════════════════ */}
<ImageLightbox
isOpen={!!lightboxNode}
onClose={closeLightbox}
imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''}
imageTitle={lightboxNode?.name || ''}
currentIndex={lightboxIdx}
totalCount={mediaNodes.length}
onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()}
showPrompt={false}
/>
<LightboxText
isOpen={!!textLightboxNode}
onClose={closeTextLightbox}
url={textLightboxNode ? getFileUrl(textLightboxNode) : ''}
fileName={textLightboxNode?.name || ''}
/>
<LightboxIframe
isOpen={!!iframeLightboxNode}
onClose={closeIframeLightbox}
url={iframeLightboxNode ? getFileUrl(iframeLightboxNode) : ''}
fileName={iframeLightboxNode?.name || ''}
/>
{/* ── Dialogs ───────────────────────────────────────────── */}
{filterDialogOpen && (
<Dialog open={filterDialogOpen} onOpenChange={(open) => {
if (!open) {
setFilterDialogOpen(false);
setTimeout(() => containerRef.current?.focus(), 0);
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle><T>Filter Current View</T></DialogTitle>
<DialogDescription>
<T>Enter a list of comma-separated wildcard matcher expressions (e.g., *.jpg, *.png) or use a preset below.</T>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor="showFolders" className="flex flex-col gap-1">
<span><T>Show Folders</T></span>
<span className="font-normal text-xs text-muted-foreground"><T>Keep subdirectories visible alongside matched files</T></span>
</Label>
<Switch
id="showFolders"
checked={tempShowFolders}
onCheckedChange={setTempShowFolders}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="globPattern">Glob Pattern</Label>
<Input
id="globPattern"
value={tempGlob}
onChange={(e) => setTempGlob(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyTempFilter();
}
}}
autoFocus
placeholder="*.*"
/>
</div>
<div className="flex flex-wrap gap-2 mt-2">
<Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob('*.*')}>
All Files (*.*)
</Badge>
<Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob(mediaGlob)}>
Media
</Badge>
<Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob(codeGlob)}>
Code
</Badge>
</div>
</div>
<div className="flex justify-end gap-3 w-full">
<Button variant="outline" onClick={() => {
setFilterDialogOpen(false);
setTimeout(() => containerRef.current?.focus(), 0);
}}>
Cancel
</Button>
<Button onClick={applyTempFilter}>
Apply Filter
</Button>
</div>
</DialogContent>
</Dialog>
)}
{/* Search dialog */}
{searchOpen && (
<SearchDialog
mount={mount}
currentPath={currentPath}
accessToken={accessToken}
onNavigate={(node: INode) => {
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);
}}
/>
)}
</div>
);
};
export { FileBrowserPanel };
export default FileBrowserPanel;