diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index bf8324c..6953a9a 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index e9dce69..cdd28a3 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 49eab92..f40b28b 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -31,7 +31,7 @@ async def delete( request: DeleteRequest, service: FileOpsService = Depends(get_file_ops_service), ) -> DeleteResponse: - return service.delete(path=request.path) + return service.delete(path=request.path, recursive=request.recursive) @router.post("/upload", response_model=UploadResponse) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 52e1ad2..1a7b8a2 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -52,6 +52,7 @@ class RenameResponse(BaseModel): class DeleteRequest(BaseModel): path: str + recursive: bool = False class DeleteResponse(BaseModel): diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 3e9eebc..05cd24d 100644 Binary files a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc and b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc differ diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index 96809f4..ab69c90 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -104,6 +104,9 @@ class FilesystemAdapter: def delete_empty_directory(self, path: Path) -> None: path.rmdir() + def delete_directory_recursive(self, path: Path) -> None: + shutil.rmtree(path) + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: src = Path(source) dst = Path(destination) diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 3414f7f..4ff0c43 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 187de64..7aa8637 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -158,7 +158,7 @@ class FileOpsService: self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=error) raise error - def delete(self, path: str) -> DeleteResponse: + def delete(self, path: str, recursive: bool = False) -> DeleteResponse: try: resolved_target = self._path_guard.resolve_existing_path(path) @@ -166,13 +166,16 @@ class FileOpsService: self._filesystem.delete_file(resolved_target.absolute) elif resolved_target.absolute.is_dir(): if not self._filesystem.is_directory_empty(resolved_target.absolute): - raise AppError( - code="directory_not_empty", - message="Directory is not empty", - status_code=409, - details={"path": resolved_target.relative}, - ) - self._filesystem.delete_empty_directory(resolved_target.absolute) + if not recursive: + raise AppError( + code="directory_not_empty", + message="Directory is not empty", + status_code=409, + details={"path": resolved_target.relative}, + ) + self._filesystem.delete_directory_recursive(resolved_target.absolute) + else: + self._filesystem.delete_empty_directory(resolved_target.absolute) else: raise AppError( code="type_conflict", diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f8c1e17..5c03607 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_api_file_ops_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc index 1976108..2bbe150 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc 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 ca384f2..adc5126 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_api_file_ops_golden.py b/webui/backend/tests/golden/test_api_file_ops_golden.py index 6732a5d..f12247c 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -300,6 +300,22 @@ class FileOpsApiGoldenTest(unittest.TestCase): }, ) + def test_delete_non_empty_directory_recursive_success(self) -> None: + target = self.scope / "non_empty_recursive" + target.mkdir() + nested = target / "nested" + nested.mkdir() + (nested / "a.txt").write_text("a", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/non_empty_recursive", "recursive": True}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/non_empty_recursive"}) + self.assertFalse(target.exists()) + def test_delete_invalid_path(self) -> None: response = self._post( "/api/files/delete", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 68480d0..e8ac8de 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -65,6 +65,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="upload-modal-count"', body) self.assertIn('id="upload-modal-status"', body) self.assertIn('id="upload-modal-cancel-btn"', body) + self.assertIn('id="feedback-modal"', body) + self.assertIn('id="feedback-message"', body) + self.assertIn('id="feedback-close-btn"', body) self.assertIn('id="settings-btn"', body) self.assertIn('id="rename-btn"', body) self.assertIn('id="view-btn"', body) @@ -132,6 +135,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn(">Target path", body) 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-apply-btn"', body) + self.assertIn('id="delete-confirm-cancel-btn"', body) + self.assertIn("Delete folder and contents?", body) self.assertIn('id="mkdir-btn"', body) self.assertIn('id="copy-btn"', body) self.assertIn('id="move-btn"', body) @@ -189,12 +196,18 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('document.getElementById("upload-btn").onclick = openUploadPicker;', app_js) + self.assertIn('function feedbackElements()', app_js) + self.assertIn('function openFeedbackModal(message)', app_js) + self.assertIn('function closeFeedbackModal()', app_js) self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js) self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js) + self.assertIn('throw createApiError(response, data);', app_js) self.assertIn('function closeUploadMenu()', app_js) 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('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) @@ -225,6 +238,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('async function ensureFolderDirectoryExists(path)', app_js) 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('async function submitDeleteConfirmModal()', app_js) + self.assertIn('recursive: true', app_js) + self.assertIn('err.code === "directory_not_empty"', 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 1ff42c8..8007f56 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -47,6 +47,9 @@ let renameState = { source: null, name: "", }; +let deleteConfirmState = { + path: null, +}; let batchMoveState = { destinationBase: "", count: 0, @@ -164,6 +167,15 @@ function setStatus(msg) { } function setError(id, msg) { + if (id === "actions-error") { + document.getElementById(id).textContent = ""; + if (msg) { + openFeedbackModal(msg); + } else { + closeFeedbackModal(); + } + return; + } document.getElementById(id).textContent = msg || ""; } @@ -281,6 +293,24 @@ function batchMoveElements() { }; } +function deleteConfirmElements() { + return { + overlay: document.getElementById("delete-confirm-modal"), + path: document.getElementById("delete-confirm-path"), + error: document.getElementById("delete-confirm-error"), + applyButton: document.getElementById("delete-confirm-apply-btn"), + cancelButton: document.getElementById("delete-confirm-cancel-btn"), + }; +} + +function feedbackElements() { + return { + overlay: document.getElementById("feedback-modal"), + message: document.getElementById("feedback-message"), + closeButton: document.getElementById("feedback-close-btn"), + }; +} + function settingsElements() { return { overlay: document.getElementById("settings-modal"), @@ -347,6 +377,28 @@ function uploadModalElements() { }; } +function isFeedbackModalOpen() { + return !feedbackElements().overlay.classList.contains("hidden"); +} + +function openFeedbackModal(message) { + const elements = feedbackElements(); + if (!elements.overlay) { + return; + } + elements.message.textContent = message || ""; + elements.overlay.classList.remove("hidden"); +} + +function closeFeedbackModal() { + const elements = feedbackElements(); + if (!elements.overlay) { + return; + } + elements.message.textContent = ""; + elements.overlay.classList.add("hidden"); +} + function setUploadModalVisible(visible) { const elements = uploadModalElements(); if (!elements.overlay) { @@ -486,8 +538,7 @@ async function apiRequest(method, url, body) { const response = await fetch(url, options); const data = await response.json().catch(() => ({})); if (!response.ok) { - const error = data.error || {}; - throw new Error(error.message || `HTTP ${response.status}`); + throw createApiError(response, data); } return data; } @@ -1831,6 +1882,40 @@ async function renameSelected() { } } +function closeDeleteConfirmModal() { + const elements = deleteConfirmElements(); + deleteConfirmState.path = null; + elements.error.textContent = ""; + elements.overlay.classList.add("hidden"); +} + +function openDeleteConfirmModal(path) { + const elements = deleteConfirmElements(); + deleteConfirmState.path = path; + elements.path.textContent = path; + elements.error.textContent = ""; + 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]; @@ -1849,6 +1934,16 @@ async function deleteSelected() { await apiRequest("POST", "/api/files/delete", { path: 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; + } failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; @@ -2089,6 +2184,10 @@ function isBatchMovePopupOpen() { return !batchMoveElements().overlay.classList.contains("hidden"); } +function isDeleteConfirmModalOpen() { + return !deleteConfirmElements().overlay.classList.contains("hidden"); +} + function isUploadConflictModalOpen() { return isUploadConflictOpen(); } @@ -3150,6 +3249,13 @@ function handleKeyboardShortcuts(event) { closeUploadMenu(); return; } + if (isFeedbackModalOpen()) { + if (event.key === "Escape" || event.key === "Enter") { + event.preventDefault(); + closeFeedbackModal(); + } + return; + } if (isInfoOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -3204,6 +3310,19 @@ function handleKeyboardShortcuts(event) { } return; } + if (isDeleteConfirmModalOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeDeleteConfirmModal(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submitDeleteConfirmModal(); + return; + } + return; + } if (isUploadConflictModalOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -3393,6 +3512,17 @@ function setupEvents() { if (modalCancel) { modalCancel.onclick = requestUploadCancel; } + const feedback = feedbackElements(); + if (feedback.closeButton) { + feedback.closeButton.onclick = closeFeedbackModal; + } + if (feedback.overlay) { + feedback.overlay.onclick = (event) => { + if (event.target === feedback.overlay) { + closeFeedbackModal(); + } + }; + } document.addEventListener("click", (event) => { const elements = uploadElements(); if (!elements.menu || elements.menu.contains(event.target)) { @@ -3514,6 +3644,15 @@ function setupEvents() { } }; + const deleteConfirm = deleteConfirmElements(); + deleteConfirm.cancelButton.onclick = closeDeleteConfirmModal; + deleteConfirm.applyButton.onclick = submitDeleteConfirmModal; + deleteConfirm.overlay.onclick = (event) => { + if (event.target === deleteConfirm.overlay) { + closeDeleteConfirmModal(); + } + }; + const viewer = viewerElements(); viewer.closeButton.onclick = closeViewer; viewer.overlay.onclick = (event) => { diff --git a/webui/html/base.css b/webui/html/base.css index 2448b44..a0c0a37 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -656,6 +656,10 @@ button:disabled { box-shadow: var(--shadow-elevated); } +.feedback-card { + width: min(440px, calc(100vw - 24px)); +} + #upload-modal .popup-card { max-width: 320px; padding: 12px 14px; diff --git a/webui/html/index.html b/webui/html/index.html index ae00d78..9b09bca 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -108,6 +108,16 @@ + + + +