feat: feedback verbetering - 06
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Binary file not shown.
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)
|
||||||
|
|||||||
+16
-14
@@ -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;
|
|
||||||
let failures = 0;
|
|
||||||
let firstError = null;
|
|
||||||
for (const item of items) {
|
|
||||||
try {
|
try {
|
||||||
const result = await apiRequest("POST", "/api/files/delete", {
|
let result;
|
||||||
|
if (items.length > 1) {
|
||||||
|
result = await apiRequest("POST", "/api/files/delete", {
|
||||||
|
paths: items.map((item) => item.path),
|
||||||
|
recursive_paths: Array.from(recursivePaths),
|
||||||
|
});
|
||||||
|
setStatus("Delete: operation started");
|
||||||
|
} else {
|
||||||
|
const item = items[0];
|
||||||
|
result = await apiRequest("POST", "/api/files/delete", {
|
||||||
path: item.path,
|
path: item.path,
|
||||||
recursive: recursivePaths.has(item.path),
|
recursive: recursivePaths.has(item.path),
|
||||||
});
|
});
|
||||||
|
setStatus("Delete: started");
|
||||||
|
}
|
||||||
state.selectedTaskId = result.task_id;
|
state.selectedTaskId = result.task_id;
|
||||||
await refreshTasksSnapshot();
|
await refreshTasksSnapshot();
|
||||||
successes += 1;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures += 1;
|
setActionError("Delete", err);
|
||||||
if (!firstError) {
|
return;
|
||||||
firstError = `${item.path}: ${err.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user