feat: voortgang delete in headerbar

This commit is contained in:
kodi
2026-03-15 11:52:39 +01:00
parent 73b09d2802
commit 7d910479f9
23 changed files with 311 additions and 43 deletions
+6 -2
View File
@@ -54,9 +54,12 @@ Success:
Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir. Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir.
### `POST /api/files/delete` ### `POST /api/files/delete`
Success: Success (202):
```json ```json
{ "path": "storage1/parent/file_or_empty_dir" } {
"task_id": "<uuid>",
"status": "queued"
}
``` ```
Non-empty directory: Non-empty directory:
@@ -74,6 +77,7 @@ Non-empty directory:
### `POST /api/files/copy` ### `POST /api/files/copy`
### `POST /api/files/move` ### `POST /api/files/move`
### `POST /api/files/delete`
Success (202): Success (202):
```json ```json
{ {
+7 -6
View File
@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask 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.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_file_ops_service 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.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.delete_task_service import DeleteTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
router = APIRouter(prefix="/files") router = APIRouter(prefix="/files")
@@ -28,12 +29,12 @@ async def rename(
return service.rename(path=request.path, new_name=request.new_name) 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( async def delete(
request: DeleteRequest, request: DeleteRequest,
service: FileOpsService = Depends(get_file_ops_service), service: DeleteTaskService = Depends(get_delete_task_service),
) -> DeleteResponse: ) -> TaskCreateResponse:
return service.delete(path=request.path, recursive=request.recursive) return service.create_delete_task(path=request.path, recursive=request.recursive)
@router.post("/upload", response_model=UploadResponse) @router.post("/upload", response_model=UploadResponse)
+1 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} 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") NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing")
TASK_MIGRATION_COLUMNS: dict[str, str] = { TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'", "operation": "TEXT NOT NULL DEFAULT 'copy'",
+10
View File
@@ -14,6 +14,7 @@ from backend.app.services.bookmark_service import BookmarkService
from backend.app.services.browse_service import BrowseService from backend.app.services.browse_service import BrowseService
from backend.app.services.copy_task_service import CopyTaskService from backend.app.services.copy_task_service import CopyTaskService
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService 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.duplicate_task_service import DuplicateTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.history_service import HistoryService 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: async def get_duplicate_task_service() -> DuplicateTaskService:
return DuplicateTaskService( return DuplicateTaskService(
path_guard=get_path_guard(), path_guard=get_path_guard(),
@@ -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")
+43
View File
@@ -79,6 +79,14 @@ class TaskRunner:
) )
thread.start() 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: def enqueue_archive_prepare(self, worker) -> None:
thread = threading.Thread( thread = threading.Thread(
target=worker, target=worker,
@@ -381,6 +389,41 @@ class TaskRunner:
) )
self._update_history_completed(task_id) 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: def _duplicate_directory(self, source: Path, destination: Path) -> None:
destination.mkdir() destination.mkdir()
copied_directories: list[tuple[Path, Path]] = [(source, destination)] copied_directories: list[tuple[Path, Path]] = [(source, destination)]
Binary file not shown.
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import tempfile import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -10,11 +11,15 @@ import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) 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.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app from backend.app.main import app
from backend.app.security.path_guard import PathGuard 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.file_ops_service import FileOpsService
from backend.app.services.task_service import TaskService
from backend.app.tasks_runner import TaskRunner
class FileOpsApiGoldenTest(unittest.TestCase): class FileOpsApiGoldenTest(unittest.TestCase):
@@ -22,21 +27,37 @@ class FileOpsApiGoldenTest(unittest.TestCase):
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "root" self.root = Path(self.temp_dir.name) / "root"
self.root.mkdir(parents=True, exist_ok=True) 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 = self.root / "scope"
self.scope.mkdir(parents=True, exist_ok=True) self.scope.mkdir(parents=True, exist_ok=True)
(self.scope / "old.txt").write_text("x", encoding="utf-8") (self.scope / "old.txt").write_text("x", encoding="utf-8")
(self.scope / "existing.txt").write_text("y", encoding="utf-8") (self.scope / "existing.txt").write_text("y", encoding="utf-8")
path_guard = PathGuard({"storage1": str(self.root)})
service = FileOpsService( service = FileOpsService(
path_guard=PathGuard({"storage1": str(self.root)}), path_guard=path_guard,
filesystem=FilesystemAdapter(), 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: async def _override_file_ops_service() -> FileOpsService:
return service 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_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: def tearDown(self) -> None:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -50,6 +71,24 @@ class FileOpsApiGoldenTest(unittest.TestCase):
return asyncio.run(_run()) 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: def test_mkdir_success(self) -> None:
response = self._post( response = self._post(
"/api/files/mkdir", "/api/files/mkdir",
@@ -225,8 +264,12 @@ class FileOpsApiGoldenTest(unittest.TestCase):
{"path": "storage1/scope/delete_me.txt"}, {"path": "storage1/scope/delete_me.txt"},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"path": "storage1/scope/delete_me.txt"}) 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()) self.assertFalse(target.exists())
def test_delete_empty_directory_success(self) -> None: def test_delete_empty_directory_success(self) -> None:
@@ -238,8 +281,12 @@ class FileOpsApiGoldenTest(unittest.TestCase):
{"path": "storage1/scope/empty_dir"}, {"path": "storage1/scope/empty_dir"},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"path": "storage1/scope/empty_dir"}) 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()) self.assertFalse(target.exists())
def test_delete_not_found(self) -> None: def test_delete_not_found(self) -> None:
@@ -312,8 +359,12 @@ class FileOpsApiGoldenTest(unittest.TestCase):
{"path": "storage1/scope/non_empty_recursive", "recursive": True}, {"path": "storage1/scope/non_empty_recursive", "recursive": True},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 202)
self.assertEqual(response.json(), {"path": "storage1/scope/non_empty_recursive"}) 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()) self.assertFalse(target.exists())
def test_delete_invalid_path(self) -> None: def test_delete_invalid_path(self) -> None:
@@ -12,7 +12,7 @@ import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) 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.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository from backend.app.db.task_repository import TaskRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter 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.security.path_guard import PathGuard
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.copy_task_service import CopyTaskService 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.duplicate_task_service import DuplicateTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.history_service import HistoryService from backend.app.services.history_service import HistoryService
@@ -78,6 +79,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
artifact_root=self.artifact_root, artifact_root=self.artifact_root,
) )
copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) 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) 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) 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) task_service = TaskService(repository=self.task_repo)
@@ -95,6 +97,9 @@ class HistoryApiGoldenTest(unittest.TestCase):
async def _override_duplicate_service() -> DuplicateTaskService: async def _override_duplicate_service() -> DuplicateTaskService:
return duplicate_service return duplicate_service
async def _override_delete_service() -> DeleteTaskService:
return delete_service
async def _override_move_service() -> MoveTaskService: async def _override_move_service() -> MoveTaskService:
return move_service 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_file_ops_service] = _override_file_ops_service
app.dependency_overrides[get_archive_download_task_service] = _override_archive_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_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_duplicate_task_service] = _override_duplicate_service
app.dependency_overrides[get_move_task_service] = _override_move_service app.dependency_overrides[get_move_task_service] = _override_move_service
app.dependency_overrides[get_task_service] = _override_task_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]['source'], 'storage1/report.txt')
self.assertEqual(history[0]['destination'], 'storage1/report copy.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: def test_single_file_download_writes_ready_history_item(self) -> None:
(self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8')
@@ -241,6 +241,30 @@ class TasksApiGoldenTest(unittest.TestCase):
self.assertEqual(body["error_code"], "io_error") self.assertEqual(body["error_code"], "io_error")
self.assertEqual(body["error_message"], "write failed") 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: def test_get_task_detail_ready_archive_download(self) -> None:
self._insert_task( self._insert_task(
task_id="task-download-ready", task_id="task-download-ready",
@@ -335,7 +335,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
pollTimer: null, pollTimer: null,
lastRenderKey: "", 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"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions} {functions}
@@ -354,21 +354,21 @@ class UiSmokeGoldenTest(unittest.TestCase):
]; ];
const activeTasks = activeTasksFromItems(mixedTasks); 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.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(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow");
assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count"); assert(activeTaskChipLabel(activeTasks.length) === "4 active tasks", "Chip label should reflect active task count");
updateHeaderTaskState(mixedTasks); updateHeaderTaskState(mixedTasks);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); 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"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
setHeaderTaskPopoverOpen(true); setHeaderTaskPopoverOpen(true);
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); 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-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-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([ updateHeaderTaskState([
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
@@ -496,7 +496,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
pollTimer: null, pollTimer: null,
lastRenderKey: "", 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"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions} {functions}
@@ -780,9 +780,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function inferDownloadTaskContext(task)', app_js)
self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js)
self.assertIn('let headerTaskState = {', 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('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 headerTaskElements()', app_js)
self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function isActiveTask(task)', app_js)
self.assertIn('function activeTasksFromItems(items)', 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 startContextMenuRename()', app_js)
self.assertIn('function startDuplicateSelected()', app_js) self.assertIn('function startDuplicateSelected()', app_js)
self.assertIn('async function deleteSelected()', 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 startContextMenuDuplicate()', app_js)
self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuCopy()', app_js)
self.assertIn('function startContextMenuMove()', app_js) self.assertIn('function startContextMenuMove()', app_js)
@@ -88,6 +88,27 @@ class TaskRecoveryServiceTest(unittest.TestCase):
self.assertEqual(changed, []) self.assertEqual(changed, [])
self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+5 -16
View File
@@ -120,9 +120,8 @@ let headerTaskState = {
pollTimer: null, pollTimer: null,
lastRenderKey: "", lastRenderKey: "",
}; };
// The header chip reflects only user-visible file actions that currently use the shared task system. // The header chip reflects only user-visible file actions that 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", "delete"]);
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
const VALID_THEME_FAMILIES = [ const VALID_THEME_FAMILIES = [
"default", "default",
@@ -2767,24 +2766,14 @@ async function executeDeleteItems(pane, items, recursivePaths) {
let firstError = null; let firstError = null;
for (const item of items) { for (const item of items) {
try { try {
await apiRequest("POST", "/api/files/delete", { const result = await apiRequest("POST", "/api/files/delete", {
path: item.path, path: item.path,
recursive: recursivePaths.has(item.path), recursive: recursivePaths.has(item.path),
}); });
state.selectedTaskId = result.task_id;
await refreshTasksSnapshot();
successes += 1; successes += 1;
} catch (err) { } 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; failures += 1;
if (!firstError) { if (!firstError) {
firstError = `${item.path}: ${err.message}`; firstError = `${item.path}: ${err.message}`;