feat(FileManager, DeviceDetailOverlay): enhance file handling with .lst support and improve device mounting logic

This commit is contained in:
Jaime Idolpx 2026-06-08 01:57:03 -04:00
parent 768c4c2336
commit 52d0e96961
2 changed files with 78 additions and 21 deletions

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { toast } from 'sonner';
import { getFileContents, joinPath } from '../webdav';
import FileBrowser from './FileBrowser';
interface Device {
@ -36,7 +38,6 @@ export default function DeviceDetailOverlay({
const [touchEnd, setTouchEnd] = useState(0);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [selectedMediaIndex, setSelectedMediaIndex] = useState(0);
const minSwipeDistance = 50;
@ -144,13 +145,48 @@ export default function DeviceDetailOverlay({
};
};
const mediaSet = detectMediaSet();
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
const mediaSetFiles: string[] | null = (() => {
if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) {
return deviceData.mediaSet as string[];
}
const detected = detectMediaSet();
return detected ? detected.files : null;
})();
const switchMedia = (index: number) => {
if (!mediaSet) return;
const switchMedia = (file: string) => {
const path = getDevicePath();
updateDeviceSetting([...path, 'url'], mediaSet.files[index]);
setSelectedMediaIndex(index);
updateDeviceSetting([...path, 'url'], file);
};
const handleFileSelect = async (selectedPath: string) => {
const devicePath = getDevicePath();
if (selectedPath.toLowerCase().endsWith('.lst')) {
try {
const text = await (await getFileContents(selectedPath)).text();
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
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('Swap list is empty'); return; }
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
dev.url = files[0];
dev.mediaSet = files;
setConfig(newConfig);
} catch (e: any) {
toast.error(`Failed to read swap list: ${e?.message ?? e}`);
}
} else {
const newConfig = JSON.parse(JSON.stringify(config));
let dev = newConfig;
for (const k of devicePath) dev = dev[k];
dev.url = selectedPath;
delete dev.mediaSet;
setConfig(newConfig);
}
};
const sendCommand = (command: string) => {
@ -322,20 +358,17 @@ export default function DeviceDetailOverlay({
</button>
</div>
{mediaSet && (
{mediaSetFiles && (
<div className="mt-3">
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
<div className="flex gap-2 flex-wrap">
{mediaSet.files.slice(0, 5).map((file, index) => {
// Attempt to extract a title from the filename, fallback to filename
// Example: /path/to/Game Disk.d64 or /path/to/disk1.d64
{mediaSetFiles.map((file: string, index: number) => {
const fileName = file.split('/').pop() || file;
// If you have a title mapping, replace this logic
const title = fileName.replace(/\.[^.]+$/, '');
return (
<button
key={index}
onClick={() => switchMedia(index)}
onClick={() => switchMedia(file)}
className={`px-3 py-1.5 rounded-lg text-sm ${
deviceData.url === file
? 'bg-blue-600 text-white'
@ -403,10 +436,7 @@ export default function DeviceDetailOverlay({
{showFileBrowser && (
<FileBrowser
currentPath={deviceData.url || '/'}
onSelect={(path) => {
const devicePath = getDevicePath();
updateDeviceSetting([...devicePath, 'url'], path);
}}
onSelect={(path) => { void handleFileSelect(path); setShowFileBrowser(false); }}
onClose={() => setShowFileBrowser(false)}
/>
)}

View File

@ -528,7 +528,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
// ── Mount ────────────────────────────────────────────────────────────────
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
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 = {};
@ -536,16 +536,43 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
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];
dev.url = mountEntry.path;
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.mediaSet = files;
} catch (e: any) {
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
return;
}
} else {
dev.url = mountEntry.path;
delete dev.mediaSet;
}
if (!dev.enabled) dev.enabled = 1;
setConfig(newConfig);
setMountEntry(null);
const deviceId = `${deviceType}-${key}`;
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`, {
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
const label = isLst
? `Loaded swap list "${mountEntry.name}" (${(dev.mediaSet 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,
});
setMountEntry(null);
};
// ── Clipboard ────────────────────────────────────────────────────────────
@ -1066,7 +1093,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
{devices.map(dev => (
<button
key={`${dev.type}-${dev.key}`}
onClick={() => mountOnDevice(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'}`} />