From 3987de27e052409a14540ea87e11c4dc03f3113b Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 08:36:47 +0100 Subject: [PATCH] feat: delete multiple non empty folders --- webui/backend/data/tasks.db | Bin 163840 -> 172032 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 29039 -> 29454 bytes .../tests/golden/test_ui_smoke_golden.py | 13 +- webui/html/app.js | 140 ++++++++++++------ webui/html/base.css | 3 +- webui/html/index.html | 2 +- 6 files changed, 106 insertions(+), 52 deletions(-) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 5c03607846c2d6d34c2bab9a0c34e457a5a20887..8df5d3f74eef95ff4896577f70b61096108a7c6f 100644 GIT binary patch delta 5359 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 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 adc5126fd2e7d0f2a767a33d4396919850328869..d4d5be5f157b480f75e8f9eb84f17e64004023fb 100644 GIT binary patch delta 962 zcmZ{hO-vI(6vubkZCRkDt(H63?GV`;DN-0QHYHaCB$Gf@nj<=h6}pgA_*8LnfbkseQ*A6_WLRr z_zF_iwAv(0TC2nqH^_ujHk68v-Oy5yi%M{(0S=|+!X9b!m3r8&GQuyZtui+x|GB{k zyG;z7HKf6TDlJ}w(Y^4ssSjL6Eoq;L%uLQxneAcytjOBj0p806J8hk;Ah>)ia**>1 zg_b}Y*%39I-=~8S>s}P4j3CRA;Vr`5Af{<^sU#{f46N)R# zv0@`!DUFkn{8+wclS6SL{=ldLRXLWGa@Jnn8mmHg%7@i(*=_>2;TwAnxFer;k>glD zK=$fpAb)(TCLiE%(QZ-RXK;A0*rI$OG3F05Dw!jj9A(t-LTwI6kfUP^A@dQFD07@i zk{M=_Wlk`f7y*L6Yi+7YdWxQg#m+p*nvty2&b-7)`Y}CSrr`B((V3=9p`XBU1Bo*_ zdNyY4V)XRWgNpbok(dvoo}qPTKYpmx+s(U0F2IxSfZxx$#cKH{G_mgPpuk;ZPr1Yn z!74~Wx5#?xM7C3)LKSeXF};^(3(0MXLNd6mj4BUhZI9SHT!NL8n%KLfzEo&`{I;z& zWGp9;F5W|W1AY%1lvME|%ZtL1P?k&R;oanxUKnk|(Wevda8Lnf8nfGCa^&)VWQ3}A zl(Ls3ogy2*T%O6=<@#?u{McxttXqQ|-ZABlnUJ~ZhF(1)y|fTUBWFJ-O!Y5YB1H2N zp}DEOu3a!SFSMMA5bZF^nc>6pI=Ig1&;YXh@T9|8pH6*rEJY&ph?p5 E2T+w$KmY&$ delta 694 zcmeBs#`yjbBj0CUUM>b82x8fq86C2b?;kUhi}2(^ZI#VNEbfAn=ZdOKt_0#)wgQu5 zSw$xAgVLO0CX@FHicHqB69ChGP9kfIkWmTopWLc6KL3Tp{E0AVpU|{@wMP_rMb|S~*e}*EHUvdk8XbSRWx38m*k=*{~~)-X+u<$@^6fg*vLX~huMB0IZ0M%U z$PN}=9m)Zw*MxF{>9wI;lMe!Axxu{kp*$d(al_>4?z*fSgEs|lo_x|>m2+e8mf+2n zOnLm1&$=rD#kT^*Wj$mWw@uz4sK64+AG{sJ5DFCt-eJlF2{#3XbOz0xnJ{Kg;omrBat5=X%lA&q6 z`KIq{7Dm;{M}w4@N-`!p1nUa$1ND^_6=#;ES}Bwim8Nc<7ThbqwhCxr@r2EqDYuwJ zt#-Iw;c&je!PC#%$$LQz!kyfoEwR}@{S4#et!ey|BQyCYduH&nLF6_EWLk1?1TnFi zf6riX-aN6;o6#tamC==P2FG;?&5IJ6Ul_pL4^G?+EIb`mmzgDRu<*3|Hu<*te_&={ Kktz}cS`PpbaMwTp 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 @@