feat: feedback verbetering - 06

This commit is contained in:
kodi
2026-03-15 15:51:13 +01:00
parent ae6a9d8c45
commit 9537a29de3
17 changed files with 368 additions and 37 deletions
+2
View File
@@ -90,6 +90,8 @@ Notes:
- Batch move is supported as one task-based operation via `{ "sources": [...], "destination_base": "..." }`. - 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 is supported for file-only selections.
- Cross-root batch move with any directory in the selection remains unsupported in v1. - 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 ## Tasks read endpoints
+2
View File
@@ -34,6 +34,8 @@ async def delete(
request: DeleteRequest, request: DeleteRequest,
service: DeleteTaskService = Depends(get_delete_task_service), service: DeleteTaskService = Depends(get_delete_task_service),
) -> TaskCreateResponse: ) -> 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) return service.create_delete_task(path=request.path, recursive=request.recursive)
+3 -1
View File
@@ -51,8 +51,10 @@ class RenameResponse(BaseModel):
class DeleteRequest(BaseModel): class DeleteRequest(BaseModel):
path: str path: str | None = None
recursive: bool = False recursive: bool = False
paths: list[str] | None = None
recursive_paths: list[str] | None = None
class DeleteResponse(BaseModel): class DeleteResponse(BaseModel):
@@ -25,7 +25,13 @@ class DeleteTaskService:
self._runner = runner self._runner = runner
self._history_repository = history_repository 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: try:
item = self._build_delete_item(path=path, recursive=recursive) item = self._build_delete_item(path=path, recursive=recursive)
@@ -71,11 +77,82 @@ class DeleteTaskService:
) )
raise error 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) resolved_target = self._path_guard.resolve_existing_path(path)
if resolved_target.absolute.is_file(): 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] = [] directories: list[str] = []
kind = "file" kind = "file"
elif resolved_target.absolute.is_dir(): elif resolved_target.absolute.is_dir():
@@ -88,7 +165,10 @@ class DeleteTaskService:
details={"path": resolved_target.relative}, details={"path": resolved_target.relative},
) )
if recursive: 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: else:
files = [] files = []
directories = [str(resolved_target.absolute)] directories = [str(resolved_target.absolute)]
@@ -111,9 +191,10 @@ class DeleteTaskService:
"progress_label": files[0]["label"] if files else None, "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]] = [] files: list[dict[str, str]] = []
directories: list[str] = [] directories: list[str] = []
start_prefix = Path(root.name) if include_root_prefix else Path()
def walk(path: Path, relative_prefix: Path) -> None: def walk(path: Path, relative_prefix: Path) -> None:
for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()): for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()):
@@ -127,7 +208,7 @@ class DeleteTaskService:
continue continue
files.append({"path": str(entry), "label": relative_path.as_posix()}) files.append({"path": str(entry), "label": relative_path.as_posix()})
walk(root, Path()) walk(root, start_prefix)
directories.append(str(root)) directories.append(str(root))
return files, directories return files, directories
+88
View File
@@ -89,6 +89,14 @@ class TaskRunner:
) )
thread.start() 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: def enqueue_archive_prepare(self, worker) -> None:
thread = threading.Thread( thread = threading.Thread(
target=worker, target=worker,
@@ -490,6 +498,66 @@ class TaskRunner:
) )
self._update_history_failed(task_id, str(exc)) 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: def _cleanup_partial_duplicate(self, path: Path) -> None:
if not path.exists(): if not path.exists():
return return
@@ -556,6 +624,26 @@ class TaskRunner:
return files[0]["label"] return files[0]["label"]
return None 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( def _copy_single_planned_file(
self, self,
task_id: str, task_id: str,
Binary file not shown.
@@ -504,6 +504,108 @@ class FileOpsApiGoldenTest(unittest.TestCase):
self.assertTrue(target.joinpath("nested", "b.txt").exists()) self.assertTrue(target.joinpath("nested", "b.txt").exists())
self.assertTrue(target.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: def test_delete_invalid_path(self) -> None:
response = self._post( response = self._post(
"/api/files/delete", "/api/files/delete",
@@ -299,6 +299,20 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.assertEqual(history[0]['status'], 'completed') self.assertEqual(history[0]['status'], 'completed')
self.assertEqual(history[0]['path'], 'storage1/trash.txt') 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: 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')
@@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
pollTimer: null, pollTimer: null,
lastRenderKey: "", 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"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
{functions} {functions}
@@ -379,7 +379,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
{{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }}, {{ 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: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }},
{{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, {{ 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: "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: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
@@ -388,28 +388,33 @@ class UiSmokeGoldenTest(unittest.TestCase):
]; ];
const activeTasks = activeTasksFromItems(mixedTasks); 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.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(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); 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 === "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"); 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 === 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 moveRow = elements["header-task-popover-list"].children[1];
const moveProgress = moveRow.children[3]; const moveProgress = moveRow.children[3];
const moveCurrent = moveRow.children[4]; const moveCurrent = moveRow.children[4];
assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available"); assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available");
assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item"); 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 cancellingProgress = cancellingRow.children[3];
const cancellingCurrent = cancellingRow.children[4]; const cancellingCurrent = cancellingRow.children[4];
const cancellingSubtext = cancellingRow.children[5]; 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(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"); 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 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.textContent === "Stop", "Queued/running tasks should expose a Stop action");
assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable");
assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); 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"); 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([ updateHeaderTaskState([
{{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }}, {{ 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, pollTimer: null,
lastRenderKey: "", 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"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
{functions} {functions}
@@ -617,6 +627,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self._extract_js_function(app_js, "paneState"), self._extract_js_function(app_js, "paneState"),
self._extract_js_function(app_js, "otherPane"), self._extract_js_function(app_js, "otherPane"),
self._extract_js_function(app_js, "defaultDestination"), 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, "startCopySelected"),
self._extract_async_js_function(app_js, "executeMoveSelection"), 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(refreshCalls.length === 1, "Move should refresh task snapshot once");
assert(statusMessages.includes("Move: operation started"), "Move should report operation start"); 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"); 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"); assert(errorCalls.length === 0, "Batch operation start should not emit action errors");
}})().catch((error) => {{ }})().catch((error) => {{
console.error(error); console.error(error);
@@ -965,7 +1000,7 @@ 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_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('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("The header chip/popover reflects user-visible file operations, not every task-backed file action.", app_js)
self.assertIn('function headerTaskElements()', 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 updateHeaderTaskState(taskItems)', app_js)
self.assertIn('function applyTaskSnapshot(taskItems)', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', 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} ${task.done_items}/${task.total_items}`;', app_js)
self.assertIn('return `${action} running`;', app_js) self.assertIn('return `${action} running`;', app_js)
self.assertIn('return "Stopping after current item...";', 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 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('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('state.selectedTaskId = result.task_id;', app_js)
self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('await refreshTasksSnapshot();', app_js)
self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuDuplicate()', app_js)
+19 -17
View File
@@ -121,7 +121,7 @@ let headerTaskState = {
lastRenderKey: "", lastRenderKey: "",
}; };
// The header chip/popover reflects user-visible file operations, not every task-backed file action. // 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 ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
const VALID_THEME_FAMILIES = [ const VALID_THEME_FAMILIES = [
"default", "default",
@@ -2761,28 +2761,30 @@ function openConfirmModal({ title, message, path, applyText = "Confirm" }) {
} }
async function executeDeleteItems(pane, items, recursivePaths) { async function executeDeleteItems(pane, items, recursivePaths) {
let successes = 0; try {
let failures = 0; let result;
let firstError = null; if (items.length > 1) {
for (const item of items) { result = await apiRequest("POST", "/api/files/delete", {
try { paths: items.map((item) => item.path),
const result = await apiRequest("POST", "/api/files/delete", { recursive_paths: Array.from(recursivePaths),
});
setStatus("Delete: operation started");
} else {
const item = items[0];
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; setStatus("Delete: started");
await refreshTasksSnapshot();
successes += 1;
} catch (err) {
failures += 1;
if (!firstError) {
firstError = `${item.path}: ${err.message}`;
}
} }
state.selectedTaskId = result.task_id;
await refreshTasksSnapshot();
} catch (err) {
setActionError("Delete", err);
return;
} }
setSelectedItem(pane, null); setSelectedItem(pane, null);
await loadBrowsePane(pane); await loadBrowsePane(pane);
showActionSummary("Delete", successes, failures, firstError);
} }
async function submitDeleteConfirmModal() { async function submitDeleteConfirmModal() {
@@ -3901,7 +3903,7 @@ function canShowChipItemProgress(task) {
if (!hasMeaningfulItemProgress(task)) { if (!hasMeaningfulItemProgress(task)) {
return false; return false;
} }
return task.operation === "copy" || task.operation === "duplicate"; return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";
} }
function compactTaskCurrentItem(task) { function compactTaskCurrentItem(task) {