meatloaf-config/src/app/components/MediaManager.tsx

1318 lines
61 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
AlignLeft,
ArrowLeft,
BookOpen,
Braces,
CassetteTape,
Check,
CheckSquare,
ChevronLeft,
ChevronRight,
ClipboardPaste,
Code2,
Copy,
Cpu,
Disc,
Download,
Eye,
File,
FilePlus,
FileText,
Folder,
FolderPlus,
HardDrive,
Hash,
Home,
Image as ImageIcon,
Loader2,
MoreVertical,
Move,
Music,
Package,
SlidersHorizontal,
Pencil,
RefreshCw,
Save,
Search,
Trash2,
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 CodeMirror, { EditorView } from '@uiw/react-codemirror';
import { oneDark } from '@codemirror/theme-one-dark';
import HexEditor from './HexEditor';
import CodeEditor from './CodeEditor';
import ConfigEditor from './ConfigEditor';
import {
copyPath,
createFolder,
deletePath,
getFileContents,
humanFileSize,
joinPath,
listDirectory,
movePath,
normalizePath,
putFileContents,
splitPath,
type EntryInfo,
} from '../webdav';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog';
// ─── Types ───────────────────────────────────────────────────────────────────
type SortKey = 'name' | 'size' | 'date';
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image' | 'config';
// ─── 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 AUDIO_EXTS = new Set(['sid', 'psid', 'mus', 'vgm']);
const ROM_EXTS = new Set(['bin', 'rom', 'crt']);
const TAPE_EXTS = new Set(['tap', 'htap', 't64', 'tcrt']);
const DISK_EXTS = new Set(['d41', 'd64', 'd71', 'd80', 'd81', 'd82', 'g64', 'g71', 'g81', 'p64', 'p71', 'p81', 'nib']);
const DISC_EXTS = new Set(['iso', 'img', 'cue']);
const HD_EXTS = new Set(['d1m', 'd2m', 'd4m', 'd90', 'dhd', 'hdd']);
const ARCHIVE_EXTS = new Set(['zip', '7z', 'tar', 'gz', 'bz2', 'xz', 'rar', 'arj', 'lzh', 'ace', 'z', 'lha', 'cab', 'lbr', 'arc', 'ark', 'lnx', 'bbt', 'd8b', 'dfi']);
const CONFIG_EXTS = new Set(['config']);
function defaultViewMode(entry: EntryInfo): ViewMode {
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
if (IMAGE_EXTS.has(ext)) return 'image';
if (CONFIG_EXTS.has(ext)) return 'config';
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<ViewMode, ViewMode[]> = {
image: ['image', 'hex'],
config: ['config', 'text', '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<ViewMode, string> = {
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', config: 'Config',
};
// ─── Viewer components ───────────────────────────────────────────────────────
function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) {
const cls = className ?? 'w-4 h-4';
switch (mode) {
case 'text': return <AlignLeft className={cls} />;
case 'markdown': return <BookOpen className={cls} />;
case 'json': return <Braces className={cls} />;
case 'xml': return <Code2 className={cls} />;
case 'hex': return <Hash className={cls} />;
case 'image': return <ImageIcon className={cls} />;
case 'config': return <SlidersHorizontal className={cls} />;
}
}
function MarkdownViewer({ text }: { text: string }) {
return (
<div className="p-6 overflow-auto h-full text-neutral-200 text-sm leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className="text-2xl font-bold mt-6 mb-3 text-white border-b border-neutral-700 pb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-xl font-bold mt-5 mb-2 text-white">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-semibold mt-4 mb-1 text-white">{children}</h3>,
p: ({ children }) => <p className="my-2">{children}</p>,
a: ({ href, children }) => <a href={href} className="text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className ?? '');
const inline = !match && !String(children).includes('\n');
return inline
? <code className="bg-neutral-700 text-green-300 rounded px-1 text-xs font-mono" {...props}>{children}</code>
: (
<div className="my-3 rounded overflow-hidden text-xs">
<SyntaxHighlighter
language={match?.[1] ?? 'text'}
style={vscDarkPlus}
customStyle={{ margin: 0, background: '#1a1a1a' }}
PreTag="div"
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
},
pre: ({ children }) => <>{children}</>,
blockquote: ({ children }) => <blockquote className="border-l-4 border-neutral-500 pl-4 my-2 text-neutral-400 italic">{children}</blockquote>,
ul: ({ children }) => <ul className="list-disc pl-5 my-2 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-2 space-y-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
hr: () => <hr className="border-neutral-600 my-4" />,
table: ({ children }) => <table className="border-collapse my-3 text-sm w-full">{children}</table>,
th: ({ children }) => <th className="border border-neutral-600 px-3 py-1 bg-neutral-800 font-semibold text-left">{children}</th>,
td: ({ children }) => <td className="border border-neutral-600 px-3 py-1">{children}</td>,
strong: ({ children }) => <strong className="text-white font-semibold">{children}</strong>,
img: ({ src, alt }) => <img src={src} alt={alt} className="max-w-full my-2 rounded" />,
}}
>
{text}
</ReactMarkdown>
</div>
);
}
const CM_THEME = EditorView.theme({
'&': { height: '100%', background: '#0a0a0a' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: '1.5' },
'.cm-content': { padding: '12px 0' },
'.cm-focused': { outline: 'none' },
});
function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) => Promise<void> }) {
const [editMode, setEditMode] = useState(false);
const [editInitText, setEditInitText] = useState('');
const [saving, setSaving] = useState(false);
const editorViewRef = useRef<EditorView | null>(null);
const save = async () => {
if (!editorViewRef.current || !onSave) return;
setSaving(true);
try { await onSave(editorViewRef.current.state.doc.toString()); }
finally { setSaving(false); }
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
{onSave && (
<button
onClick={() => { if (!editMode) setEditInitText(text); setEditMode(v => !v); }}
className={editMode
? 'px-2 py-1 rounded bg-amber-600 text-white inline-flex items-center gap-1'
: 'px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1'}
>
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
</button>
)}
{editMode && onSave && (
<button onClick={() => void save()} disabled={saving}
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1">
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
{editMode && <span className="text-neutral-600 ml-auto">Ctrl+Z/Y undo · Ctrl+F search</span>}
</div>
{editMode ? (
<div className="flex-1 overflow-hidden">
<CodeMirror
value={editInitText}
extensions={[CM_THEME]}
theme={oneDark}
height="100%"
onCreateEditor={v => { editorViewRef.current = v; }}
/>
</div>
) : (
<MarkdownViewer text={text} />
)}
</div>
);
}
// ─── Entry icon ───────────────────────────────────────────────────────────────
function EntryIcon({ entry }: { entry: EntryInfo }) {
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
if (DISK_EXTS.has(ext)) return <Save className="w-5 h-5 text-amber-500 flex-shrink-0" />;
if (HD_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-orange-500 flex-shrink-0" />;
if (DISC_EXTS.has(ext)) return <Disc className="w-5 h-5 text-sky-500 flex-shrink-0" />;
if (TAPE_EXTS.has(ext)) return <CassetteTape className="w-5 h-5 text-rose-400 flex-shrink-0" />;
if (ROM_EXTS.has(ext)) return <Cpu className="w-5 h-5 text-red-500 flex-shrink-0" />;
if (AUDIO_EXTS.has(ext)) return <Music className="w-5 h-5 text-teal-500 flex-shrink-0" />;
if (ARCHIVE_EXTS.has(ext)) return <Package className="w-5 h-5 text-yellow-600 flex-shrink-0" />;
if (CONFIG_EXTS.has(ext)) return <SlidersHorizontal className="w-5 h-5 text-slate-400 flex-shrink-0" />;
if (JSON_EXTS.has(ext)) return <Braces className="w-5 h-5 text-yellow-500 flex-shrink-0" />;
if (XML_EXTS.has(ext)) return <Code2 className="w-5 h-5 text-cyan-500 flex-shrink-0" />;
if (MD_EXTS.has(ext)) return <BookOpen className="w-5 h-5 text-sky-400 flex-shrink-0" />;
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
}
// ─── ActionsModal ─────────────────────────────────────────────────────────────
interface FolderManagementActions {
onMountFolder: () => void;
onNewFolder: () => void;
onNewFile: () => void;
onUpload: () => void;
clipboard: Clipboard | null;
onPaste: () => void;
selectedCount: number;
totalCount: number;
onSelectAll: () => void;
isRoot: boolean;
}
interface ActionsModalProps {
entry: EntryInfo | null;
onClose: () => void;
onOpen: (entry: EntryInfo, mode?: ViewMode) => void;
onMount: (entry: EntryInfo) => void;
onDownload: (entry: EntryInfo) => void;
onRename: (entry: EntryInfo) => void;
onCopy: (entry: EntryInfo) => void;
onCut: (entry: EntryInfo) => void;
onDelete: (entry: EntryInfo) => void;
folderManagement?: FolderManagementActions;
}
function ActionsModal({ entry, onClose, onOpen, onMount, onDownload, onRename, onCopy, onCut, onDelete, folderManagement }: ActionsModalProps) {
const isFolder = entry?.type === 'folder';
const fm = folderManagement;
return (
<Dialog open={entry !== null} onOpenChange={open => !open && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="truncate">{entry?.name || '/'}</DialogTitle>
<DialogDescription>
{isFolder ? 'Folder' : humanFileSize(entry?.size ?? 0)}
</DialogDescription>
</DialogHeader>
{entry && (
<div className="flex flex-col gap-2">
{/* Folder management items — current-folder context (header Actions) */}
{fm && (
<>
<button onClick={() => { onClose(); fm.onMountFolder(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount Folder</span>
</button>
<div className="border-t border-neutral-100" />
<button onClick={() => { onClose(); fm.onNewFolder(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<FolderPlus className="w-4 h-4 text-neutral-500" /> <span>New Folder</span>
</button>
<button onClick={() => { onClose(); fm.onNewFile(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<FilePlus className="w-4 h-4 text-neutral-500" /> <span>New File</span>
</button>
<button onClick={() => { onClose(); fm.onUpload(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Upload className="w-4 h-4 text-neutral-500" /> <span>Upload Files</span>
</button>
{fm.clipboard && (
<button onClick={() => { onClose(); fm.onPaste(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<ClipboardPaste className="w-4 h-4 text-neutral-500" />
<span>{fm.clipboard.op === 'copy' ? 'Copy' : 'Move'} {fm.clipboard.paths.length} item{fm.clipboard.paths.length !== 1 ? 's' : ''} here</span>
</button>
)}
<button onClick={() => { onClose(); fm.onSelectAll(); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<CheckSquare className="w-4 h-4 text-neutral-500" />
<span>{fm.selectedCount === fm.totalCount && fm.totalCount > 0 ? 'Deselect All' : 'Select All'}</span>
</button>
{!fm.isRoot && <div className="border-t border-neutral-100" />}
</>
)}
{/* Open folder — list item only (not current-folder context) */}
{isFolder && !fm && (
<button onClick={() => { onClose(); onOpen(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<Folder className="w-4 h-4 text-blue-600" /> <span>Open folder</span>
</button>
)}
{/* File actions */}
{!isFolder && (
<>
<button onClick={() => { onClose(); onMount(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3">
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
</button>
<button onClick={() => { onClose(); onOpen(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Eye className="w-4 h-4 text-blue-600" />
<span className="flex-1">Open / View</span>
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(entry)]}</span>
</button>
{availableViewers(entry).filter(m => m !== defaultViewMode(entry)).map(mode => (
<button key={mode} onClick={() => { onClose(); onOpen(entry, mode); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
<span>Open as {VIEWER_LABEL[mode]}</span>
</button>
))}
<button onClick={() => { onClose(); onDownload(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Download className="w-4 h-4" /> <span>Download</span>
</button>
</>
)}
{/* Rename / Copy / Move / Delete */}
{(!fm || !fm.isRoot) && (
<>
<div className="border-t border-neutral-100" />
<button onClick={() => { onClose(); onRename(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Pencil className="w-4 h-4" /> <span>Rename</span>
</button>
{!fm && (
<>
<button onClick={() => { onClose(); onCopy(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Copy className="w-4 h-4" /> <span>Copy</span>
</button>
<button onClick={() => { onClose(); onCut(entry); }}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3">
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
</button>
</>
)}
<button onClick={() => { onClose(); onDelete(entry); }}
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3">
<Trash2 className="w-4 h-4" /> <span>Delete</span>
</button>
</>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface MediaManagerProps {
initialPath?: string;
rootPath?: string;
title?: string;
config?: any;
setConfig?: (c: any) => void;
onBack?: () => void;
onNavigateToDevice?: (deviceId: string) => void;
}
// ─── File cache (session + localStorage) ─────────────────────────────────────
const _sessionCache = new Map<string, Uint8Array>();
const FM_LS_MAX = 2 * 1024 * 1024; // only persist ≤ 2 MB to localStorage
function _cacheKey(path: string, size: number, mtime: string | null) {
return `${path}|${size}|${mtime ?? ''}`;
}
function _lsGet(key: string): Uint8Array | null {
try {
const raw = localStorage.getItem('fmcache:' + key);
if (!raw) return null;
const bin = atob(raw);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
} catch { return null; }
}
function _lsSet(key: string, data: Uint8Array) {
if (data.length > FM_LS_MAX) return;
try {
let b64 = '';
for (let i = 0; i < data.length; i += 8192) {
b64 += btoa(
String.fromCharCode.apply(null, data.slice(i, Math.min(i + 8192, data.length)) as unknown as number[]),
);
}
localStorage.setItem('fmcache:' + key, b64);
} catch { /* quota exceeded — skip */ }
}
async function _getEntryBytes(entry: EntryInfo): Promise<Uint8Array> {
const key = _cacheKey(entry.path, entry.size, entry.lastModified?.toISOString() ?? null);
const sess = _sessionCache.get(key);
if (sess) return sess;
const ls = _lsGet(key);
if (ls) { _sessionCache.set(key, ls); return ls; }
const blob = await getFileContents(entry.path);
const data = new Uint8Array(await blob.arrayBuffer());
_sessionCache.set(key, data);
_lsSet(key, data);
return data;
}
// ─── Main component ───────────────────────────────────────────────────────────
const FM_PATH_KEY = 'fileManager.path';
export default function MediaManager({ initialPath = '/', rootPath, title, config, setConfig, onBack, onNavigateToDevice }: MediaManagerProps) {
const pathKey = rootPath ? `fileManager.path:${rootPath}` : FM_PATH_KEY;
const [path, setPath] = useState(() => normalizePath(localStorage.getItem(pathKey) || rootPath || initialPath));
const [entries, setEntries] = useState<EntryInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState(() => localStorage.getItem('fileManager.filter') ?? '');
const [sortKey, setSortKey] = useState<SortKey>(() => (localStorage.getItem('fileManager.sortKey') as SortKey) ?? 'name');
const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false');
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const [folderActionOpen, setFolderActionOpen] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [showNewFile, setShowNewFile] = useState(false);
const [newFileName, setNewFileName] = useState('');
// Viewer
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [viewText, setViewText] = useState<string | null>(null);
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
const [viewHexData, setViewHexData] = useState<Uint8Array | null>(null);
const [viewLoading, setViewLoading] = useState(false);
// Rename / folder
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [renameEntry, setRenameEntry] = useState<EntryInfo | null>(null);
const [renameName, setRenameName] = useState('');
const [mountEntry, setMountEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
// ── Directory loading ────────────────────────────────────────────────────
const load = useCallback(async (p: string) => {
setLoading(true);
setError(null);
setSelected(new Set());
try {
setEntries(await listDirectory(p));
} catch (e: any) {
setError(e?.message ?? 'Failed to load directory');
setEntries([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(path); }, [path, load]);
useEffect(() => { localStorage.setItem('fileManager.filter', filter); }, [filter]);
useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]);
useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]);
// ── Folder config (.config) ──────────────────────────────────────────────
const [folderConfig, setFolderConfig] = useState<Record<string, string> | null>(null);
useEffect(() => {
let cancelled = false;
getFileContents(joinPath(path, '.config'))
.then(async blob => {
if (cancelled) return;
const cfg: Record<string, string> = {};
for (const line of (await blob.text()).split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
cfg[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
}
setFolderConfig(cfg);
})
.catch(() => { if (!cancelled) setFolderConfig(null); });
return () => { cancelled = true; };
}, [path]);
const navigateTo = (p: string) => {
let norm = normalizePath(p);
if (rootPath && !norm.startsWith(rootPath)) norm = rootPath;
localStorage.setItem(pathKey, norm);
setPath(norm);
setFilter('');
};
const navigateUp = () => { if (path !== (rootPath ?? '/')) navigateTo(splitPath(path).parent); };
// ── Sort + filter ────────────────────────────────────────────────────────
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' });
else if (sortKey === 'size') cmp = a.size - b.size;
else cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0);
return sortAsc ? cmp : -cmp;
});
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortAsc(v => !v);
else { setSortKey(key); setSortAsc(true); }
};
// ── 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 openEntry = async (entry: EntryInfo, mode?: ViewMode) => {
if (renameEntry !== null) return;
if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; }
const targetMode = mode ?? defaultViewMode(entry);
setViewEntry(entry);
setViewMode(targetMode);
setViewText(null);
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
setViewHexData(null);
setViewLoading(true);
try {
const bytes = await _getEntryBytes(entry);
if (targetMode === 'image') {
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
setViewImgUrl(URL.createObjectURL(new Blob([ab])));
} else if (targetMode === 'hex') {
setViewHexData(bytes);
} else {
setViewText(new TextDecoder().decode(bytes));
}
} catch (e: any) {
toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`);
setViewEntry(null);
setViewMode(null);
} finally {
setViewLoading(false);
}
};
const switchViewMode = async (mode: ViewMode) => {
if (!viewEntry) return;
setViewMode(mode);
setViewLoading(true);
try {
const bytes = await _getEntryBytes(viewEntry);
if (mode === 'image') {
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); });
} else if (mode === 'hex') {
setViewHexData(bytes);
} else {
setViewText(new TextDecoder().decode(bytes));
}
} finally {
setViewLoading(false);
}
};
const closeViewer = () => {
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
setViewEntry(null); setViewMode(null);
setViewText(null); setViewHexData(null);
};
const saveViewFile = async (content: string | Uint8Array) => {
if (!viewEntry) throw new Error('No file open');
const bytes: Uint8Array = typeof content === 'string'
? new TextEncoder().encode(content)
: content;
await putFileContents(viewEntry.path, bytes);
if (typeof content === 'string') setViewText(content);
else setViewHexData(bytes);
// Keep cache coherent within this session
const key = _cacheKey(viewEntry.path, viewEntry.size, viewEntry.lastModified?.toISOString() ?? null);
_sessionCache.set(key, bytes);
_lsSet(key, bytes);
toast.success(`Saved ${viewEntry.name}`);
void load(path);
};
// ── 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();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const downloadEntry = async (entry: EntryInfo) => {
try { triggerDownload(await getFileContents(entry.path), entry.name); }
catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
};
const downloadSelected = async () => {
for (const e of entries.filter(e => selected.has(e.path) && e.type === 'file'))
await downloadEntry(e);
};
// ── Delete ───────────────────────────────────────────────────────────────
const deleteEntry = async (entry: EntryInfo) => {
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));
if (!window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`)) return;
let failed = 0;
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' : ''}`);
void load(path);
setSelected(new Set());
};
// ── Rename ───────────────────────────────────────────────────────────────
const startRename = (entry: EntryInfo) => {
setRenameEntry(entry);
setRenameName(entry.name);
setActionEntry(null);
setFolderActionOpen(false);
setTimeout(() => renameInputRef.current?.focus(), 50);
};
const commitRename = async () => {
if (!renameEntry) return;
const newName = renameName.trim();
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}"`);
if (renameEntry.path === path) navigateTo(dest);
else void load(path);
} catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); }
finally { setRenameEntry(null); }
};
// ── Mount ────────────────────────────────────────────────────────────────
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
if (!mountEntry || !setConfig || !config) return;
const newConfig = JSON.parse(JSON.stringify(config));
if (!newConfig.iec) newConfig.iec = {};
if (!newConfig.iec.devices) newConfig.iec.devices = {};
if (!newConfig.iec.devices[deviceType]) newConfig.iec.devices[deviceType] = {};
if (!newConfig.iec.devices[deviceType][key]) newConfig.iec.devices[deviceType][key] = {};
const dev = newConfig.iec.devices[deviceType][key];
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
try {
const text = await (await getFileContents(mountEntry.path)).text();
const dir = splitPath(mountEntry.path).parent;
const files = text.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('#'))
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
if (files.length === 0) {
toast.error(`${mountEntry.name}: swap list is empty`);
return;
}
dev.url = files[0];
dev.media_set = files;
} catch (e: any) {
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
return;
}
} else {
dev.url = mountEntry.path;
delete dev.media_set;
}
if (!dev.enabled) dev.enabled = 1;
if (folderConfig?.['base_url']) {
dev.base_url = folderConfig['base_url'];
delete dev.url;
}
if (folderConfig?.['cache'] === '.') dev.cache = path;
setConfig(newConfig);
setMountEntry(null);
const deviceId = `${deviceType}-${key}`;
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
const label = isLst
? `Loaded swap list "${mountEntry.name}" (${(dev.media_set as string[]).length} disks) on ${deviceType} #${key}`
: `Mounted "${mountEntry.name}" on ${deviceType} #${key}`;
toast.success(label, {
action: onNavigateToDevice
? { label: 'View Device', onClick: () => onNavigateToDevice(deviceId) }
: undefined,
});
};
// ── Clipboard ────────────────────────────────────────────────────────────
const cutOrCopyEntry = (entry: EntryInfo, op: 'copy' | 'move') => {
setClipboard({ op, paths: [entry.path] });
toast.success(`"${entry.name}" ${op === 'copy' ? 'copied' : 'cut'} — navigate and paste`);
setActionEntry(null);
};
const copySelected = () => {
setClipboard({ op: 'copy', paths: [...selected] });
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`);
setSelected(new Set());
};
const moveSelected = () => {
setClipboard({ op: 'move', paths: [...selected] });
toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} cut — navigate and paste`);
setSelected(new Set());
};
const paste = async () => {
if (!clipboard) return;
let failed = 0;
for (const src of clipboard.paths) {
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' : ''}`);
setClipboard(null);
void load(path);
};
// ── 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('');
void load(path);
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
};
// ── New file ─────────────────────────────────────────────────────────────
const handleCreateFile = async () => {
const name = newFileName.trim();
if (!name) return;
try {
await putFileContents(joinPath(path, name), new Uint8Array(0));
toast.success(`Created "${name}"`);
setShowNewFile(false); setNewFileName('');
void load(path);
} catch (e: any) { toast.error(`Failed to create file: ${e?.message ?? e}`); }
};
// ── Upload ───────────────────────────────────────────────────────────────
const handleUpload = async (file: File) => {
try {
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}`); }
};
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
Array.from(e.target.files).forEach(f => void handleUpload(f));
e.target.value = '';
};
// ── Drag & drop ──────────────────────────────────────────────────────────
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault(); dragCounter.current++;
if (e.dataTransfer.types.includes('Files')) setDragOver(true);
};
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);
Array.from(e.dataTransfer.files).forEach(f => void handleUpload(f));
};
// ── Derived ──────────────────────────────────────────────────────────────
const pathParts = path.split('/').filter(Boolean);
const selCount = selected.size;
// ── Render ───────────────────────────────────────────────────────────────
return (
<div
className="relative flex flex-col h-full"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* ── Header ── */}
<div className="bg-white border-b border-neutral-200 px-4 py-3 flex-shrink-0">
<div className="flex items-center gap-2 mb-2">
{onBack && (
<button onClick={onBack} className="p-1 rounded hover:bg-neutral-100" title="Back">
<ChevronLeft className="w-5 h-5" />
</button>
)}
<h2 className="font-semibold flex-1 text-sm">{title ?? 'Media Manager'}</h2>
<button onClick={() => void load(path)} className="p-1.5 rounded hover:bg-neutral-100" title="Refresh">
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => { setShowNewFile(v => !v); setShowNewFolder(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New File">
<FilePlus className="w-4 h-4" />
</button>
<button onClick={() => { setShowNewFolder(v => !v); setShowNewFile(false); }} className="p-1.5 rounded hover:bg-neutral-100" title="New Folder">
<FolderPlus className="w-4 h-4" />
</button>
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded hover:bg-neutral-100" title="Upload">
<Upload className="w-4 h-4" />
</button>
<button onClick={() => setFolderActionOpen(true)} className="p-1.5 rounded hover:bg-neutral-100" title="Actions">
<MoreVertical className="w-4 h-4" />
</button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm text-neutral-600 overflow-x-auto">
<button onClick={() => navigateTo(rootPath ?? '/')} className="p-1 rounded hover:bg-neutral-100 flex-shrink-0" title="Root">
<Home className="w-4 h-4" />
</button>
{pathParts.map((part, i) => {
const isLast = i === pathParts.length - 1;
const isEditing = isLast && renameEntry !== null && renameEntry.path === path;
return (
<div key={i} className="flex items-center gap-1 flex-shrink-0">
<ChevronRight className="w-3 h-3 text-neutral-400" />
{isEditing ? (
<input
ref={renameInputRef}
value={renameName}
onChange={e => setRenameName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename();
if (e.key === 'Escape') setRenameEntry(null);
}}
onBlur={() => void commitRename()}
onFocus={e => e.currentTarget.select()}
className="px-1 py-0 border border-blue-400 rounded text-sm max-w-[120px] min-w-[60px]"
autoFocus
/>
) : (
<button
onClick={() => navigateTo('/' + pathParts.slice(0, i + 1).join('/'))}
className="hover:text-blue-600 hover:underline max-w-[120px] truncate"
>
{part}
</button>
)}
</div>
);
})}
</div>
{(folderConfig?.['base_url']) && (
<div className="text-xs text-neutral-400 mt-0.5 truncate">
Base: {folderConfig?.['base_url']}
</div>
)}
{showNewFile && (
<div className="mt-2 flex items-center gap-2">
<input
value={newFileName}
onChange={e => setNewFileName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void handleCreateFile();
if (e.key === 'Escape') { setShowNewFile(false); setNewFileName(''); }
}}
placeholder="New file name"
className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded"
autoFocus
/>
<button onClick={() => void handleCreateFile()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded">
Create
</button>
</div>
)}
{showNewFolder && (
<div className="mt-2 flex items-center gap-2">
<input
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void handleCreateFolder();
if (e.key === 'Escape') { setShowNewFolder(false); setNewFolderName(''); }
}}
placeholder="New folder name"
className="flex-1 px-2 py-1 text-sm border border-neutral-300 rounded"
autoFocus
/>
<button onClick={() => void handleCreateFolder()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded">
Create
</button>
</div>
)}
</div>
{/* ── Filter + sort bar ── */}
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex items-center gap-2 flex-shrink-0">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter…"
className="w-full pl-7 pr-6 py-1 text-sm border border-neutral-300 rounded bg-white"
/>
{filter && (
<button onClick={() => setFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2">
<X className="w-3.5 h-3.5 text-neutral-400" />
</button>
)}
</div>
{(['name', 'size', 'date'] as SortKey[]).map(k => (
<button
key={k}
onClick={() => toggleSort(k)}
className={`text-xs px-2 py-1 rounded border flex-shrink-0 ${sortKey === k ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-neutral-300 bg-white text-neutral-600'}`}
>
{k === 'name' ? 'Name' : k === 'size' ? 'Size' : 'Date'}
{sortKey === k ? (sortAsc ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
{/* ── Selection / clipboard bar ── */}
{(selCount > 0 || clipboard) && (
<div className="bg-blue-50 border-b border-blue-200 px-3 py-2 flex items-center gap-2 flex-shrink-0 flex-wrap text-sm">
{selCount > 0 && (
<>
<span className="text-blue-700 font-medium">{selCount} selected</span>
<button onClick={() => void downloadSelected()} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
<Download className="w-3.5 h-3.5" /> Download
</button>
<button onClick={copySelected} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
<Copy className="w-3.5 h-3.5" /> Copy
</button>
<button onClick={moveSelected} className="px-2 py-0.5 rounded border border-blue-300 bg-white text-blue-700 hover:bg-blue-100 inline-flex items-center gap-1">
<Move className="w-3.5 h-3.5" /> Move
</button>
<button onClick={() => void deleteSelected()} className="px-2 py-0.5 rounded border border-red-300 bg-white text-red-700 hover:bg-red-50 inline-flex items-center gap-1">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
<button onClick={() => setSelected(new Set())} className="ml-auto p-1 rounded hover:bg-blue-200">
<X className="w-3.5 h-3.5 text-blue-600" />
</button>
</>
)}
{selCount === 0 && clipboard && (
<>
<span className="text-blue-700 font-medium">
{clipboard.op === 'copy' ? 'Copy' : 'Move'} {clipboard.paths.length} item{clipboard.paths.length !== 1 ? 's' : ''} here?
</span>
<button onClick={() => void paste()} className="px-3 py-0.5 rounded bg-blue-600 text-white hover:bg-blue-700 inline-flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Paste
</button>
<button onClick={() => setClipboard(null)} className="ml-auto p-1 rounded hover:bg-blue-200">
<X className="w-3.5 h-3.5 text-blue-600" />
</button>
</>
)}
</div>
)}
{/* ── File list ── */}
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="p-8 flex flex-col items-center gap-2 text-neutral-500 text-sm">
<Loader2 className="w-6 h-6 animate-spin" /> Loading
</div>
)}
{!loading && error && (
<div className="p-4 text-sm">
<div className="text-red-600 mb-1">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{error}</div>
<button onClick={() => void load(path)} className="mt-3 text-blue-600 inline-flex items-center gap-1">
<RefreshCw className="w-3 h-3" /> Retry
</button>
</div>
)}
{!loading && !error && (
<>
{path !== '/' && (
<button
onClick={navigateUp}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-neutral-50 border-b border-neutral-100 text-left"
>
<div className="w-4 flex-shrink-0" />
<ArrowLeft className="w-5 h-5 text-neutral-400 flex-shrink-0" />
<span className="text-neutral-600">..</span>
</button>
)}
{visible.length > 0 && (
<div className="px-4 py-1.5 flex items-center gap-3 border-b border-neutral-100 bg-neutral-50 text-xs text-neutral-500">
<input
type="checkbox"
checked={selCount === visible.length && visible.length > 0}
onChange={selectAll}
className="w-4 h-4"
/>
<span>{visible.length} item{visible.length !== 1 ? 's' : ''}{filter ? ' (filtered)' : ''}</span>
</div>
)}
{visible.map(entry => (
<div
key={entry.path}
className={`px-4 py-3 flex items-center gap-3 border-b border-neutral-100 hover:bg-neutral-50 ${selected.has(entry.path) ? 'bg-blue-50' : ''}`}
>
<input
type="checkbox"
checked={selected.has(entry.path)}
onChange={() => toggleSelect(entry.path)}
onClick={e => e.stopPropagation()}
className="w-4 h-4 flex-shrink-0"
/>
<button
onClick={() => {
if (renameEntry !== null) return;
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
else setMountEntry(entry);
}}
className="flex-1 flex items-center gap-3 text-left min-w-0"
>
<EntryIcon entry={entry} />
<div className="min-w-0 flex-1">
{renameEntry?.path === entry.path ? (
<input
ref={renameInputRef}
value={renameName}
onChange={e => setRenameName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename();
if (e.key === 'Escape') setRenameEntry(null);
}}
onBlur={() => void commitRename()}
onClick={e => e.stopPropagation()}
onFocus={e => {
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
/>
) : (
<div className="text-neutral-900 truncate text-sm">{entry.name}</div>
)}
{entry.type === 'file' && (
<div className="text-xs text-neutral-400 truncate">
{humanFileSize(entry.size)}
{entry.lastModified ? ` · ${entry.lastModified.toLocaleDateString()}` : ''}
</div>
)}
</div>
{entry.type === 'folder' && <ChevronRight className="w-4 h-4 text-neutral-400 flex-shrink-0" />}
</button>
<button
onClick={e => { e.stopPropagation(); if (renameEntry !== null) return; setActionEntry(entry); }}
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
title="Actions"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
))}
{visible.length === 0 && entries.length === 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">Empty folder drop files here to upload</div>
)}
{visible.length === 0 && entries.length > 0 && (
<div className="p-8 text-center text-neutral-500 text-sm">No files match the filter</div>
)}
</>
)}
</div>
{/* ── Drag overlay ── */}
{dragOver && (
<div className="absolute inset-0 bg-blue-200/60 flex items-center justify-center pointer-events-none z-40">
<div className="bg-white rounded-xl shadow-lg px-8 py-6 flex flex-col items-center gap-3 text-blue-700">
<Upload className="w-10 h-10" />
<span className="font-medium">Drop to upload to {path}</span>
</div>
</div>
)}
{/* ── Actions modal (per-entry + current-folder context) ── */}
<ActionsModal
entry={folderActionOpen
? { name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }
: actionEntry}
onClose={() => { setActionEntry(null); setFolderActionOpen(false); }}
onOpen={(e, mode) => void openEntry(e, mode)}
onMount={e => setMountEntry(e)}
onDownload={e => void downloadEntry(e)}
onRename={e => startRename(e)}
onCopy={e => cutOrCopyEntry(e, 'copy')}
onCut={e => cutOrCopyEntry(e, 'move')}
onDelete={e => void deleteEntry(e)}
folderManagement={folderActionOpen ? {
onMountFolder: () => setMountEntry({ name: splitPath(path).name || '/', path, type: 'folder', size: 0, lastModified: null, contentType: null }),
onNewFolder: () => { setShowNewFolder(true); setShowNewFile(false); },
onNewFile: () => { setShowNewFile(true); setShowNewFolder(false); },
onUpload: () => fileInputRef.current?.click(),
clipboard,
onPaste: () => void paste(),
selectedCount: selected.size,
totalCount: visible.length,
onSelectAll: selectAll,
isRoot: path === (rootPath ?? '/'),
} : undefined}
/>
{/* ── File viewer overlay ── */}
{viewEntry && (
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
{/* Title + mode switcher */}
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
<X className="w-5 h-5 text-white" />
</button>
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
<div className="flex items-center gap-1">
{viewMode && availableViewers(viewEntry).map(mode => (
<button
key={mode}
onClick={() => void switchViewMode(mode)}
title={VIEWER_LABEL[mode]}
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
viewMode === mode
? 'bg-blue-600 text-white'
: 'text-neutral-400 hover:bg-neutral-700 hover:text-white'
}`}
>
<ViewerModeIcon mode={mode} className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{VIEWER_LABEL[mode]}</span>
</button>
))}
</div>
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-700 text-neutral-300 hover:text-white" title="Download">
<Download className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden bg-neutral-950">
{viewLoading && (
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
<Loader2 className="w-5 h-5 animate-spin" /> Loading
</div>
)}
{!viewLoading && viewMode === 'image' && viewImgUrl && (
<div className="h-full flex items-center justify-center overflow-auto p-4">
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
</div>
)}
{!viewLoading && viewMode === 'hex' && viewHexData && (
<HexEditor key={viewEntry.path} data={viewHexData} onSave={d => saveViewFile(d)} />
)}
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
<MarkdownEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
)}
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
<CodeEditor key={viewEntry.path} text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
)}
{!viewLoading && viewMode === 'config' && viewText !== null && (
<ConfigEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
)}
</div>
</div>
)}
{/* ── Mount dialog ── */}
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Mount on Virtual Drive</DialogTitle>
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
</DialogHeader>
{(() => {
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, base_url: v?.base_url as string | undefined, 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, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled }));
const devices = [...drives, ...meatloafs];
if (!devices.length)
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
return (
<div className="flex flex-col gap-2">
{devices.map(dev => (
<button
key={`${dev.type}-${dev.key}`}
onClick={() => void mountOnDevice(dev.type, dev.key)}
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
>
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
<div className="min-w-0 flex-1">
<div className="font-medium text-sm">Device #{dev.key}</div>
{(dev.base_url || dev.url) && (
<div className="text-xs text-neutral-500 truncate">
{[dev.base_url, dev.url].filter(Boolean).join('')}
</div>
)}
</div>
{!dev.enabled && <span className="text-xs text-neutral-400 flex-shrink-0">disabled</span>}
</button>
))}
</div>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}