diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 0c5c015..98245e0 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -54,9 +54,12 @@ Success: Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir. ### `POST /api/files/delete` -Success: +Success (202): ```json -{ "path": "storage1/parent/file_or_empty_dir" } +{ + "task_id": "", + "status": "queued" +} ``` Non-empty directory: @@ -74,6 +77,7 @@ Non-empty directory: ### `POST /api/files/copy` ### `POST /api/files/move` +### `POST /api/files/delete` Success (202): ```json { diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 121f2a6..59f49de 100644 Binary files a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc and b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index bfa008a..74f3440 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 551a980..3e48736 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/routes_files.py b/webui/backend/app/api/routes_files.py index 8ad4fae..2ccbb8f 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask -from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse -from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service +from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse +from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.file_ops_service import FileOpsService router = APIRouter(prefix="/files") @@ -28,12 +29,12 @@ async def rename( return service.rename(path=request.path, new_name=request.new_name) -@router.post("/delete", response_model=DeleteResponse) +@router.post("/delete", response_model=TaskCreateResponse, status_code=202) async def delete( request: DeleteRequest, - service: FileOpsService = Depends(get_file_ops_service), -) -> DeleteResponse: - return service.delete(path=request.path, recursive=request.recursive) + service: DeleteTaskService = Depends(get_delete_task_service), +) -> TaskCreateResponse: + return service.create_delete_task(path=request.path, recursive=request.recursive) @router.post("/upload", response_model=UploadResponse) diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index e179f08..084f5b1 100644 Binary files a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index ae98cd9..5154a14 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from pathlib import Path VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} -VALID_OPERATIONS = {"copy", "move", "download", "duplicate"} +VALID_OPERATIONS = {"copy", "move", "download", "duplicate", "delete"} NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing") TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 061b3d6..4fb2bc0 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -14,6 +14,7 @@ from backend.app.services.bookmark_service import BookmarkService from backend.app.services.browse_service import BrowseService from backend.app.services.copy_task_service import CopyTaskService from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.duplicate_task_service import DuplicateTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService @@ -113,6 +114,15 @@ async def get_copy_task_service() -> CopyTaskService: ) +async def get_delete_task_service() -> DeleteTaskService: + return DeleteTaskService( + path_guard=get_path_guard(), + repository=get_task_repository(), + runner=get_task_runner(), + history_repository=get_history_repository(), + ) + + async def get_duplicate_task_service() -> DuplicateTaskService: return DuplicateTaskService( path_guard=get_path_guard(), 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 new file mode 100644 index 0000000..f585653 Binary files /dev/null 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 new file mode 100644 index 0000000..b80bc05 --- /dev/null +++ b/webui/backend/app/services/delete_task_service.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskCreateResponse +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.security.path_guard import PathGuard +from backend.app.tasks_runner import TaskRunner + + +class DeleteTaskService: + def __init__( + self, + path_guard: PathGuard, + repository: TaskRepository, + runner: TaskRunner, + history_repository: HistoryRepository | None = None, + ): + self._path_guard = path_guard + self._repository = repository + self._runner = runner + self._history_repository = history_repository + + def create_delete_task(self, path: str, recursive: bool = False) -> TaskCreateResponse: + try: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_file(): + kind = "file" + elif resolved_target.absolute.is_dir(): + kind = "directory" + if not recursive and any(resolved_target.absolute.iterdir()): + raise AppError( + code="directory_not_empty", + message="Directory is not empty", + status_code=409, + details={"path": resolved_target.relative}, + ) + else: + raise AppError( + code="type_conflict", + message="Unsupported path type for delete", + status_code=409, + details={"path": resolved_target.relative}, + ) + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="delete", + source=resolved_target.relative, + destination="", + task_id=task_id, + ) + self._record_history( + entry_id=task_id, + operation="delete", + status="queued", + path=resolved_target.relative, + ) + self._runner.enqueue_delete_path( + task_id=task["id"], + target=str(resolved_target.absolute), + kind=kind, + recursive=recursive, + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + except AppError as exc: + self._record_history( + operation="delete", + status="failed", + path=path, + 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=path, + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + raise error + + def _record_history(self, **kwargs) -> None: + if self._history_repository: + self._history_repository.create_entry(**kwargs) + + @staticmethod + def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index f124391..1ebe50d 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -79,6 +79,14 @@ class TaskRunner: ) thread.start() + def enqueue_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + thread = threading.Thread( + target=self._run_delete_path, + args=(task_id, target, kind, recursive), + daemon=True, + ) + thread.start() + def enqueue_archive_prepare(self, worker) -> None: thread = threading.Thread( target=worker, @@ -381,6 +389,41 @@ class TaskRunner: ) self._update_history_completed(task_id) + def _run_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=1, + current_item=target, + ) + + try: + path = Path(target) + if kind == "file": + self._filesystem.delete_file(path) + elif recursive: + self._filesystem.delete_directory_recursive(path) + else: + self._filesystem.delete_empty_directory(path) + self._repository.mark_completed( + task_id=task_id, + done_items=1, + total_items=1, + ) + self._update_history_completed(task_id) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=target, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=1, + ) + self._update_history_failed(task_id, str(exc)) + def _duplicate_directory(self, source: Path, destination: Path) -> None: destination.mkdir() copied_directories: list[tuple[Path, Path]] = [(source, destination)] diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 7ae249a..6a67fa5 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 2bbe150..4a42ec0 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 e7a76d1..93fdc0c 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_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc index b067121..c049be7 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_tasks_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 d6bcffc..1a52fb4 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 f12247c..26a75f9 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import sys import tempfile +import time import unittest from pathlib import Path @@ -10,11 +11,15 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) -from backend.app.dependencies import get_file_ops_service +from backend.app.dependencies import get_delete_task_service, get_file_ops_service, get_task_service +from backend.app.db.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.main import app from backend.app.security.path_guard import PathGuard +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.task_service import TaskService +from backend.app.tasks_runner import TaskRunner class FileOpsApiGoldenTest(unittest.TestCase): @@ -22,21 +27,37 @@ class FileOpsApiGoldenTest(unittest.TestCase): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) / "root" self.root.mkdir(parents=True, exist_ok=True) + self.repo = TaskRepository(str(Path(self.temp_dir.name) / "tasks.db")) self.scope = self.root / "scope" self.scope.mkdir(parents=True, exist_ok=True) (self.scope / "old.txt").write_text("x", encoding="utf-8") (self.scope / "existing.txt").write_text("y", encoding="utf-8") + path_guard = PathGuard({"storage1": str(self.root)}) service = FileOpsService( - path_guard=PathGuard({"storage1": str(self.root)}), + path_guard=path_guard, filesystem=FilesystemAdapter(), ) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=FilesystemAdapter()), + ) + 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 def tearDown(self) -> None: app.dependency_overrides.clear() @@ -50,6 +71,24 @@ class FileOpsApiGoldenTest(unittest.TestCase): return asyncio.run(_run()) + def _get(self, url: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get(url) + + return asyncio.run(_run()) + + def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._get(f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in {"completed", "failed"}: + return body + time.sleep(0.02) + self.fail("task did not reach terminal state in time") + def test_mkdir_success(self) -> None: response = self._post( "/api/files/mkdir", @@ -225,8 +264,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"path": "storage1/scope/delete_me.txt"}, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"path": "storage1/scope/delete_me.txt"}) + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_empty_directory_success(self) -> None: @@ -238,8 +281,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"path": "storage1/scope/empty_dir"}, ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"path": "storage1/scope/empty_dir"}) + self.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_not_found(self) -> None: @@ -312,8 +359,12 @@ class FileOpsApiGoldenTest(unittest.TestCase): {"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.assertEqual(response.status_code, 202) + body = response.json() + self.assertEqual(body["status"], "queued") + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) def test_delete_invalid_path(self) -> None: diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 0c28869..454b2e2 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -12,7 +12,7 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) -from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_duplicate_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service +from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_delete_task_service, get_duplicate_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service from backend.app.db.history_repository import HistoryRepository from backend.app.db.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter @@ -20,6 +20,7 @@ from backend.app.main import app from backend.app.security.path_guard import PathGuard from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.copy_task_service import CopyTaskService +from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.duplicate_task_service import DuplicateTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService @@ -78,6 +79,7 @@ class HistoryApiGoldenTest(unittest.TestCase): artifact_root=self.artifact_root, ) copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) + delete_service = DeleteTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) duplicate_service = DuplicateTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) task_service = TaskService(repository=self.task_repo) @@ -95,6 +97,9 @@ class HistoryApiGoldenTest(unittest.TestCase): async def _override_duplicate_service() -> DuplicateTaskService: return duplicate_service + async def _override_delete_service() -> DeleteTaskService: + return delete_service + async def _override_move_service() -> MoveTaskService: return move_service @@ -107,6 +112,7 @@ class HistoryApiGoldenTest(unittest.TestCase): app.dependency_overrides[get_file_ops_service] = _override_file_ops_service app.dependency_overrides[get_archive_download_task_service] = _override_archive_service app.dependency_overrides[get_copy_task_service] = _override_copy_service + app.dependency_overrides[get_delete_task_service] = _override_delete_service app.dependency_overrides[get_duplicate_task_service] = _override_duplicate_service app.dependency_overrides[get_move_task_service] = _override_move_service app.dependency_overrides[get_task_service] = _override_task_service @@ -229,6 +235,19 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['source'], 'storage1/report.txt') self.assertEqual(history[0]['destination'], 'storage1/report copy.txt') + def test_delete_completed_history_item(self) -> None: + (self.root1 / 'trash.txt').write_text('bye', encoding='utf-8') + + response = self._request('POST', '/api/files/delete', {'path': 'storage1/trash.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'], 'storage1/trash.txt') + 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_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index 180245a..6303bf1 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -241,6 +241,30 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["error_code"], "io_error") self.assertEqual(body["error_message"], "write failed") + def test_get_task_detail_delete_running(self) -> None: + self._insert_task( + task_id="task-delete", + operation="delete", + status="running", + source="storage1/trash.txt", + destination="", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + done_items=0, + total_items=1, + current_item="storage1/trash.txt", + ) + + response = self._get("/api/tasks/task-delete") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["operation"], "delete") + self.assertEqual(body["status"], "running") + self.assertEqual(body["done_items"], 0) + self.assertEqual(body["total_items"], 1) + self.assertEqual(body["current_item"], "storage1/trash.txt") + def test_get_task_detail_ready_archive_download(self) -> None: self._insert_task( task_id="task-download-ready", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 07f3d03..17ecc8d 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -335,7 +335,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); {functions} @@ -354,21 +354,21 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running should count as active"); + assert(activeTasks.length === 4, "Only task-based file actions in queued or running 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 not be counted because it is not task-based in the current UI flow"); - assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count"); + assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); + assert(activeTaskChipLabel(activeTasks.length) === "4 active tasks", "Chip label should reflect active task 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 === "3 active tasks", "Chip label should render active task count"); + assert(elements["header-task-chip-label"].textContent === "4 active tasks", "Chip label should render active task 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 === 3, "Popover should render only active file-action tasks"); + assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active file-action tasks"); updateHeaderTaskState([ {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, @@ -496,7 +496,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); {functions} @@ -780,9 +780,9 @@ 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_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) + self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js) - self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js) + self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", app_js) self.assertIn('function headerTaskElements()', app_js) self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js) @@ -884,6 +884,9 @@ 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('state.selectedTaskId = result.task_id;', app_js) + self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc index fa89add..46efd68 100644 Binary files a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc and b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/test_task_recovery_service.py b/webui/backend/tests/unit/test_task_recovery_service.py index d2e497a..e47b4b4 100644 --- a/webui/backend/tests/unit/test_task_recovery_service.py +++ b/webui/backend/tests/unit/test_task_recovery_service.py @@ -88,6 +88,27 @@ class TaskRecoveryServiceTest(unittest.TestCase): self.assertEqual(changed, []) self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed") + def test_reconcile_persisted_incomplete_tasks_marks_stale_delete_task_failed(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-delete", + "operation": "delete", + "status": "running", + "source": "storage1/trash.txt", + "destination": "", + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "current_item": "storage1/trash.txt", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, ["task-delete"]) + task = self.task_repo.get_task("task-delete") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "task_interrupted") + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index 30abb2a..a4b362d 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -120,9 +120,8 @@ let headerTaskState = { pollTimer: null, lastRenderKey: "", }; -// The header chip reflects only user-visible file actions that currently use the shared task system. -// Delete stays out of this set because it still runs as a direct request flow, not as a backend task. -const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); +// The header chip reflects only user-visible file actions that use the shared task system. +const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const VALID_THEME_FAMILIES = [ "default", @@ -2767,24 +2766,14 @@ async function executeDeleteItems(pane, items, recursivePaths) { let firstError = null; for (const item of items) { try { - await apiRequest("POST", "/api/files/delete", { + const 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) { - 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) { firstError = `${item.path}: ${err.message}`;