import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowLeft, Check, ChevronLeft, ChevronRight, Copy, Download, Eye, File, FileText, Folder, FolderPlus, HardDrive, Home, Image as ImageIcon, Loader2, MoreVertical, Move, Pencil, RefreshCw, Search, Trash2, Upload, X, } from 'lucide-react'; import { copyPath, createFolder, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, joinPath, listDirectory, movePath, normalizePath, putFileContents, splitPath, type EntryInfo, } from '../webdav'; import { toast } from 'sonner'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from './ui/dialog'; type SortKey = 'name' | 'size' | 'date'; type Clipboard = { op: 'copy' | 'move'; paths: string[] }; type FileCategory = 'text' | 'image' | 'disk' | 'binary'; 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']); 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'; 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 ; } interface FileManagerProps { initialPath?: string; config?: any; setConfig?: (c: any) => void; onBack?: () => void; } export default function FileManager({ initialPath = '/', config, setConfig, onBack }: FileManagerProps) { const [path, setPath] = useState(normalizePath(initialPath)); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState>(new Set()); 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); const [viewImgUrl, setViewImgUrl] = useState(null); const [viewLoading, setViewLoading] = useState(false); 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 dragCounter = useRef(0); const load = useCallback(async (p: string) => { setLoading(true); setError(null); setSelected(new Set()); try { const items = await listDirectory(p); setEntries(items); } catch (e: any) { setError(e?.message || 'Failed to load directory'); setEntries([]); } finally { setLoading(false); } }, []); useEffect(() => { void load(path); }, [path, load]); const navigateTo = (p: string) => { setPath(normalizePath(p)); setFilter(''); }; const navigateUp = () => { if (path === '/') return; navigateTo(splitPath(path).parent); }; // 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' }); else if (sortKey === 'size') cmp = a.size - b.size; else if (sortKey === 'date') 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 = (entryPath: string) => { setSelected(prev => { const next = new Set(prev); if (next.has(entryPath)) next.delete(entryPath); else next.add(entryPath); 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) => { 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; } setViewEntry(entry); setViewText(null); setViewImgUrl(null); setViewLoading(true); try { const blob = await getFileContents(entry.path); if (cat === 'image') { setViewImgUrl(URL.createObjectURL(blob)); } else { setViewText(await blob.text()); } } catch (e: any) { toast.error(`Failed to open ${entry.name}: ${e?.message || e}`); setViewEntry(null); } finally { setViewLoading(false); } }; const closeViewer = () => { if (viewImgUrl) URL.revokeObjectURL(viewImgUrl); setViewEntry(null); setViewText(null); setViewImgUrl(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(); 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}`); } }; const downloadSelected = async () => { const targets = entries.filter(e => selected.has(e.path) && e.type === 'file'); for (const entry of targets) await downloadEntry(entry); }; // 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}`); } }; 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; let failed = 0; for (const entry of targets) { try { await deletePath(entry.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); }; 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}"`); void load(path); } catch (e: any) { toast.error(`Rename failed: ${e?.message || e}`); } finally { setRenameEntry(null); } }; // Mount disk image onto a drive device const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => { if (!mountEntry || !setConfig || !config) return; const url = `${getWebDAVBaseUrl()}${mountEntry.path}`; 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] = {}; newConfig.iec.devices[deviceType][key].url = url; setConfig(newConfig); toast.success(`Mounted "${mountEntry.name}" on device #${key}`); setMountEntry(null); }; // Clipboard (copy / move) 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 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++; } } 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}`); } }; // Upload const handleUpload = async (file: File) => { const target = joinPath(path, file.name); try { await putFileContents(target, 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) => { 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 = () => { dragCounter.current--; 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)); }; const pathParts = path.split('/').filter(Boolean); const selCount = selected.size; return (
{/* Header */}
{onBack && ( )}

File Manager

{/* Breadcrumb */}
{pathParts.map((part, i) => (
))}
{showNewFolder && (
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 />
)}
{/* Filter + sort bar */}
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 && ( )}
{(['name', 'size', 'date'] as SortKey[]).map(k => ( ))}
{/* Selection / clipboard bar */} {(selCount > 0 || clipboard) && (
{selCount > 0 && ( <> {selCount} selected )} {selCount === 0 && clipboard && ( <> {clipboard.op === 'copy' ? 'Copy' : 'Move'} {clipboard.paths.length} item{clipboard.paths.length !== 1 ? 's' : ''} here? )}
)} {/* File list */}
{loading && (
Loading…
)} {!loading && error && (
Failed to load directory
{error}
)} {!loading && !error && ( <> {path !== '/' && ( )} {visible.length > 0 && (
0} onChange={selectAll} className="w-4 h-4" /> {visible.length} item{visible.length !== 1 ? 's' : ''}{filter ? ' (filtered)' : ''}
)} {visible.map(entry => (
toggleSelect(entry.path)} onClick={e => e.stopPropagation()} className="w-4 h-4 flex-shrink-0" />
))} {visible.length === 0 && entries.length === 0 && (
Empty folder — drop files here to upload
)} {visible.length === 0 && entries.length > 0 && (
No files match the filter
)} )}
{/* Drag overlay */} {dragOver && (
Drop to upload to {path}
)} {/* Per-entry action menu */} !open && setActionEntry(null)}> {actionEntry?.name} {actionEntry?.type === 'folder' ? 'Folder' : humanFileSize(actionEntry?.size ?? 0)}
{actionEntry?.type === 'file' && ( )} {actionEntry?.type === 'file' && fileCategory(actionEntry) === 'disk' && ( )}
{/* File viewer overlay */} {viewEntry && (
{viewEntry.name}
{viewLoading && (
Loading…
)} {!viewLoading && viewImgUrl && ( {viewEntry.name} )} {!viewLoading && viewText !== null && (
{viewText}
)}
)} {/* Mount dialog */} !open && setMountEntry(null)}> Mount on Virtual Drive {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) { return

No drive devices found in config.

; } return (
{devices.map(dev => ( ))}
); })()}
); }