diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 5c03607..8df5d3f 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 adc5126..d4d5be5 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 e8ac8de..51ba908 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -136,6 +136,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="batch-move-popup"', body) self.assertIn('id="batch-move-apply-btn"', body) self.assertIn('id="delete-confirm-modal"', body) + self.assertIn('id="delete-confirm-message"', body) self.assertIn('id="delete-confirm-apply-btn"', body) self.assertIn('id="delete-confirm-cancel-btn"', body) self.assertIn("Delete folder and contents?", body) @@ -206,8 +207,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function toggleUploadMenu()', app_js) self.assertNotIn('if (event.altKey) {', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) - self.assertIn('err.code === "directory_not_empty"', app_js) - self.assertIn('openDeleteConfirmModal(item.path);', app_js) + self.assertIn('function collectDeleteRecursivePaths(selectedItems)', app_js) + self.assertIn('openDeleteConfirmModal(pane, selectedItems, recursivePaths);', 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) self.assertIn('await loadSettings();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) @@ -239,10 +242,10 @@ 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(path)', app_js) + self.assertIn('function openDeleteConfirmModal(pane, items, recursivePaths)', app_js) + self.assertIn('async function executeDeleteItems(pane, items, recursivePaths)', app_js) self.assertIn('async function submitDeleteConfirmModal()', app_js) - self.assertIn('recursive: true', app_js) - self.assertIn('err.code === "directory_not_empty"', app_js) + self.assertIn('recursive: recursivePaths.has(item.path)', app_js) self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js) self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 8007f56..c6cb557 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -48,7 +48,9 @@ let renameState = { name: "", }; let deleteConfirmState = { - path: null, + pane: "left", + items: [], + recursivePaths: [], }; let batchMoveState = { destinationBase: "", @@ -296,6 +298,8 @@ function batchMoveElements() { function deleteConfirmElements() { return { overlay: document.getElementById("delete-confirm-modal"), + title: document.getElementById("delete-confirm-title"), + message: document.getElementById("delete-confirm-message"), path: document.getElementById("delete-confirm-path"), error: document.getElementById("delete-confirm-error"), applyButton: document.getElementById("delete-confirm-apply-btn"), @@ -1884,65 +1888,54 @@ async function renameSelected() { function closeDeleteConfirmModal() { const elements = deleteConfirmElements(); - deleteConfirmState.path = null; + deleteConfirmState.pane = "left"; + deleteConfirmState.items = []; + deleteConfirmState.recursivePaths = []; elements.error.textContent = ""; elements.overlay.classList.add("hidden"); } -function openDeleteConfirmModal(path) { +function openDeleteConfirmModal(pane, items, recursivePaths) { const elements = deleteConfirmElements(); - deleteConfirmState.path = path; - elements.path.textContent = path; + deleteConfirmState.pane = pane; + deleteConfirmState.items = items.map((item) => ({ ...item })); + deleteConfirmState.recursivePaths = Array.from(recursivePaths); elements.error.textContent = ""; + if (items.length === 1) { + elements.title.textContent = "Delete folder and contents?"; + elements.message.textContent = "This will permanently delete the folder and all files and subfolders inside it."; + elements.path.textContent = items[0].path; + } else { + elements.title.textContent = "Delete selected items and folder contents?"; + elements.message.textContent = `This will permanently delete ${items.length} selected items, including all files and subfolders inside the selected folders.`; + elements.path.textContent = `${items.length} selected items`; + } elements.overlay.classList.remove("hidden"); } -async function submitDeleteConfirmModal() { - const path = deleteConfirmState.path; - if (!path) { - return; - } - const elements = deleteConfirmElements(); - elements.error.textContent = ""; - try { - await apiRequest("POST", "/api/files/delete", { path, recursive: true }); - closeDeleteConfirmModal(); - setSelectedItem(state.activePane, null); - await loadBrowsePane(state.activePane); - setStatus("Delete: 1 success, 0 failed"); - setError("actions-error", ""); - } catch (err) { - elements.error.textContent = err.message; - } -} - -async function deleteSelected() { - const pane = state.activePane; - const selectedItems = [...paneState(pane).selectedItems]; - if (selectedItems.length === 0) { - return; - } - if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) { - return; - } - setError("actions-error", ""); +async function executeDeleteItems(pane, items, recursivePaths) { let successes = 0; let failures = 0; let firstError = null; - for (const item of selectedItems) { + for (const item of items) { try { - await apiRequest("POST", "/api/files/delete", { path: item.path }); + await apiRequest("POST", "/api/files/delete", { + path: item.path, + recursive: recursivePaths.has(item.path), + }); successes += 1; } catch (err) { - if ( - err.code === "directory_not_empty" - && selectedItems.length === 1 - && item.kind === "directory" - ) { - failures = 0; - firstError = null; - openDeleteConfirmModal(item.path); - return; + if (err.code === "directory_not_empty" && recursivePaths.has(item.path)) { + try { + await apiRequest("POST", "/api/files/delete", { + path: item.path, + recursive: true, + }); + successes += 1; + continue; + } catch (retryErr) { + err = retryErr; + } } failures += 1; if (!firstError) { @@ -1955,6 +1948,63 @@ async function deleteSelected() { showActionSummary("Delete", successes, failures, firstError); } +async function submitDeleteConfirmModal() { + const elements = deleteConfirmElements(); + if (!deleteConfirmState.items.length) { + return; + } + elements.error.textContent = ""; + try { + const pane = deleteConfirmState.pane; + const items = [...deleteConfirmState.items]; + const recursivePaths = new Set(deleteConfirmState.recursivePaths); + closeDeleteConfirmModal(); + await executeDeleteItems(pane, items, recursivePaths); + } catch (err) { + elements.error.textContent = err.message; + } +} + +async function collectDeleteRecursivePaths(selectedItems) { + const recursivePaths = new Set(); + for (const item of selectedItems) { + if (item.kind !== "directory") { + continue; + } + const query = new URLSearchParams({ + path: item.path, + show_hidden: "true", + }); + const data = await apiRequest("GET", `/api/browse?${query.toString()}`); + if ((data.directories && data.directories.length > 0) || (data.files && data.files.length > 0)) { + recursivePaths.add(item.path); + } + } + return recursivePaths; +} + +async function deleteSelected() { + const pane = state.activePane; + const selectedItems = [...paneState(pane).selectedItems]; + if (selectedItems.length === 0) { + return; + } + setError("actions-error", ""); + try { + const recursivePaths = await collectDeleteRecursivePaths(selectedItems); + if (recursivePaths.size > 0) { + openDeleteConfirmModal(pane, selectedItems, recursivePaths); + return; + } + if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) { + return; + } + await executeDeleteItems(pane, selectedItems, new Set()); + } catch (err) { + setActionError("Delete", err); + } +} + function defaultDestination(sourcePath, targetBasePath) { const sourceName = baseName(sourcePath); return `${targetBasePath}/${sourceName}`; diff --git a/webui/html/base.css b/webui/html/base.css index a0c0a37..cfee420 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -565,7 +565,8 @@ button:disabled { justify-content: center; gap: 5px; width: 100%; - max-width: 760px; + max-width: none; + flex-wrap: nowrap; } #function-bar button { diff --git a/webui/html/index.html b/webui/html/index.html index 9b09bca..ee0cd17 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -270,7 +270,7 @@