870 lines
41 KiB
TypeScript
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;
|