Folder move added
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -125,39 +125,62 @@ class TaskRepository:
|
||||
).fetchall()
|
||||
return [self._to_dict(row) for row in rows]
|
||||
|
||||
def mark_running(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None:
|
||||
def mark_running(
|
||||
self,
|
||||
task_id: str,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
current_item: str | None = None,
|
||||
) -> None:
|
||||
started_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, current_item = ?
|
||||
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
("running", started_at, done_bytes, total_bytes, current_item, task_id),
|
||||
("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id),
|
||||
)
|
||||
|
||||
def update_progress(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None:
|
||||
def update_progress(
|
||||
self,
|
||||
task_id: str,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
current_item: str | None = None,
|
||||
) -> None:
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET done_bytes = ?, total_bytes = ?, current_item = ?
|
||||
SET done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(done_bytes, total_bytes, current_item, task_id),
|
||||
(done_bytes, total_bytes, done_items, total_items, current_item, task_id),
|
||||
)
|
||||
|
||||
def mark_completed(self, task_id: str, done_bytes: int | None, total_bytes: int | None) -> None:
|
||||
def mark_completed(
|
||||
self,
|
||||
task_id: str,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> None:
|
||||
finished_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?
|
||||
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
("completed", finished_at, done_bytes, total_bytes, task_id),
|
||||
("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id),
|
||||
)
|
||||
|
||||
def mark_failed(
|
||||
@@ -168,16 +191,29 @@ class TaskRepository:
|
||||
failed_item: str | None,
|
||||
done_bytes: int | None,
|
||||
total_bytes: int | None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> None:
|
||||
finished_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?
|
||||
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
("failed", finished_at, error_code, error_message, failed_item, done_bytes, total_bytes, task_id),
|
||||
(
|
||||
"failed",
|
||||
finished_at,
|
||||
error_code,
|
||||
error_message,
|
||||
failed_item,
|
||||
done_bytes,
|
||||
total_bytes,
|
||||
done_items,
|
||||
total_items,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
|
||||
Binary file not shown.
@@ -38,6 +38,9 @@ class FilesystemAdapter:
|
||||
def move_file(self, source: str, destination: str) -> None:
|
||||
Path(source).rename(Path(destination))
|
||||
|
||||
def move_directory(self, source: str, destination: str) -> None:
|
||||
Path(source).rename(Path(destination))
|
||||
|
||||
def is_directory_empty(self, path: Path) -> bool:
|
||||
return not any(path.iterdir())
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -20,14 +22,16 @@ class MoveTaskService:
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a regular file",
|
||||
message="Source must not be a symlink",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
)
|
||||
if not resolved_source.absolute.is_file():
|
||||
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:
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a file",
|
||||
message="Unsupported source path type",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
)
|
||||
@@ -41,6 +45,14 @@ class MoveTaskService:
|
||||
)
|
||||
self._map_directory_validation(parent_relative)
|
||||
|
||||
if source_is_directory and resolved_destination.absolute == resolved_source.absolute:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Destination must differ from source",
|
||||
status_code=400,
|
||||
details={"path": source, "destination": destination},
|
||||
)
|
||||
|
||||
if resolved_destination.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
@@ -49,6 +61,36 @@ class MoveTaskService:
|
||||
details={"path": resolved_destination.relative},
|
||||
)
|
||||
|
||||
same_root = resolved_source.alias == resolved_destination.alias
|
||||
|
||||
if source_is_directory:
|
||||
if not same_root:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Cross-root directory move is not supported in v1",
|
||||
status_code=400,
|
||||
details={"path": source, "destination": destination},
|
||||
)
|
||||
if self._is_nested_destination(resolved_source.absolute, resolved_destination.absolute):
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Destination cannot be inside source",
|
||||
status_code=400,
|
||||
details={"path": source, "destination": destination},
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -56,7 +98,6 @@ class MoveTaskService:
|
||||
destination=resolved_destination.relative,
|
||||
)
|
||||
|
||||
same_root = resolved_source.alias == resolved_destination.alias
|
||||
self._runner.enqueue_move_file(
|
||||
task_id=task["id"],
|
||||
source=str(resolved_source.absolute),
|
||||
@@ -79,3 +120,11 @@ class MoveTaskService:
|
||||
details=exc.details,
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_nested_destination(source: Path, destination: Path) -> bool:
|
||||
try:
|
||||
destination.relative_to(source)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -35,6 +35,14 @@ class TaskRunner:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def enqueue_move_directory(self, task_id: str, source: str, destination: str) -> None:
|
||||
thread = threading.Thread(
|
||||
target=self._run_move_directory,
|
||||
args=(task_id, source, destination),
|
||||
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,
|
||||
@@ -123,3 +131,28 @@ class TaskRunner:
|
||||
done_bytes=progress["done"],
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
|
||||
def _run_move_directory(self, task_id: str, source: str, destination: str) -> None:
|
||||
self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
current_item=source,
|
||||
)
|
||||
|
||||
try:
|
||||
self._filesystem.move_directory(source=source, destination=destination)
|
||||
self._repository.mark_completed(
|
||||
task_id=task_id,
|
||||
done_items=1,
|
||||
total_items=1,
|
||||
)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
error_code="io_error",
|
||||
error_message=str(exc),
|
||||
failed_item=source,
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -96,6 +96,33 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertTrue((self.root1 / "moved.txt").exists())
|
||||
self.assertFalse(src.exists())
|
||||
|
||||
def test_move_directory_success_same_root_and_completed(self) -> None:
|
||||
src_dir = self.root1 / "source-dir"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "nested.txt").write_text("hello", encoding="utf-8")
|
||||
target_parent = self.root1 / "target-parent"
|
||||
target_parent.mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/source-dir", "destination": "storage1/target-parent/moved-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
body = response.json()
|
||||
self.assertEqual(body["status"], "queued")
|
||||
|
||||
detail = self._wait_task(body["task_id"])
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 1)
|
||||
self.assertIsNone(detail["done_bytes"])
|
||||
self.assertIsNone(detail["total_bytes"])
|
||||
self.assertTrue((self.root1 / "target-parent" / "moved-dir").is_dir())
|
||||
self.assertTrue((self.root1 / "target-parent" / "moved-dir" / "nested.txt").exists())
|
||||
self.assertFalse(src_dir.exists())
|
||||
|
||||
def test_move_success_cross_root_create_task_shape_and_completed(self) -> None:
|
||||
src = self.root1 / "source.txt"
|
||||
src.write_text("hello", encoding="utf-8")
|
||||
@@ -116,6 +143,19 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertTrue((self.root2 / "moved.txt").exists())
|
||||
self.assertFalse(src.exists())
|
||||
|
||||
def test_move_directory_cross_root_blocked(self) -> None:
|
||||
src_dir = self.root1 / "source-dir"
|
||||
src_dir.mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/source-dir", "destination": "storage2/source-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_request")
|
||||
|
||||
def test_move_source_not_found(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
@@ -126,13 +166,24 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||
|
||||
def test_move_source_is_directory_type_conflict(self) -> None:
|
||||
def test_move_directory_source_not_found(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/missing-dir", "destination": "storage1/out-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||
|
||||
def test_move_source_is_directory_type_conflict_for_file_destination_parent(self) -> None:
|
||||
(self.root1 / "dir").mkdir()
|
||||
(self.root1 / "out.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/dir", "destination": "storage1/out.txt"},
|
||||
{"source": "storage1/dir", "destination": "storage1/out.txt/child"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
@@ -151,6 +202,19 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "already_exists")
|
||||
|
||||
def test_move_directory_destination_exists_already_exists(self) -> None:
|
||||
(self.root1 / "source-dir").mkdir()
|
||||
(self.root1 / "target-dir").mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/source-dir", "destination": "storage1/target-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "already_exists")
|
||||
|
||||
def test_move_traversal_source(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
@@ -173,6 +237,33 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||
|
||||
def test_move_directory_destination_inside_source_blocked(self) -> None:
|
||||
src_dir = self.root1 / "source-dir"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "child").mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/source-dir", "destination": "storage1/source-dir/child/moved-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_request")
|
||||
|
||||
def test_move_directory_same_source_destination_blocked(self) -> None:
|
||||
src_dir = self.root1 / "source-dir"
|
||||
src_dir.mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/source-dir", "destination": "storage1/source-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_request")
|
||||
|
||||
def test_move_source_symlink_rejected(self) -> None:
|
||||
target = self.root1 / "real.txt"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
@@ -188,6 +279,22 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "type_conflict")
|
||||
|
||||
def test_move_directory_source_symlink_rejected(self) -> None:
|
||||
target = self.root1 / "real-dir"
|
||||
target.mkdir()
|
||||
(target / "nested.txt").write_text("x", encoding="utf-8")
|
||||
link = self.root1 / "dir-link"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{"source": "storage1/dir-link", "destination": "storage1/out-dir"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "type_conflict")
|
||||
|
||||
def test_move_runtime_io_error_failed_task_shape(self) -> None:
|
||||
src = self.root1 / "source.txt"
|
||||
src.write_text("hello", encoding="utf-8")
|
||||
|
||||
@@ -82,6 +82,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertTrue((static_root / "style.css").exists())
|
||||
app_js = (static_root / "app.js").read_text(encoding="utf-8")
|
||||
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("function rootKeyFromPath(path)", app_js)
|
||||
self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js)
|
||||
|
||||
app_js_url = app.url_path_for("ui", path="/app.js")
|
||||
style_css_url = app.url_path_for("ui", path="/style.css")
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from backend.app.services.move_task_service import MoveTaskService
|
||||
|
||||
|
||||
class MoveTaskServiceTest(unittest.TestCase):
|
||||
def test_is_nested_destination_true_for_child_path(self) -> None:
|
||||
source = Path("/tmp/source")
|
||||
destination = source / "child" / "target"
|
||||
|
||||
self.assertTrue(MoveTaskService._is_nested_destination(source, destination))
|
||||
|
||||
def test_is_nested_destination_false_for_sibling_path(self) -> None:
|
||||
source = Path("/tmp/source")
|
||||
destination = Path("/tmp/other/target")
|
||||
|
||||
self.assertFalse(MoveTaskService._is_nested_destination(source, destination))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+42
-4
@@ -259,6 +259,30 @@ function baseName(path) {
|
||||
return index >= 0 ? path.slice(index + 1) : path;
|
||||
}
|
||||
|
||||
function rootKeyFromPath(path) {
|
||||
const normalized = (path || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
return normalized;
|
||||
}
|
||||
return `/${segments[0]}/${segments[1]}`;
|
||||
}
|
||||
return normalized.split("/")[0];
|
||||
}
|
||||
|
||||
function isNestedPath(sourcePath, destinationPath) {
|
||||
const source = (sourcePath || "").replace(/\/+$/, "");
|
||||
const destination = (destinationPath || "").replace(/\/+$/, "");
|
||||
if (!source || !destination) {
|
||||
return false;
|
||||
}
|
||||
return destination.startsWith(`${source}/`);
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(pane, path) {
|
||||
const nav = document.getElementById(`${pane}-breadcrumbs`);
|
||||
nav.innerHTML = "";
|
||||
@@ -892,6 +916,12 @@ function showDirectoryMoveNotSupported() {
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
function showBatchDirectoryMoveNotSupported() {
|
||||
const message = "Batch directory move is not supported in v1";
|
||||
setError("actions-error", message);
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
function resetRenameMoveState() {
|
||||
renameMoveState = {
|
||||
source: null,
|
||||
@@ -965,7 +995,7 @@ function openF6Flow() {
|
||||
return openRenameMovePopup();
|
||||
}
|
||||
if (selectedItems.some((item) => item.kind !== "file")) {
|
||||
showDirectoryMoveNotSupported();
|
||||
showBatchDirectoryMoveNotSupported();
|
||||
return true;
|
||||
}
|
||||
return openBatchMovePopup(selectedItems);
|
||||
@@ -991,9 +1021,17 @@ async function submitRenameMovePopup() {
|
||||
elements.error.textContent = "Destination must differ from source";
|
||||
return;
|
||||
}
|
||||
if (source.kind === "directory" && destinationParent !== sourceParent) {
|
||||
elements.error.textContent = "Directory move is not supported in v1";
|
||||
return;
|
||||
if (source.kind === "directory") {
|
||||
if (isNestedPath(source.path, destination)) {
|
||||
elements.error.textContent = "Destination cannot be inside source";
|
||||
return;
|
||||
}
|
||||
if (destinationParent !== sourceParent) {
|
||||
if (rootKeyFromPath(destination) !== rootKeyFromPath(source.path)) {
|
||||
elements.error.textContent = "Cross-root directory move is not supported in v1";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user