From 0e684077b2ec6716bd5c501f62b99640db108d1f Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sun, 7 Jun 2026 22:34:49 -0400 Subject: [PATCH] feat(FileManager): enhance file viewing capabilities with markdown and syntax highlighting support - Added support for viewing markdown files using ReactMarkdown and remark-gfm. - Integrated syntax highlighting for code snippets using react-syntax-highlighter. - Introduced new viewer modes: markdown, json, xml, and hex. - Updated file categorization logic to include markdown files. - Enhanced the viewer interface with icons representing different file types. - Improved user experience with loading states and error handling during file operations. - Refactored code for better readability and maintainability. --- package.json | 4 + src/app/components/FileManager.tsx | 605 +++++++++++++++++++---------- 2 files changed, 396 insertions(+), 213 deletions(-) diff --git a/package.json b/package.json index 92bf5a9..748e0d5 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-toggle-group": "1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@types/react-syntax-highlighter": "^15.5.13", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -53,12 +54,15 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-hook-form": "7.55.0", + "react-markdown": "^10.1.0", "react-popper": "2.3.0", "react-resizable-panels": "2.1.7", "react-responsive-masonry": "2.7.1", "react-router": "^7.17.0", "react-slick": "0.31.0", + "react-syntax-highlighter": "^16.1.1", "recharts": "2.15.2", + "remark-gfm": "^4.0.1", "sonner": "2.0.3", "tailwind-merge": "3.2.0", "tw-animate-css": "1.3.8", diff --git a/src/app/components/FileManager.tsx b/src/app/components/FileManager.tsx index 0862e0c..dd158d9 100644 --- a/src/app/components/FileManager.tsx +++ b/src/app/components/FileManager.tsx @@ -1,9 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + AlignLeft, ArrowLeft, + BookOpen, + Braces, Check, ChevronLeft, ChevronRight, + Code2, Copy, Download, Eye, @@ -12,6 +16,7 @@ import { Folder, FolderPlus, HardDrive, + Hash, Home, Image as ImageIcon, Loader2, @@ -24,6 +29,10 @@ import { Upload, X, } from 'lucide-react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { copyPath, createFolder, @@ -47,32 +56,184 @@ import { DialogDescription, } from './ui/dialog'; -type SortKey = 'name' | 'size' | 'date'; -type Clipboard = { op: 'copy' | 'move'; paths: string[] }; -type FileCategory = 'text' | 'image' | 'disk' | 'binary'; +// ─── Types ─────────────────────────────────────────────────────────────────── -const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'json', 'bas', 'asm', 'seq', 'rel', 'prg', 'md', 'log', 'csv']); -const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']); -const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']); +type SortKey = 'name' | 'size' | 'date'; +type Clipboard = { op: 'copy' | 'move'; paths: string[] }; +type FileCategory = 'text' | 'image' | 'disk' | 'binary'; +type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image'; + +// ─── Extension sets ────────────────────────────────────────────────────────── + +const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'bas', 'asm', 'seq', 'rel', 'prg', 'log', 'csv', 's', 'lst']); +const MD_EXTS = new Set(['md', 'markdown']); +const JSON_EXTS = new Set(['json']); +const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']); +const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']); +const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']); function fileCategory(entry: EntryInfo): FileCategory { if (entry.type === 'folder') return 'binary'; const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; - if (TEXT_EXTS.has(ext)) return 'text'; if (IMAGE_EXTS.has(ext)) return 'image'; - if (DISK_EXTS.has(ext)) return 'disk'; + if (DISK_EXTS.has(ext)) return 'disk'; + if (TEXT_EXTS.has(ext) || MD_EXTS.has(ext) || JSON_EXTS.has(ext) || XML_EXTS.has(ext)) return 'text'; return 'binary'; } -function EntryIcon({ entry }: { entry: EntryInfo }) { - if (entry.type === 'folder') return ; - const cat = fileCategory(entry); - if (cat === 'image') return ; - if (cat === 'text') return ; - if (cat === 'disk') return ; - return ; +function defaultViewMode(entry: EntryInfo): ViewMode { + const ext = entry.name.split('.').pop()?.toLowerCase() ?? ''; + if (IMAGE_EXTS.has(ext)) return 'image'; + if (MD_EXTS.has(ext)) return 'markdown'; + if (JSON_EXTS.has(ext)) return 'json'; + if (XML_EXTS.has(ext)) return 'xml'; + if (TEXT_EXTS.has(ext)) return 'text'; + return 'hex'; } +function availableViewers(entry: EntryInfo): ViewMode[] { + const def = defaultViewMode(entry); + const map: Record = { + image: ['image', 'hex'], + markdown: ['markdown', 'text', 'hex'], + json: ['json', 'text', 'hex'], + xml: ['xml', 'text', 'hex'], + text: ['text', 'hex'], + hex: ['hex', 'text'], + }; + return map[def]; +} + +const VIEWER_LABEL: Record = { + text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', +}; + +const SYNTAX_LANG: Partial> = { + json: 'json', xml: 'xml', text: 'text', +}; + +// ─── Viewer components ─────────────────────────────────────────────────────── + +function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) { + const cls = className ?? 'w-4 h-4'; + switch (mode) { + case 'text': return ; + case 'markdown': return ; + case 'json': return ; + case 'xml': return ; + case 'hex': return ; + case 'image': return ; + } +} + +function HexViewer({ data }: { data: Uint8Array }) { + const MAX = 65536; + const view = data.length > MAX ? data.slice(0, MAX) : data; + const lines: string[] = []; + for (let i = 0; i < view.length; i += 16) { + const row = view.slice(i, i + 16); + const addr = i.toString(16).padStart(8, '0').toUpperCase(); + const hex = Array.from({ length: 16 }, (_, j) => + j < row.length ? row[j].toString(16).padStart(2, '0').toUpperCase() : ' ', + ); + const hexStr = hex.slice(0, 8).join(' ') + ' ' + hex.slice(8).join(' '); + const ascii = Array.from(row).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); + lines.push(`${addr} ${hexStr} |${ascii}|`); + } + return ( +
+ {data.length > MAX && ( +
+ Showing first {MAX.toLocaleString()} of {data.length.toLocaleString()} bytes +
+ )} +
{lines.join('\n')}
+
+ ); +} + +function CodeViewer({ text, mode }: { text: string; mode: ViewMode }) { + const lang = SYNTAX_LANG[mode] ?? 'text'; + const source = mode === 'json' ? (() => { + try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } + })() : text; + + return ( +
+ + {source} + +
+ ); +} + +function MarkdownViewer({ text }: { text: string }) { + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + a: ({ href, children }) => {children}, + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className ?? ''); + const inline = !match && !String(children).includes('\n'); + return inline + ? {children} + : ( +
+ + {String(children).replace(/\n$/, '')} + +
+ ); + }, + pre: ({ children }) => <>{children}, + blockquote: ({ children }) =>
{children}
, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + hr: () =>
    , + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + strong: ({ children }) => {children}, + img: ({ src, alt }) => {alt}, + }} + > + {text} +
    +
    + ); +} + +// ─── Entry icon ─────────────────────────────────────────────────────────────── + +function EntryIcon({ entry }: { entry: EntryInfo }) { + if (entry.type === 'folder') return ; + const cat = fileCategory(entry); + if (cat === 'image') return ; + if (cat === 'disk') return ; + if (cat === 'text') return ; + return ; +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + interface FileManagerProps { initialPath?: string; config?: any; @@ -80,69 +241,72 @@ interface FileManagerProps { onBack?: () => void; } +// ─── Main component ─────────────────────────────────────────────────────────── + export default function FileManager({ initialPath = '/', config, setConfig, onBack }: FileManagerProps) { - const [path, setPath] = useState(normalizePath(initialPath)); + const [path, setPath] = useState(normalizePath(initialPath)); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [selected, setSelected] = useState>(new Set()); - const [filter, setFilter] = useState(''); + const [filter, setFilter] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [clipboard, setClipboard] = useState(null); const [actionEntry, setActionEntry] = useState(null); const [dragOver, setDragOver] = useState(false); - const [viewEntry, setViewEntry] = useState(null); - const [viewText, setViewText] = useState(null); + + // Viewer + const [viewEntry, setViewEntry] = useState(null); + const [viewMode, setViewMode] = useState(null); + const [viewBlob, setViewBlob] = useState(null); + const [viewText, setViewText] = useState(null); const [viewImgUrl, setViewImgUrl] = useState(null); + const [viewHexData, setViewHexData] = useState(null); const [viewLoading, setViewLoading] = useState(false); + + // Rename / folder const [showNewFolder, setShowNewFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - const [renameEntry, setRenameEntry] = useState(null); - const [renameName, setRenameName] = useState(''); - const [mountEntry, setMountEntry] = useState(null); - const fileInputRef = useRef(null); + const [renameEntry, setRenameEntry] = useState(null); + const [renameName, setRenameName] = useState(''); + const [mountEntry, setMountEntry] = useState(null); + + const fileInputRef = useRef(null); const renameInputRef = useRef(null); - const dragCounter = useRef(0); + const dragCounter = useRef(0); + + // ── Directory loading ──────────────────────────────────────────────────── const load = useCallback(async (p: string) => { setLoading(true); setError(null); setSelected(new Set()); try { - const items = await listDirectory(p); - setEntries(items); + setEntries(await listDirectory(p)); } catch (e: any) { - setError(e?.message || 'Failed to load directory'); + setError(e?.message ?? 'Failed to load directory'); setEntries([]); } finally { setLoading(false); } }, []); - useEffect(() => { - void load(path); - }, [path, load]); + useEffect(() => { void load(path); }, [path, load]); - const navigateTo = (p: string) => { - setPath(normalizePath(p)); - setFilter(''); - }; + const navigateTo = (p: string) => { setPath(normalizePath(p)); setFilter(''); }; + const navigateUp = () => { if (path !== '/') navigateTo(splitPath(path).parent); }; - const navigateUp = () => { - if (path === '/') return; - navigateTo(splitPath(path).parent); - }; + // ── Sort + filter ──────────────────────────────────────────────────────── - // Sorted + filtered view const visible = entries .filter(e => !filter || e.name.toLowerCase().includes(filter.toLowerCase())) .sort((a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; let cmp = 0; - if (sortKey === 'name') cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + if (sortKey === 'name') cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); else if (sortKey === 'size') cmp = a.size - b.size; - else if (sortKey === 'date') cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0); + else cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0); return sortAsc ? cmp : -cmp; }); @@ -151,122 +315,106 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa else { setSortKey(key); setSortAsc(true); } }; - // Multi-select - const toggleSelect = (entryPath: string) => { - setSelected(prev => { - const next = new Set(prev); - if (next.has(entryPath)) next.delete(entryPath); - else next.add(entryPath); - return next; - }); + // ── Multi-select ───────────────────────────────────────────────────────── + + const toggleSelect = (p: string) => + setSelected(prev => { const next = new Set(prev); next.has(p) ? next.delete(p) : next.add(p); return next; }); + + const selectAll = () => + setSelected(selected.size === visible.length && visible.length > 0 + ? new Set() + : new Set(visible.map(e => e.path))); + + // ── File viewer ────────────────────────────────────────────────────────── + + const processBlob = async (blob: Blob, mode: ViewMode) => { + setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); + setViewText(null); + setViewHexData(null); + if (mode === 'image') setViewImgUrl(URL.createObjectURL(blob)); + else if (mode === 'hex') setViewHexData(new Uint8Array(await blob.arrayBuffer())); + else setViewText(await blob.text()); }; - const selectAll = () => { - setSelected(selected.size === visible.length && visible.length > 0 ? new Set() : new Set(visible.map(e => e.path))); - }; - - // File viewer - const openEntry = async (entry: EntryInfo) => { + const openEntry = async (entry: EntryInfo, mode?: ViewMode) => { if (renameEntry !== null) return; - if (entry.type === 'folder') { - navigateTo(joinPath(path, entry.name)); - return; - } - const cat = fileCategory(entry); - if (cat === 'disk') { - setMountEntry(entry); - return; - } - if (cat === 'binary') { - void downloadEntry(entry); - return; - } + if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; } + const targetMode = mode ?? defaultViewMode(entry); setViewEntry(entry); + setViewMode(targetMode); + setViewBlob(null); setViewText(null); setViewImgUrl(null); + setViewHexData(null); setViewLoading(true); try { const blob = await getFileContents(entry.path); - if (cat === 'image') { - setViewImgUrl(URL.createObjectURL(blob)); - } else { - setViewText(await blob.text()); - } + setViewBlob(blob); + await processBlob(blob, targetMode); } catch (e: any) { - toast.error(`Failed to open ${entry.name}: ${e?.message || e}`); + toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`); setViewEntry(null); + setViewMode(null); } finally { setViewLoading(false); } }; - const closeViewer = () => { - if (viewImgUrl) URL.revokeObjectURL(viewImgUrl); - setViewEntry(null); - setViewText(null); - setViewImgUrl(null); + const switchViewMode = async (mode: ViewMode) => { + if (!viewBlob) return; + setViewMode(mode); + setViewLoading(true); + try { await processBlob(viewBlob, mode); } finally { setViewLoading(false); } }; - // Download + const closeViewer = () => { + setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); + setViewEntry(null); setViewMode(null); setViewBlob(null); + setViewText(null); setViewHexData(null); + }; + + // ── Download ───────────────────────────────────────────────────────────── + const triggerDownload = (blob: Blob, name: string) => { const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = name; - a.click(); + const a = document.createElement('a'); + a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const downloadEntry = async (entry: EntryInfo) => { - try { - const blob = await getFileContents(entry.path); - triggerDownload(blob, entry.name); - } catch (e: any) { - toast.error(`Download failed: ${e?.message || e}`); - } + try { triggerDownload(await getFileContents(entry.path), entry.name); } + catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); } }; const downloadSelected = async () => { - const targets = entries.filter(e => selected.has(e.path) && e.type === 'file'); - for (const entry of targets) await downloadEntry(entry); + for (const e of entries.filter(e => selected.has(e.path) && e.type === 'file')) + await downloadEntry(e); }; - // Delete + // ── Delete ─────────────────────────────────────────────────────────────── + const deleteEntry = async (entry: EntryInfo) => { - const ok = window.confirm( - entry.type === 'folder' - ? `Delete folder "${entry.name}" and all its contents?` - : `Delete "${entry.name}"?`, - ); - if (!ok) return; - try { - await deletePath(entry.path); - toast.success(`Deleted ${entry.name}`); - void load(path); - } catch (e: any) { - toast.error(`Delete failed: ${e?.message || e}`); - } + if (!window.confirm(entry.type === 'folder' + ? `Delete folder "${entry.name}" and all its contents?` + : `Delete "${entry.name}"?`)) return; + try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); void load(path); } + catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); } }; const deleteSelected = async () => { const targets = entries.filter(e => selected.has(e.path)); - const ok = window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`); - if (!ok) return; + if (!window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`)) return; let failed = 0; - for (const entry of targets) { - try { - await deletePath(entry.path); - } catch { - failed++; - } - } + for (const e of targets) { try { await deletePath(e.path); } catch { failed++; } } if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`); - else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`); + else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`); void load(path); setSelected(new Set()); }; - // Rename + // ── Rename ─────────────────────────────────────────────────────────────── + const startRename = (entry: EntryInfo) => { setRenameEntry(entry); setRenameName(entry.name); @@ -277,23 +425,18 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa const commitRename = async () => { if (!renameEntry) return; const newName = renameName.trim(); - if (!newName || newName === renameEntry.name) { - setRenameEntry(null); - return; - } + if (!newName || newName === renameEntry.name) { setRenameEntry(null); return; } const dest = joinPath(splitPath(renameEntry.path).parent, newName); try { await movePath(renameEntry.path, dest); toast.success(`Renamed to "${newName}"`); void load(path); - } catch (e: any) { - toast.error(`Rename failed: ${e?.message || e}`); - } finally { - setRenameEntry(null); - } + } catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); } + finally { setRenameEntry(null); } }; - // Mount disk image onto a drive device + // ── Mount ──────────────────────────────────────────────────────────────── + const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => { if (!mountEntry || !setConfig || !config) return; const newConfig = JSON.parse(JSON.stringify(config)); @@ -309,7 +452,8 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa setMountEntry(null); }; - // Clipboard (copy / move) + // ── Clipboard ──────────────────────────────────────────────────────────── + const cutOrCopyEntry = (entry: EntryInfo, op: 'copy' | 'move') => { setClipboard({ op, paths: [entry.path] }); toast.success(`"${entry.name}" ${op === 'copy' ? 'copied' : 'cut'} — navigate and paste`); @@ -332,46 +476,37 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa if (!clipboard) return; let failed = 0; for (const src of clipboard.paths) { - const name = splitPath(src).name; - const dest = joinPath(path, name); - try { - if (clipboard.op === 'copy') await copyPath(src, dest); - else await movePath(src, dest); - } catch { - failed++; - } + const dest = joinPath(path, splitPath(src).name); + try { clipboard.op === 'copy' ? await copyPath(src, dest) : await movePath(src, dest); } + catch { failed++; } } if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed`); - else toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); + else toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); setClipboard(null); void load(path); }; - // New folder + // ── New folder ─────────────────────────────────────────────────────────── + const handleCreateFolder = async () => { const name = newFolderName.trim(); if (!name) return; try { await createFolder(joinPath(path, name), true); toast.success(`Created folder "${name}"`); - setShowNewFolder(false); - setNewFolderName(''); + setShowNewFolder(false); setNewFolderName(''); void load(path); - } catch (e: any) { - toast.error(`Failed to create folder: ${e?.message || e}`); - } + } catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); } }; - // Upload + // ── Upload ─────────────────────────────────────────────────────────────── + const handleUpload = async (file: File) => { - const target = joinPath(path, file.name); try { - await putFileContents(target, await file.arrayBuffer()); + await putFileContents(joinPath(path, file.name), await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); - } catch (e: any) { - toast.error(`Upload failed for ${file.name}: ${e?.message || e}`); - } + } catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); } }; const onPickFiles = (e: React.ChangeEvent) => { @@ -380,29 +515,25 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa e.target.value = ''; }; - // Drag & drop + // ── Drag & drop ────────────────────────────────────────────────────────── + const onDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - dragCounter.current++; + e.preventDefault(); dragCounter.current++; if (e.dataTransfer.types.includes('Files')) setDragOver(true); }; - - const onDragLeave = () => { - dragCounter.current--; - if (dragCounter.current <= 0) { dragCounter.current = 0; setDragOver(false); } - }; - - const onDragOver = (e: React.DragEvent) => e.preventDefault(); - + const onDragLeave = () => { if (--dragCounter.current <= 0) { dragCounter.current = 0; setDragOver(false); } }; + const onDragOver = (e: React.DragEvent) => e.preventDefault(); const onDrop = (e: React.DragEvent) => { - e.preventDefault(); - dragCounter.current = 0; - setDragOver(false); + e.preventDefault(); dragCounter.current = 0; setDragOver(false); Array.from(e.dataTransfer.files).forEach(f => void handleUpload(f)); }; + // ── Derived ────────────────────────────────────────────────────────────── + const pathParts = path.split('/').filter(Boolean); - const selCount = selected.size; + const selCount = selected.size; + + // ── Render ─────────────────────────────────────────────────────────────── return (
    - {/* Header */} + {/* ── Header ── */}
    {onBack && ( @@ -471,7 +602,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa )}
    - {/* Filter + sort bar */} + {/* ── Filter + sort bar ── */}
    @@ -499,7 +630,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa ))}
    - {/* Selection / clipboard bar */} + {/* ── Selection / clipboard bar ── */} {(selCount > 0 || clipboard) && (
    {selCount > 0 && ( @@ -538,12 +669,11 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
    )} - {/* File list */} + {/* ── File list ── */}
    {loading && (
    - - Loading… + Loading…
    )} @@ -551,7 +681,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
    Failed to load directory
    {error}
    -
    @@ -602,6 +732,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
    {renameEntry?.path === entry.path ? ( setRenameName(e.target.value)} onKeyDown={e => { @@ -609,12 +740,10 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa if (e.key === 'Escape') setRenameEntry(null); }} onBlur={() => void commitRename()} - ref={renameInputRef} onClick={e => e.stopPropagation()} onFocus={e => { - const dotIdx = renameName.lastIndexOf('.'); - const end = dotIdx > 0 ? dotIdx : renameName.length; - e.currentTarget.setSelectionRange(0, end); + const dot = renameName.lastIndexOf('.'); + e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length); }} className="w-full px-1 py-0 border border-blue-400 rounded text-sm" autoFocus @@ -642,9 +771,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa ))} {visible.length === 0 && entries.length === 0 && ( -
    - Empty folder — drop files here to upload -
    +
    Empty folder — drop files here to upload
    )} {visible.length === 0 && entries.length > 0 && (
    No files match the filter
    @@ -653,9 +780,9 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa )}
    - {/* Drag overlay */} + {/* ── Drag overlay ── */} {dragOver && ( -
    +
    Drop to upload to {path} @@ -663,7 +790,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
    )} - {/* Per-entry action menu */} + {/* ── Per-entry action menu ── */} !open && setActionEntry(null)}> @@ -673,31 +800,57 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
    + {/* Default open */} + + {/* Alternate viewers */} + {actionEntry?.type === 'file' && availableViewers(actionEntry) + .filter(m => m !== defaultViewMode(actionEntry)) + .map(mode => { + const entry = actionEntry; + return ( + + ); + })} + {actionEntry?.type === 'file' && ( )} + {actionEntry?.type === 'file' && fileCategory(actionEntry) === 'disk' && ( )} + +
    +
    - {/* File viewer overlay */} + {/* ── File viewer overlay ── */} {viewEntry && ( -
    -
    - - {viewEntry.name} - + ))} +
    +
    -
    + +
    {viewLoading && ( -
    +
    Loading…
    )} - {!viewLoading && viewImgUrl && ( - {viewEntry.name} + {!viewLoading && viewMode === 'image' && viewImgUrl && ( +
    + {viewEntry.name} +
    )} - {!viewLoading && viewText !== null && ( -
    {viewText}
    + {!viewLoading && viewMode === 'hex' && viewHexData && ( + + )} + {!viewLoading && viewMode === 'markdown' && viewText !== null && ( + + )} + {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( + )}
    )} - {/* Mount dialog */} + {/* ── Mount dialog ── */} !open && setMountEntry(null)}> @@ -762,21 +942,20 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa {mountEntry?.name} {(() => { - const driveDevices = Object.entries(config?.iec?.devices?.drive ?? {}) - .filter(([key]) => key !== 'vdrive' && key !== 'rom') - .map(([key, value]: [string, any]) => ({ deviceType: 'drive' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled })); - const meatloafDevices = Object.entries(config?.iec?.devices?.meatloaf ?? {}) - .map(([key, value]: [string, any]) => ({ deviceType: 'meatloaf' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled })); - const devices = [...driveDevices, ...meatloafDevices]; - if (devices.length === 0) { + const drives = Object.entries(config?.iec?.devices?.drive ?? {}) + .filter(([k]) => k !== 'vdrive' && k !== 'rom') + .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const meatloafs = Object.entries(config?.iec?.devices?.meatloaf ?? {}) + .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled })); + const devices = [...drives, ...meatloafs]; + if (!devices.length) return

    No drive devices found in config.

    ; - } return (
    {devices.map(dev => (
    ); -} \ No newline at end of file +}