diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 0827a23..3b152f2 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 6646d14..05919cb 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 6d87efe..a9a0f85 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -84,6 +84,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="context-menu-edit-btn"', body) self.assertIn('id="context-menu-download-btn"', body) self.assertIn('id="context-menu-rename-btn"', body) + self.assertIn('id="context-menu-duplicate-btn"', body) self.assertIn('id="context-menu-copy-btn"', body) self.assertIn('id="context-menu-move-btn"', body) self.assertIn('id="context-menu-delete-btn"', body) @@ -307,6 +308,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('setStatus(zipDownload ? "Preparing download..." : "Requesting download...");', app_js) self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js) + self.assertIn('"/api/files/duplicate"', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) @@ -330,10 +332,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuEdit()', app_js) self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) + self.assertIn('function startDuplicateSelected()', app_js) + self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) self.assertIn('function startContextMenuDelete()', app_js) self.assertIn('function startContextMenuProperties()', app_js) + self.assertIn('contextMenu.duplicateButton.onclick = startContextMenuDuplicate;', app_js) self.assertIn('selectedPathsSet.has(entry.path)', app_js) self.assertIn('entry.isParent', app_js) self.assertIn('row.oncontextmenu = (event) => {', app_js) @@ -354,6 +359,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js) self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) + self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js) + self.assertIn('elements.duplicateButton.disabled = items.length === 0;', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) self.assertIn('elements.moveButton.classList.remove("hidden");', app_js) @@ -368,6 +375,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js) self.assertIn('anchor.download = fileName || selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) + self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js) + self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js) + self.assertIn('showActionSummary("Duplicate", 0, 1, err.message);', app_js) self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) self.assertIn('deleteSelected();', app_js) @@ -395,7 +405,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertNotIn('if (event.altKey) {', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('function collectDeleteRecursivePaths(selectedItems)', app_js) - self.assertIn('openDeleteConfirmModal(pane, selectedItems, recursivePaths);', app_js) + self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('recursivePaths.has(item.path)', app_js) self.assertIn('Delete selected items and folder contents?', app_js) self.assertIn('async function loadSettings()', app_js) @@ -420,8 +430,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js) - self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', app_js) - self.assertIn('settings.downloadsTab.onclick = () => setSettingsTab("downloads");', app_js) + self.assertIn('settings.interfaceTab.onclick = () => {', app_js) + self.assertIn('setSettingsTab("interface");', app_js) + self.assertIn('settings.downloadsTab.onclick = () => {', app_js) + self.assertIn('setSettingsTab("downloads");', app_js) self.assertIn('"/api/settings"', app_js) self.assertIn('function uploadElements()', app_js) self.assertIn('function openUploadPicker()', app_js) @@ -436,7 +448,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('async function executeFolderUploadPlan(plan)', app_js) self.assertIn('async function handleFolderSelection(event)', app_js) self.assertIn('function deleteConfirmElements()', app_js) - self.assertIn('function openDeleteConfirmModal(pane, items, recursivePaths)', app_js) + self.assertIn('function openConfirmModal({ title, message, path, applyText = "Confirm" })', app_js) self.assertIn('async function executeDeleteItems(pane, items, recursivePaths)', app_js) self.assertIn('async function submitDeleteConfirmModal()', app_js) self.assertIn('recursive: recursivePaths.has(item.path)', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 6ba4795..e35f116 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -359,6 +359,7 @@ function contextMenuElements() { editButton: document.getElementById("context-menu-edit-btn"), downloadButton: document.getElementById("context-menu-download-btn"), renameButton: document.getElementById("context-menu-rename-btn"), + duplicateButton: document.getElementById("context-menu-duplicate-btn"), copyButton: document.getElementById("context-menu-copy-btn"), moveButton: document.getElementById("context-menu-move-btn"), deleteButton: document.getElementById("context-menu-delete-btn"), @@ -706,6 +707,8 @@ function openContextMenu(pane, entry, event) { elements.downloadButton.classList.remove("hidden"); elements.downloadButton.disabled = !downloadableSelection; elements.renameButton.classList.toggle("hidden", isMulti); + elements.duplicateButton.classList.remove("hidden"); + elements.duplicateButton.disabled = items.length === 0; elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = items.length === 0; elements.moveButton.classList.remove("hidden"); @@ -782,6 +785,36 @@ function startContextMenuCopy() { startCopySelected(); } +async function startDuplicateSelected() { + const sourcePane = state.activePane; + const selectedItems = [...paneState(sourcePane).selectedItems]; + if (selectedItems.length === 0) { + return; + } + setError("actions-error", ""); + try { + const result = await createDuplicateTask(selectedItems.map((item) => item.path)); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + showActionSummary("Duplicate", 1, 0, null); + } catch (err) { + showActionSummary("Duplicate", 0, 1, err.message); + } +} + +function startContextMenuDuplicate() { + if (contextMenuElements().duplicateButton?.disabled) { + return; + } + if (!applyContextMenuSelection()) { + closeContextMenu(); + return; + } + closeContextMenu(); + startDuplicateSelected(); +} + function startContextMenuOpen() { if (contextMenuElements().openButton?.disabled) { return; @@ -1165,6 +1198,10 @@ async function createArchiveDownloadTask(paths) { return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); } +async function createDuplicateTask(paths) { + return apiRequest("POST", "/api/files/duplicate", { paths }); +} + async function getTaskRequest(taskId) { return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); } @@ -4633,6 +4670,9 @@ function setupEvents() { if (contextMenu.downloadButton) { contextMenu.downloadButton.onclick = startContextMenuDownload; } + if (contextMenu.duplicateButton) { + contextMenu.duplicateButton.onclick = startContextMenuDuplicate; + } if (contextMenu.copyButton) { contextMenu.copyButton.onclick = startContextMenuCopy; } diff --git a/webui/html/index.html b/webui/html/index.html index c9c494e..1a7010c 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -144,6 +144,7 @@ +