diff --git a/src/app/App.tsx b/src/app/App.tsx index 8f9c291..e75ed10 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw } from 'lucide-react'; +import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link } from 'lucide-react'; import { Toaster } from 'sonner'; import StatusPage from './components/StatusPage'; import DevicesPage from './components/DevicesPage'; @@ -9,12 +9,15 @@ import IECPage from './components/IECPage'; import OtherPage from './components/OtherPage'; import ToolsPage from './components/ToolsPage'; import SearchOverlay from './components/SearchOverlay'; +import FileManager from './components/FileManager'; import logoSvg from '../imports/logo.svg'; import { useSettings } from './settings'; type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId; type AppId = + | 'file-manager' + | 'serial-console' | 'directory-editor' | 'sector-editor' | 'bam-editor' @@ -51,6 +54,15 @@ export default function App() {

Apps

+ {/* Manangement Group */} +
+

Management

+
+ } label="File Manager" onClick={() => setCurrentPage('file-manager')} /> + } label="Serial Console" onClick={() => setCurrentPage('serial-console')} /> + } label="Short Codes" onClick={() => setCurrentPage('serial-console')} /> +
+
{/* Disk Group */}

Disk

@@ -96,6 +108,8 @@ export default function App() {
), // Individual app pages + 'file-manager': setCurrentPage('apps')} config={config} setConfig={setConfig} />, + 'serial-console': setCurrentPage('apps')} />, 'directory-editor': setCurrentPage('apps')} />, 'sector-editor': setCurrentPage('apps')} />, 'bam-editor': setCurrentPage('apps')} />, diff --git a/src/app/components/FileManager.tsx b/src/app/components/FileManager.tsx new file mode 100644 index 0000000..b1aa028 --- /dev/null +++ b/src/app/components/FileManager.tsx @@ -0,0 +1,794 @@ +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 => ( + + ))} +
+ ); + })()} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/webdav.ts b/src/app/webdav.ts index 4ee5b1e..4f1b58c 100644 --- a/src/app/webdav.ts +++ b/src/app/webdav.ts @@ -271,6 +271,17 @@ export async function movePath(from: string, to: string): Promise { ); } +export async function copyPath(from: string, to: string): Promise { + const manager = getWebDAVClient(); + const base = manager.client.baseUrl; + await manager.client.copymove( + 'COPY', + pathToUrl(normalizePath(from), base), + pathToUrl(normalizePath(to), base), + true, + ); +} + export async function putFileContents( path: string, data: string | ArrayBuffer | Uint8Array | Blob,