diff --git a/src/app/App.tsx b/src/app/App.tsx index 5446a8c..3cb3e49 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -299,7 +299,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) { + )} @@ -630,19 +636,32 @@ export default function MediaManager({ initialPath, rootPath, title, config, set if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType }; const dev = newConfig.devices.iec[key]; - if (mountEntry.name.toLowerCase().endsWith('.lst')) { + const mountExt = mountEntry.name.split('.').pop()?.toLowerCase() ?? ''; + if (PLAYLIST_EXTS.has(mountExt)) { try { - const text = await (await getFileContents(mountEntry.path)).text(); - const dir = splitPath(mountEntry.path).parent; - const candidates = text.split('\n') + const text = await (await getFileContents(mountEntry.path)).text(); + const dir = splitPath(mountEntry.path).parent; + const isVms = mountExt === 'vms'; + const candidates: MediaSetEntry[] = text.split('\n') .map(l => l.trim()) .filter(l => l.length > 0 && !l.startsWith('#')) - .map(l => l.startsWith('/') ? l : joinPath(dir, l)); + .map(l => { + if (isVms) { + const comma = l.indexOf(','); + if (comma > 0) { + const url = l.slice(0, comma).trim(); + const name = l.slice(comma + 1).trim(); + const resolved = url.startsWith('/') ? url : joinPath(dir, url); + return name ? { url: resolved, name } : resolved; + } + } + return l.startsWith('/') ? l : joinPath(dir, l); + }); if (candidates.length === 0) { toast.error(`${mountEntry.name}: swap list is empty`); return; } - const exists = await Promise.all(candidates.map(f => fileExists(f).catch(() => false))); + const exists = await Promise.all(candidates.map(e => fileExists(mediaSetEntryUrl(e)).catch(() => false))); const files = candidates.filter((_, i) => exists[i]); if (files.length === 0) { toast.error(`${mountEntry.name}: no files in swap list exist on device`); @@ -651,7 +670,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set if (files.length < candidates.length) { toast.warning(`${candidates.length - files.length} missing file(s) skipped from swap list`); } - dev.url = files[0]; + dev.url = mediaSetEntryUrl(files[0]); dev.media_set = files; } catch (e: any) { toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`); @@ -677,9 +696,9 @@ export default function MediaManager({ initialPath, rootPath, title, config, set 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}` + const isPlaylist = PLAYLIST_EXTS.has(mountExt); + const label = isPlaylist + ? `Loaded "${mountEntry.name}" (${(dev.media_set as MediaSetEntry[]).length} disks) on ${deviceType} #${key}` : `Mounted "${mountEntry.name}" on ${deviceType} #${key}`; toast.success(label, { action: onNavigateToDevice @@ -696,6 +715,28 @@ export default function MediaManager({ initialPath, rootPath, title, config, set setActionEntry(null); }; + const duplicateEntry = async (entry: EntryInfo) => { + const { parent } = splitPath(entry.path); + const dotIdx = entry.name.lastIndexOf('.'); + const base = dotIdx > 0 ? entry.name.slice(0, dotIdx) : entry.name; + const ext = dotIdx > 0 ? entry.name.slice(dotIdx) : ''; + let destName = `${base} copy${ext}`; + let destPath = joinPath(parent, destName); + let n = 2; + while (await fileExists(destPath)) { + destName = `${base} copy ${n}${ext}`; + destPath = joinPath(parent, destName); + n++; + } + try { + await copyPath(entry.path, destPath); + toast.success(`Duplicated as "${destName}"`); + void load(path); + } catch (e: any) { + toast.error(`Duplicate failed: ${e?.message ?? e}`); + } + }; + const copySelected = () => { setClipboard({ op: 'copy', paths: [...selected] }); toast.success(`${selected.size} item${selected.size !== 1 ? 's' : ''} copied — navigate and paste`); @@ -1109,6 +1150,7 @@ export default function MediaManager({ initialPath, rootPath, title, config, set onOpen={(e, mode) => void openEntry(e, mode)} onMount={e => setMountEntry(e)} onDownload={e => void downloadEntry(e)} + onDuplicate={e => void duplicateEntry(e)} onRename={e => startRename(e)} onCopy={e => cutOrCopyEntry(e, 'copy')} onCut={e => cutOrCopyEntry(e, 'move')}