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')}