diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index b700bb7..d6e7692 100644 Binary files a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc and b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc index 9f04b64..64ca8e0 100644 Binary files a/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 629abb5..12bc28d 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_move.py b/webui/backend/app/api/routes_move.py index ea42f85..31213eb 100644 --- a/webui/backend/app/api/routes_move.py +++ b/webui/backend/app/api/routes_move.py @@ -14,4 +14,9 @@ async def move_file( request: MoveRequest, service: MoveTaskService = Depends(get_move_task_service), ) -> TaskCreateResponse: + if request.sources is not None: + return service.create_batch_move_task( + sources=request.sources, + destination_base=request.destination_base, + ) return service.create_move_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 3a1dec7..58284c2 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -125,8 +125,10 @@ class TaskCreateResponse(BaseModel): class MoveRequest(BaseModel): - source: str - destination: str + source: str | None = None + destination: str | None = None + sources: list[str] | None = None + destination_base: str | None = None class BookmarkCreateRequest(BaseModel): diff --git a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc index e06c762..b1b6ea5 100644 Binary files a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index 6ccec9d..1418173 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -5,7 +5,7 @@ from pathlib import Path from backend.app.api.errors import AppError from backend.app.api.schemas import TaskCreateResponse from backend.app.db.task_repository import TaskRepository -from backend.app.security.path_guard import PathGuard +from backend.app.security.path_guard import PathGuard, ResolvedPath from backend.app.tasks_runner import TaskRunner @@ -15,7 +15,108 @@ class MoveTaskService: self._repository = repository self._runner = runner - def create_move_task(self, source: str, destination: str) -> TaskCreateResponse: + def create_move_task(self, source: str | None, destination: str | None) -> TaskCreateResponse: + if not source or not destination: + raise AppError( + code="invalid_request", + message="Source and destination are required", + status_code=400, + ) + + item = self._build_move_item(source=source, destination=destination) + + task = self._repository.create_task( + operation="move", + source=item["source_relative"], + destination=item["destination_relative"], + ) + + if item["kind"] == "directory": + self._runner.enqueue_move_directory( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + ) + else: + self._runner.enqueue_move_file( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + total_bytes=item["total_bytes"], + same_root=item["same_root"], + ) + + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def create_batch_move_task(self, sources: list[str] | None, destination_base: str | None) -> TaskCreateResponse: + if not sources or len(sources) < 2: + raise AppError( + code="invalid_request", + message="Batch move requires at least 2 sources", + status_code=400, + ) + if not destination_base: + raise AppError( + code="invalid_request", + message="Destination base is required", + status_code=400, + ) + + resolved_destination_base = self._path_guard.resolve_directory_path(destination_base) + items: list[dict] = [] + resolved_sources = [self._path_guard.resolve_existing_path(source) for source in sources] + source_aliases = {resolved_source.alias for resolved_source in resolved_sources} + if len(source_aliases) != 1: + raise AppError( + code="invalid_request", + message="Batch move requires all selected items to be in the same root", + status_code=400, + ) + + root_alias = next(iter(source_aliases)) + if root_alias != resolved_destination_base.alias: + raise AppError( + code="invalid_request", + message="Cross-root batch directory move is not supported in v1", + status_code=400, + details={"destination_base": destination_base}, + ) + + for source, source_resolved in zip(sources, resolved_sources): + destination = self._join_destination_base(destination_base, source_resolved.absolute.name) + item = self._build_move_item( + source=source, + destination=destination, + resolved_destination=resolved_destination_base, + destination_base=destination_base, + ) + items.append(item) + + task = self._repository.create_task( + operation="move", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + ) + self._runner.enqueue_move_batch( + task_id=task["id"], + items=[ + { + "source": item["source_absolute"], + "destination": item["destination_absolute"], + "kind": item["kind"], + } + for item in items + ], + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def _build_move_item( + self, + source: str, + destination: str, + resolved_destination: ResolvedPath | None = None, + destination_base: str | None = None, + ) -> dict: resolved_source = self._path_guard.resolve_existing_path(source) _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) @@ -26,6 +127,7 @@ class MoveTaskService: status_code=409, details={"path": source}, ) + source_is_file = resolved_source.absolute.is_file() source_is_directory = resolved_source.absolute.is_dir() if not source_is_file and not source_is_directory: @@ -36,8 +138,18 @@ class MoveTaskService: details={"path": source}, ) - resolved_destination = self._path_guard.resolve_path(destination) - destination_parent = resolved_destination.absolute.parent + resolved_destination = resolved_destination or self._path_guard.resolve_path(destination) + destination_absolute = ( + resolved_destination.absolute / resolved_source.absolute.name + if destination_base is not None + else resolved_destination.absolute + ) + destination_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_absolute, + display_style=resolved_destination.display_style, + ) + destination_parent = destination_absolute.parent parent_relative = self._path_guard.entry_relative_path( resolved_destination.alias, destination_parent, @@ -45,20 +157,20 @@ class MoveTaskService: ) self._map_directory_validation(parent_relative) - if source_is_directory and resolved_destination.absolute == resolved_source.absolute: + if destination_absolute == resolved_source.absolute: raise AppError( code="invalid_request", message="Destination must differ from source", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - if resolved_destination.absolute.exists(): + if destination_absolute.exists(): raise AppError( code="already_exists", message="Target path already exists", status_code=409, - details={"path": resolved_destination.relative}, + details={"path": destination_relative}, ) same_root = resolved_source.alias == resolved_destination.alias @@ -69,44 +181,25 @@ class MoveTaskService: code="invalid_request", message="Cross-root directory move is not supported in v1", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - if self._is_nested_destination(resolved_source.absolute, resolved_destination.absolute): + if self._is_nested_destination(resolved_source.absolute, destination_absolute): raise AppError( code="invalid_request", message="Destination cannot be inside source", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - task = self._repository.create_task( - operation="move", - source=resolved_source.relative, - destination=resolved_destination.relative, - ) - self._runner.enqueue_move_directory( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - ) - return TaskCreateResponse(task_id=task["id"], status=task["status"]) - - total_bytes = int(resolved_source.absolute.stat().st_size) - task = self._repository.create_task( - operation="move", - source=resolved_source.relative, - destination=resolved_destination.relative, - ) - - self._runner.enqueue_move_file( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - total_bytes=total_bytes, - same_root=same_root, - ) - - return TaskCreateResponse(task_id=task["id"], status=task["status"]) + return { + "source_relative": resolved_source.relative, + "destination_relative": destination_relative, + "source_absolute": str(resolved_source.absolute), + "destination_absolute": str(destination_absolute), + "kind": "directory" if source_is_directory else "file", + "same_root": same_root, + "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, + } def _map_directory_validation(self, relative_path: str) -> None: try: @@ -121,6 +214,10 @@ class MoveTaskService: ) raise + @staticmethod + def _join_destination_base(destination_base: str, name: str) -> str: + return f"{destination_base.rstrip('/')}/{name}" if destination_base.rstrip("/") else f"/{name}" + @staticmethod def _is_nested_destination(source: Path, destination: Path) -> bool: try: diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 2111370..1da80aa 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -43,6 +43,14 @@ class TaskRunner: ) thread.start() + def enqueue_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + thread = threading.Thread( + target=self._run_move_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: self._repository.mark_running( task_id=task_id, @@ -156,3 +164,49 @@ class TaskRunner: done_items=0, total_items=1, ) + + def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + total_items = len(items) + current_item = items[0]["source"] if items else None + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=current_item, + ) + + completed_items = 0 + for index, item in enumerate(items): + source = item["source"] + destination = item["destination"] + try: + if item["kind"] == "directory": + self._filesystem.move_directory(source=source, destination=destination) + else: + self._filesystem.move_file(source=source, destination=destination) + completed_items = index + 1 + next_item = items[index + 1]["source"] if index + 1 < total_items else source + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=next_item, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=None, + total_bytes=None, + done_items=completed_items, + total_items=total_items, + ) + return + + self._repository.mark_completed( + task_id=task_id, + done_items=total_items, + total_items=total_items, + ) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f33208e..f045ea4 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc index 0dda84c..7383ab3 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index f9f49eb..5a04505 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py index 1d083e5..fbdfaff 100644 --- a/webui/backend/tests/golden/test_api_move_golden.py +++ b/webui/backend/tests/golden/test_api_move_golden.py @@ -26,6 +26,18 @@ class FailingDeleteFilesystemAdapter(FilesystemAdapter): raise OSError("forced delete failure") +class FailingBatchFilesystemAdapter(FilesystemAdapter): + def move_file(self, source: str, destination: str) -> None: + if Path(source).name == "fail-file.txt": + raise OSError("forced batch move failure") + super().move_file(source, destination) + + def move_directory(self, source: str, destination: str) -> None: + if Path(source).name == "fail-dir": + raise OSError("forced batch move failure") + super().move_directory(source, destination) + + class MoveApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -156,6 +168,193 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_move_batch_same_root_directories_success(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + (first / "a.txt").write_text("a", encoding="utf-8") + (second / "b.txt").write_text("b", encoding="utf-8") + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((target / "first-dir").is_dir()) + self.assertTrue((target / "second-dir").is_dir()) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + + def test_move_batch_same_root_mixed_files_and_directories_success(self) -> None: + source_file = self.root1 / "one.txt" + source_file.write_text("x", encoding="utf-8") + source_dir = self.root1 / "dir-a" + source_dir.mkdir() + (source_dir / "nested.txt").write_text("y", encoding="utf-8") + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/one.txt", "storage1/dir-a"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((target / "one.txt").exists()) + self.assertTrue((target / "dir-a").is_dir()) + self.assertFalse(source_file.exists()) + self.assertFalse(source_dir.exists()) + + def test_move_batch_cross_root_directories_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage2", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_mixed_root_selection_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root2 / "other-dir" + first.mkdir() + second.mkdir() + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage2/other-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_destination_exists_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + target = self.root1 / "target" + target.mkdir() + (target / "second-dir").mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + + def test_move_batch_destination_inside_source_blocked(self) -> None: + first = self.root1 / "first-dir" + first.mkdir() + (first / "child").mkdir() + second = self.root1 / "second-dir" + second.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/first-dir/child", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_symlink_source_blocked(self) -> None: + real_dir = self.root1 / "real-dir" + real_dir.mkdir() + symlink = self.root1 / "dir-link" + symlink.symlink_to(real_dir, target_is_directory=True) + other = self.root1 / "other-dir" + other.mkdir() + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/dir-link", "storage1/other-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_move_batch_runtime_io_error_failed_task_shape(self) -> None: + first = self.root1 / "ok-dir" + first.mkdir() + second = self.root1 / "fail-dir" + second.mkdir() + target = self.root1 / "target" + target.mkdir() + + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=FailingBatchFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/ok-dir", "storage1/fail-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "failed") + self.assertEqual(detail["error_code"], "io_error") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertEqual(detail["failed_item"], str(second)) + self.assertTrue((target / "ok-dir").is_dir()) + self.assertTrue(second.exists()) + def test_move_source_not_found(self) -> None: response = self._request( "POST", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 92cf661..e74f713 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -84,6 +84,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('currentPath: "/Volumes"', app_js) self.assertIn('Cross-root directory move is not supported in v1', app_js) self.assertIn('Batch directory move is not supported in v1', app_js) + self.assertIn('Batch move requires all selected items to be in the same root', app_js) + self.assertIn('destination_base', app_js) + self.assertIn('sources: selectedItems.map((item) => item.path)', app_js) self.assertIn("function rootKeyFromPath(path)", app_js) self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 36aa2a4..6c77034 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -283,6 +283,10 @@ function isNestedPath(sourcePath, destinationPath) { return destination.startsWith(`${source}/`); } +function uniqueRootKeysForItems(items) { + return [...new Set(items.map((item) => rootKeyFromPath(item.path)).filter(Boolean))]; +} + function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; @@ -699,7 +703,22 @@ async function executeMoveSelection(baseDestination) { if (selectedItems.length === 0) { return; } + const allFiles = selectedItems.every((item) => item.kind === "file"); setError("actions-error", ""); + + if (!allFiles) { + const result = await apiRequest("POST", "/api/files/move", { + sources: selectedItems.map((item) => item.path), + destination_base: baseDestination, + }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + setSelectedItem(sourcePane, null); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + setStatus("Move: batch started"); + return; + } + let successes = 0; let failures = 0; let firstError = null; @@ -995,8 +1014,14 @@ function openF6Flow() { return openRenameMovePopup(); } if (selectedItems.some((item) => item.kind !== "file")) { - showBatchDirectoryMoveNotSupported(); - return true; + const roots = uniqueRootKeysForItems(selectedItems); + if (roots.length > 1) { + const message = "Batch move requires all selected items to be in the same root"; + setError("actions-error", message); + setStatus(message); + return true; + } + return openBatchMovePopup(selectedItems); } return openBatchMovePopup(selectedItems); }