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 */}
+
+
+ {/* File viewer overlay */}
+ {viewEntry && (
+
+
+
+ {viewEntry.name}
+
+
+
+ {viewLoading && (
+
+ Loading…
+
+ )}
+ {!viewLoading && viewImgUrl && (
+

+ )}
+ {!viewLoading && viewText !== null && (
+
{viewText}
+ )}
+
+
+ )}
+
+ {/* Mount dialog */}
+
+
+ );
+}
\ 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,