diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 817945b..10bd06e 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -90,6 +90,8 @@ Notes: - Batch move is supported as one task-based operation via `{ "sources": [...], "destination_base": "..." }`. - Cross-root batch move is supported for file-only selections. - Cross-root batch move with any directory in the selection remains unsupported in v1. +- Batch delete is supported as one task-based operation via `{ "paths": [...], "recursive_paths": [...] }`. +- Single delete remains supported via `{ "path": "...", "recursive": true|false }`. ## Tasks read endpoints diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 1b28586..f32105d 100644 Binary files a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc and b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc differ 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 3e48736..1ae7df5 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 f326901..ff6d8d5 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 2ccbb8f..23252d7 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -34,6 +34,8 @@ async def delete( request: DeleteRequest, service: DeleteTaskService = Depends(get_delete_task_service), ) -> TaskCreateResponse: + if request.paths is not None: + return service.create_batch_delete_task(paths=request.paths, recursive_paths=request.recursive_paths or []) return service.create_delete_task(path=request.path, recursive=request.recursive) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index afb46c4..e350fc8 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -51,8 +51,10 @@ class RenameResponse(BaseModel): class DeleteRequest(BaseModel): - path: str + path: str | None = None recursive: bool = False + paths: list[str] | None = None + recursive_paths: list[str] | None = None class DeleteResponse(BaseModel): diff --git a/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc index c85e51e..e5fd947 100644 Binary files a/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/delete_task_service.py b/webui/backend/app/services/delete_task_service.py index 9dc3308..f152e8b 100644 --- a/webui/backend/app/services/delete_task_service.py +++ b/webui/backend/app/services/delete_task_service.py @@ -25,7 +25,13 @@ class DeleteTaskService: self._runner = runner self._history_repository = history_repository - def create_delete_task(self, path: str, recursive: bool = False) -> TaskCreateResponse: + def create_delete_task(self, path: str | None, recursive: bool = False) -> TaskCreateResponse: + if not path: + raise AppError( + code="invalid_request", + message="Query parameter 'path' is required", + status_code=400, + ) try: item = self._build_delete_item(path=path, recursive=recursive) @@ -71,11 +77,82 @@ class DeleteTaskService: ) raise error - def _build_delete_item(self, path: str, recursive: bool) -> dict: + def create_batch_delete_task(self, paths: list[str] | None, recursive_paths: list[str] | None = None) -> TaskCreateResponse: + if not paths or len(paths) < 2: + raise AppError( + code="invalid_request", + message="Batch delete requires at least 2 paths", + status_code=400, + ) + + recursive_paths_set = set(recursive_paths or []) + invalid_recursive = sorted(path for path in recursive_paths_set if path not in paths) + if invalid_recursive: + raise AppError( + code="invalid_request", + message="Recursive delete paths must be included in the batch selection", + status_code=400, + details={"path": invalid_recursive[0]}, + ) + + try: + items = [ + self._build_delete_item( + path=path, + recursive=path in recursive_paths_set, + include_root_prefix=True, + ) + for path in paths + ] + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="delete", + source=f"{len(items)} items", + destination="", + task_id=task_id, + ) + self._record_history( + entry_id=task_id, + operation="delete", + status="queued", + path=f"{len(items)} items", + ) + self._runner.enqueue_delete_batch(task_id=task["id"], items=items) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + except AppError as exc: + self._record_history( + operation="delete", + status="failed", + path=f"{len(paths or [])} items", + error_code=exc.code, + error_message=exc.message, + finished_at=self._now_iso(), + ) + raise + except OSError as exc: + error = AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + self._record_history( + operation="delete", + status="failed", + path=f"{len(paths or [])} items", + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + raise error + + def _build_delete_item(self, path: str, recursive: bool, include_root_prefix: bool = False) -> dict: resolved_target = self._path_guard.resolve_existing_path(path) if resolved_target.absolute.is_file(): - files = [{"path": str(resolved_target.absolute), "label": resolved_target.absolute.name}] + label = resolved_target.absolute.name + files = [{"path": str(resolved_target.absolute), "label": label}] directories: list[str] = [] kind = "file" elif resolved_target.absolute.is_dir(): @@ -88,7 +165,10 @@ class DeleteTaskService: details={"path": resolved_target.relative}, ) if recursive: - files, directories = self._build_recursive_delete_plan(resolved_target.absolute) + files, directories = self._build_recursive_delete_plan( + resolved_target.absolute, + include_root_prefix=include_root_prefix, + ) else: files = [] directories = [str(resolved_target.absolute)] @@ -111,9 +191,10 @@ class DeleteTaskService: "progress_label": files[0]["label"] if files else None, } - def _build_recursive_delete_plan(self, root: Path) -> tuple[list[dict[str, str]], list[str]]: + def _build_recursive_delete_plan(self, root: Path, include_root_prefix: bool = False) -> tuple[list[dict[str, str]], list[str]]: files: list[dict[str, str]] = [] directories: list[str] = [] + start_prefix = Path(root.name) if include_root_prefix else Path() def walk(path: Path, relative_prefix: Path) -> None: for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()): @@ -127,7 +208,7 @@ class DeleteTaskService: continue files.append({"path": str(entry), "label": relative_path.as_posix()}) - walk(root, Path()) + walk(root, start_prefix) directories.append(str(root)) return files, directories diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index a5f7f86..c7ea4f4 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -89,6 +89,14 @@ class TaskRunner: ) thread.start() + def enqueue_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None: + thread = threading.Thread( + target=self._run_delete_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def enqueue_archive_prepare(self, worker) -> None: thread = threading.Thread( target=worker, @@ -490,6 +498,66 @@ class TaskRunner: ) self._update_history_failed(task_id, str(exc)) + def _run_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None: + total_items = self._total_delete_work_count(items) + current_item = self._first_delete_item_label(items) + if not self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=current_item, + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return + + completed_items = 0 + current_target = str(items[0]["target"]) if items else "" + try: + for item in items: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + target = str(item["target"]) + current_target = target + kind = str(item["kind"]) + recursive = bool(item["recursive"]) + files = list(item.get("files", [])) # type: ignore[arg-type] + directories = list(item.get("directories", [])) # type: ignore[arg-type] + if kind == "file": + completed_items = self._delete_planned_file(task_id, files[0], completed_items, total_items) + elif recursive: + for file_entry in files: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + completed_items = self._delete_planned_file(task_id, file_entry, completed_items, total_items) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + for directory in directories: + self._filesystem.delete_empty_directory(Path(directory)) + else: + self._filesystem.delete_empty_directory(Path(target)) + self._complete_or_cancel_item_task( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + ) + except OSError as exc: + task = self._repository.get_task(task_id) + failed_item = (task.get("current_item") if task else None) or current_target + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=failed_item, + done_bytes=None, + total_bytes=None, + done_items=completed_items, + total_items=total_items, + ) + self._update_history_failed(task_id, str(exc)) + def _cleanup_partial_duplicate(self, path: Path) -> None: if not path.exists(): return @@ -556,6 +624,26 @@ class TaskRunner: return files[0]["label"] return None + def _total_delete_work_count(self, items: list[dict[str, object]]) -> int: + total = 0 + for item in items: + progress_total_items = item.get("progress_total_items") + if progress_total_items is not None: + total += int(progress_total_items) + continue + total += len(self._file_entries(item)) + return total + + def _first_delete_item_label(self, items: list[dict[str, object]]) -> str | None: + for item in items: + progress_label = item.get("progress_label") + if isinstance(progress_label, str) and progress_label: + return progress_label + files = self._file_entries(item) + if files: + return files[0]["label"] + return None + def _copy_single_planned_file( self, task_id: str, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 8e6eeee..e74a73f 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 9ad4f63..07a857d 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_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index ce1e2f7..2559aa4 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_history_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 159d5dc..6f0e9fa 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 fb45249..41f2821 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -504,6 +504,108 @@ class FileOpsApiGoldenTest(unittest.TestCase): self.assertTrue(target.joinpath("nested", "b.txt").exists()) self.assertTrue(target.exists()) + def test_delete_batch_multi_select_starts_one_task_and_completes(self) -> None: + first = self.scope / "batch-a.txt" + second_dir = self.scope / "batch-dir" + second_nested = second_dir / "nested.txt" + first.write_text("a", encoding="utf-8") + second_dir.mkdir() + second_nested.write_text("b", encoding="utf-8") + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/batch-a.txt", "storage1/scope/batch-dir"], + "recursive_paths": ["storage1/scope/batch-dir"], + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["source"], "2 items") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertFalse(first.exists()) + self.assertFalse(second_dir.exists()) + + def test_delete_batch_cancelled_after_current_delete_finishes(self) -> None: + blocking_fs = BlockingDeleteFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=blocking_fs), + ) + task_service = TaskService(repository=self.repo) + + async def _override_file_ops_service() -> FileOpsService: + return service + + async def _override_delete_task_service() -> DeleteTaskService: + return delete_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_delete_task_service] = _override_delete_task_service + app.dependency_overrides[get_task_service] = _override_task_service + + first = self.scope / "cancel-a.txt" + second = self.scope / "cancel-b.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/cancel-a.txt", "storage1/scope/cancel-b.txt"], + }, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["done_items"], 0) + self.assertEqual(running["total_items"], 2) + + cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {}) + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertFalse(first.exists()) + self.assertTrue(second.exists()) + + def test_delete_batch_directory_only_empty_dirs_remains_honestly_coarse(self) -> None: + first = self.scope / "empty-a" + second = self.scope / "empty-b" + first.mkdir() + second.mkdir() + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/empty-a", "storage1/scope/empty-b"], + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 0) + self.assertEqual(detail["total_items"], 0) + self.assertIsNone(detail["current_item"]) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + def test_delete_invalid_path(self) -> None: response = self._post( "/api/files/delete", diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 1670cee..7c8a553 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -299,6 +299,20 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['status'], 'completed') self.assertEqual(history[0]['path'], 'storage1/trash.txt') + def test_delete_batch_completed_history_item(self) -> None: + (self.root1 / 'trash-a.txt').write_text('a', encoding='utf-8') + (self.root1 / 'trash-b.txt').write_text('b', encoding='utf-8') + + response = self._request('POST', '/api/files/delete', {'paths': ['storage1/trash-a.txt', 'storage1/trash-b.txt']}) + + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) + + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'delete') + self.assertEqual(history[0]['status'], 'completed') + self.assertEqual(history[0]['path'], '2 items') + def test_single_file_download_writes_ready_history_item(self) -> None: (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index c7b24df..29c703d 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -379,7 +379,7 @@ class UiSmokeGoldenTest(unittest.TestCase): {{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }}, {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, - {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, + {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "", done_items: 2, total_items: 5, current_item: "folder/delete-me.txt" }}, {{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop", done_items: 1, total_items: 4, current_item: "nested/final-file.txt" }}, {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, @@ -388,28 +388,33 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 4, "Only active user-visible operations should count as active"); + assert(activeTasks.length === 5, "Only active user-visible operations should count as active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); - assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should stay out of operation UI until it maps cleanly to one user-visible operation"); + assert(activeTasks.some((task) => task.operation === "delete"), "Delete should be included once it maps cleanly to one user-visible operation"); assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); - assert(activeTaskChipLabel(activeTasks) === "4 active operations", "Chip label should reflect active operation count"); + assert(activeTaskChipLabel(activeTasks) === "5 active operations", "Chip label should reflect active operation count"); updateHeaderTaskState(mixedTasks); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); - assert(elements["header-task-chip-label"].textContent === "4 active operations", "Chip label should render active operation count"); + assert(elements["header-task-chip-label"].textContent === "5 active operations", "Chip label should render active operation count"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); setHeaderTaskPopoverOpen(true); assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); - assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active operations"); + assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active operations"); const moveRow = elements["header-task-popover-list"].children[1]; const moveProgress = moveRow.children[3]; const moveCurrent = moveRow.children[4]; assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available"); assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item"); - const cancellingRow = elements["header-task-popover-list"].children[3]; + const deleteRow = elements["header-task-popover-list"].children[3]; + const deleteProgress = deleteRow.children[3]; + const deleteCurrent = deleteRow.children[4]; + assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available"); + assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item"); + const cancellingRow = elements["header-task-popover-list"].children[4]; const cancellingProgress = cancellingRow.children[3]; const cancellingCurrent = cancellingRow.children[4]; const cancellingSubtext = cancellingRow.children[5]; @@ -417,7 +422,7 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item"); assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics"); const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; - const cancellingActionButton = elements["header-task-popover-list"].children[3].children[6].children[0]; + const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0]; assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); @@ -433,6 +438,11 @@ class UiSmokeGoldenTest(unittest.TestCase): ]); assert(elements["header-task-chip-label"].textContent === "Duplicate 3/12", "Single duplicate task should show compact item progress in chip"); + updateHeaderTaskState([ + {{ id: "single-del", operation: "delete", status: "running", source: "/src/a", destination: "", done_items: 2, total_items: 5, current_item: "nested/file.txt" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Delete 2/5", "Single delete task should show compact item progress in chip"); + updateHeaderTaskState([ {{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }}, ]); @@ -582,7 +592,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -617,6 +627,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "paneState"), self._extract_js_function(app_js, "otherPane"), self._extract_js_function(app_js, "defaultDestination"), + self._extract_async_js_function(app_js, "executeDeleteItems"), self._extract_async_js_function(app_js, "startCopySelected"), self._extract_async_js_function(app_js, "executeMoveSelection"), ] @@ -693,6 +704,30 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(refreshCalls.length === 1, "Move should refresh task snapshot once"); assert(statusMessages.includes("Move: operation started"), "Move should report operation start"); assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Move batch should clear source selection once"); + + apiCalls.length = 0; + refreshCalls.length = 0; + statusMessages.length = 0; + loadCalls.length = 0; + clearedSelection.length = 0; + state.selectedTaskId = null; + + await executeDeleteItems("left", [ + {{ path: "storage1/source/a.txt", kind: "file", name: "a.txt" }}, + {{ path: "storage1/source/folder", kind: "directory", name: "folder" }}, + ], new Set(["storage1/source/folder"])); + assert(apiCalls.length === 1, "Multi-select delete should issue one request"); + assert(apiCalls[0].url === "/api/files/delete", "Delete should use delete endpoint"); + assert(Array.isArray(apiCalls[0].body.paths), "Delete should send batch paths"); + assert(apiCalls[0].body.paths.length === 2, "Delete batch should include all selected items"); + assert(Array.isArray(apiCalls[0].body.recursive_paths), "Delete should send recursive path list"); + assert(apiCalls[0].body.recursive_paths.length === 1, "Delete batch should include recursive selection paths"); + assert(apiCalls[0].body.recursive_paths[0] === "storage1/source/folder", "Delete batch should preserve recursive path selection"); + assert(state.selectedTaskId === "task-123", "Delete should store the created task id"); + assert(refreshCalls.length === 1, "Delete should refresh task snapshot once"); + assert(statusMessages.includes("Delete: operation started"), "Delete should report operation start"); + assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Delete batch should clear source selection once"); + assert(loadCalls.length === 1 && loadCalls[0] === "left", "Delete batch should reload the source pane once"); assert(errorCalls.length === 0, "Batch operation start should not emit action errors"); }})().catch((error) => {{ console.error(error); @@ -965,7 +1000,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('let headerTaskState = {', app_js) - self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) + self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js) self.assertIn("The header chip/popover reflects user-visible file operations, not every task-backed file action.", app_js) self.assertIn('function headerTaskElements()', app_js) @@ -988,7 +1023,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function updateHeaderTaskState(taskItems)', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js) self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js) - self.assertIn('return task.operation === "copy" || task.operation === "duplicate";', app_js) + self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js) self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js) self.assertIn('return `${action} running`;', app_js) self.assertIn('return "Stopping after current item...";', app_js) @@ -1081,7 +1116,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startDuplicateSelected()', app_js) self.assertIn('async function deleteSelected()', app_js) - self.assertIn('const result = await apiRequest("POST", "/api/files/delete", {', app_js) + self.assertIn('result = await apiRequest("POST", "/api/files/delete", {', app_js) + self.assertIn('paths: items.map((item) => item.path),', app_js) + self.assertIn('recursive_paths: Array.from(recursivePaths),', app_js) + self.assertIn('setStatus("Delete: operation started");', app_js) self.assertIn('state.selectedTaskId = result.task_id;', app_js) self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 0a36ff3..76f803e 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -121,7 +121,7 @@ let headerTaskState = { lastRenderKey: "", }; // The header chip/popover reflects user-visible file operations, not every task-backed file action. -const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); +const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); const VALID_THEME_FAMILIES = [ "default", @@ -2761,28 +2761,30 @@ function openConfirmModal({ title, message, path, applyText = "Confirm" }) { } async function executeDeleteItems(pane, items, recursivePaths) { - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of items) { - try { - const result = await apiRequest("POST", "/api/files/delete", { + try { + let result; + if (items.length > 1) { + result = await apiRequest("POST", "/api/files/delete", { + paths: items.map((item) => item.path), + recursive_paths: Array.from(recursivePaths), + }); + setStatus("Delete: operation started"); + } else { + const item = items[0]; + result = await apiRequest("POST", "/api/files/delete", { path: item.path, recursive: recursivePaths.has(item.path), }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } + setStatus("Delete: started"); } + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + } catch (err) { + setActionError("Delete", err); + return; } setSelectedItem(pane, null); await loadBrowsePane(pane); - showActionSummary("Delete", successes, failures, firstError); } async function submitDeleteConfirmModal() { @@ -3901,7 +3903,7 @@ function canShowChipItemProgress(task) { if (!hasMeaningfulItemProgress(task)) { return false; } - return task.operation === "copy" || task.operation === "duplicate"; + return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete"; } function compactTaskCurrentItem(task) {