diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 61e069a..96564b6 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_copy.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc index d6dc33e..67aaf36 100644 Binary files a/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_copy.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 cdd28a3..d68e0a4 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_copy.py b/webui/backend/app/api/routes_copy.py index d547859..8480740 100644 --- a/webui/backend/app/api/routes_copy.py +++ b/webui/backend/app/api/routes_copy.py @@ -14,4 +14,9 @@ async def copy_file( request: CopyRequest, service: CopyTaskService = Depends(get_copy_task_service), ) -> TaskCreateResponse: + if request.sources is not None: + return service.create_batch_copy_task( + sources=request.sources, + destination_base=request.destination_base, + ) return service.create_copy_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 1a7b8a2..d228d04 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -153,8 +153,10 @@ class TaskDetailResponse(BaseModel): class CopyRequest(BaseModel): - source: str - destination: str + source: str | None = None + destination: str | None = None + sources: list[str] | None = None + destination_base: str | None = None class TaskCreateResponse(BaseModel): diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 05cd24d..5d4512b 100644 Binary files a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc and b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc differ diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index ab69c90..8b3c992 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -120,6 +120,9 @@ class FilesystemAdapter: on_progress(out_f.tell()) shutil.copystat(src, dst, follow_symlinks=False) + def copy_directory(self, source: str, destination: str) -> None: + shutil.copytree(source, destination, symlinks=True, copy_function=shutil.copy2) + def read_text_preview(self, path: Path, max_bytes: int, encoding: str = "utf-8") -> dict: size = int(path.stat().st_size) limit = max_bytes + 1 diff --git a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc index 2d517e8..6aa2d74 100644 Binary files a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/copy_task_service.py b/webui/backend/app/services/copy_task_service.py index 98c8bbd..fe24e70 100644 --- a/webui/backend/app/services/copy_task_service.py +++ b/webui/backend/app/services/copy_task_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path import uuid @@ -8,7 +9,7 @@ from backend.app.api.errors import AppError from backend.app.api.schemas import TaskCreateResponse from backend.app.db.history_repository import HistoryRepository from backend.app.db.task_repository import TaskRepository -from backend.app.security.path_guard import PathGuard +from backend.app.security.path_guard import PathGuard, ResolvedPath from backend.app.tasks_runner import TaskRunner @@ -20,63 +21,42 @@ class CopyTaskService: self._history_repository = history_repository def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse: - try: - resolved_source = self._path_guard.resolve_existing_path(source) - _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) - if lexical_source.is_symlink(): - raise AppError( - code="type_conflict", - message="Source must be a regular file", - status_code=409, - details={"path": source}, - ) - if not resolved_source.absolute.is_file(): - raise AppError( - code="type_conflict", - message="Source must be a file", - status_code=409, - details={"path": source}, - ) - - resolved_destination = self._path_guard.resolve_path(destination) - destination_parent = resolved_destination.absolute.parent - parent_relative = self._path_guard.entry_relative_path( - resolved_destination.alias, - destination_parent, - display_style=resolved_destination.display_style, + if not source or not destination: + raise AppError( + code="invalid_request", + message="Source and destination are required", + status_code=400, ) - self._map_directory_validation(parent_relative) - - if resolved_destination.absolute.exists(): - raise AppError( - code="already_exists", - message="Target path already exists", - status_code=409, - details={"path": resolved_destination.relative}, - ) - - total_bytes = int(resolved_source.absolute.stat().st_size) + try: + item = self._build_copy_item(source=source, destination=destination) task_id = str(uuid.uuid4()) task = self._repository.create_task( operation="copy", - source=resolved_source.relative, - destination=resolved_destination.relative, + source=item["source_relative"], + destination=item["destination_relative"], task_id=task_id, ) self._record_history( entry_id=task_id, operation="copy", status="queued", - source=resolved_source.relative, - destination=resolved_destination.relative, + source=item["source_relative"], + destination=item["destination_relative"], ) - self._runner.enqueue_copy_file( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - total_bytes=total_bytes, - ) + if item["kind"] == "directory": + self._runner.enqueue_copy_directory( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + ) + else: + self._runner.enqueue_copy_file( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + total_bytes=item["total_bytes"], + ) return TaskCreateResponse(task_id=task["id"], status=task["status"]) except AppError as exc: @@ -91,6 +71,133 @@ class CopyTaskService: ) raise + def create_batch_copy_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 copy 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] = [] + for source in sources: + destination = self._join_destination_base(destination_base, self._path_guard.resolve_existing_path(source).absolute.name) + item = self._build_copy_item( + source=source, + destination=destination, + resolved_destination=resolved_destination_base, + destination_base=destination_base, + ) + items.append(item) + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="copy", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + task_id=task_id, + ) + self._record_history( + entry_id=task_id, + operation="copy", + status="queued", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + ) + self._runner.enqueue_copy_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_copy_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) + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must not be a symlink", + 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: + raise AppError( + code="type_conflict", + message="Unsupported source path type", + status_code=409, + details={"path": source}, + ) + + if source_is_directory: + self._validate_directory_tree(resolved_source) + + 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, + display_style=resolved_destination.display_style, + ) + self._map_directory_validation(parent_relative) + + if destination_absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": destination_relative}, + ) + + if source_is_directory and 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_relative}, + ) + + 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", + "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: self._path_guard.resolve_directory_path(relative_path) @@ -104,6 +211,31 @@ class CopyTaskService: ) raise + def _validate_directory_tree(self, resolved_source: ResolvedPath) -> None: + for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): + root_path = Path(root) + for name in [*dirnames, *filenames]: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + + @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: + destination.relative_to(source) + return True + except ValueError: + return False + def _record_history(self, **kwargs) -> None: if self._history_repository: self._history_repository.create_entry(**kwargs) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index b018801..f269d48 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -22,6 +22,22 @@ class TaskRunner: ) thread.start() + def enqueue_copy_directory(self, task_id: str, source: str, destination: str) -> None: + thread = threading.Thread( + target=self._run_copy_directory, + args=(task_id, source, destination), + daemon=True, + ) + thread.start() + + def enqueue_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + thread = threading.Thread( + target=self._run_copy_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def enqueue_move_file( self, task_id: str, @@ -91,6 +107,83 @@ class TaskRunner: ) self._update_history_failed(task_id, str(exc)) + def _run_copy_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.copy_directory(source=source, destination=destination) + self._repository.mark_completed( + task_id=task_id, + done_items=1, + total_items=1, + ) + self._update_history_completed(task_id) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=1, + ) + self._update_history_failed(task_id, str(exc)) + + def _run_copy_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.copy_directory(source=source, destination=destination) + else: + self._filesystem.copy_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, + ) + self._update_history_failed(task_id, str(exc)) + return + + self._repository.mark_completed( + task_id=task_id, + done_items=total_items, + total_items=total_items, + ) + self._update_history_completed(task_id) + def _run_move_file( self, task_id: str, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index b3484b6..ba7d4c7 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_copy_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc index 88c47d7..d89f9fe 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_copy_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 d977911..365d88e 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_copy_golden.py b/webui/backend/tests/golden/test_api_copy_golden.py index 05116ad..934135b 100644 --- a/webui/backend/tests/golden/test_api_copy_golden.py +++ b/webui/backend/tests/golden/test_api_copy_golden.py @@ -25,6 +25,9 @@ class FailingFilesystemAdapter(FilesystemAdapter): def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: raise OSError("forced copy failure") + def copy_directory(self, source: str, destination: str) -> None: + raise OSError("forced copy failure") + class CopyApiGoldenTest(unittest.TestCase): def setUp(self) -> None: @@ -96,6 +99,96 @@ class CopyApiGoldenTest(unittest.TestCase): self.assertTrue((self.root / "copy.txt").exists()) self.assertEqual((self.root / "copy.txt").read_text(encoding="utf-8"), "hello") + def test_copy_batch_multi_file_success(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/dest", + }, + ) + + 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.assertEqual((self.root / "dest" / "a.txt").read_text(encoding="utf-8"), "A") + self.assertEqual((self.root / "dest" / "b.txt").read_text(encoding="utf-8"), "B") + + def test_copy_single_directory_success(self) -> None: + src = self.root / "photos" + (src / "nested").mkdir(parents=True) + (src / "cover.jpg").write_text("img", encoding="utf-8") + (src / "nested" / "a.txt").write_text("nested", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/photos", "destination": "storage1/photos-copy"}, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertTrue((self.root / "photos-copy").is_dir()) + self.assertEqual((self.root / "photos-copy" / "cover.jpg").read_text(encoding="utf-8"), "img") + self.assertEqual((self.root / "photos-copy" / "nested" / "a.txt").read_text(encoding="utf-8"), "nested") + + def test_copy_batch_multi_directory_success(self) -> None: + (self.root / "dir1" / "sub").mkdir(parents=True) + (self.root / "dir2").mkdir() + (self.root / "dir1" / "sub" / "a.txt").write_text("A", encoding="utf-8") + (self.root / "dir2" / "b.txt").write_text("B", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/dir1", "storage1/dir2"], + "destination_base": "storage1/dest", + }, + ) + + 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.assertEqual((self.root / "dest" / "dir1" / "sub" / "a.txt").read_text(encoding="utf-8"), "A") + self.assertEqual((self.root / "dest" / "dir2" / "b.txt").read_text(encoding="utf-8"), "B") + + def test_copy_batch_mixed_file_and_directory_success(self) -> None: + (self.root / "file.txt").write_text("F", encoding="utf-8") + (self.root / "docs" / "nested").mkdir(parents=True) + (self.root / "docs" / "nested" / "note.txt").write_text("N", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/file.txt", "storage1/docs"], + "destination_base": "storage1/dest", + }, + ) + + 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.assertEqual((self.root / "dest" / "file.txt").read_text(encoding="utf-8"), "F") + self.assertEqual((self.root / "dest" / "docs" / "nested" / "note.txt").read_text(encoding="utf-8"), "N") + def test_copy_source_not_found(self) -> None: response = self._request( "POST", @@ -115,18 +208,6 @@ class CopyApiGoldenTest(unittest.TestCase): }, ) - def test_copy_source_is_directory_type_conflict(self) -> None: - (self.root / "dir").mkdir() - - response = self._request( - "POST", - "/api/files/copy", - {"source": "storage1/dir", "destination": "storage1/out.txt"}, - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "type_conflict") - def test_copy_destination_exists_already_exists(self) -> None: (self.root / "source.txt").write_text("x", encoding="utf-8") (self.root / "exists.txt").write_text("y", encoding="utf-8") @@ -149,6 +230,38 @@ class CopyApiGoldenTest(unittest.TestCase): }, ) + def test_copy_directory_destination_exists_already_exists(self) -> None: + (self.root / "src").mkdir() + (self.root / "src" / "a.txt").write_text("x", encoding="utf-8") + (self.root / "exists").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/src", "destination": "storage1/exists"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + + def test_copy_batch_destination_exists_already_exists(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "dest").mkdir() + (self.root / "dest" / "a.txt").write_text("exists", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/dest", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + def test_copy_traversal_source(self) -> None: response = self._request( "POST", @@ -171,6 +284,31 @@ class CopyApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + def test_copy_invalid_root_alias(self) -> None: + (self.root / "source.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "unknown/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_copy_destination_inside_directory_source_blocked(self) -> None: + (self.root / "src").mkdir() + (self.root / "src" / "a.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/src", "destination": "storage1/src/child"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_copy_source_symlink_rejected(self) -> None: target = self.root / "real.txt" target.write_text("x", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 710a638..7931bdc 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -223,6 +223,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('closeContextMenu();', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) + self.assertIn('const allFiles = items.length > 0 && items.every((item) => item.kind === "file");', app_js) + self.assertIn('elements.copyButton.disabled = !allFiles;', app_js) self.assertIn('elements.moveButton.classList.remove("hidden");', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 5dd5684..507203c 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -368,10 +368,12 @@ function openContextMenu(pane, entry, event) { contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; + const allFiles = items.length > 0 && items.every((item) => item.kind === "file"); elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.renameButton.classList.toggle("hidden", isMulti); elements.copyButton.classList.remove("hidden"); + elements.copyButton.disabled = !allFiles; elements.moveButton.classList.remove("hidden"); elements.deleteButton.classList.remove("hidden"); @@ -433,6 +435,9 @@ function startContextMenuMove() { } function startContextMenuCopy() { + if (contextMenuElements().copyButton?.disabled) { + return; + } if (!applyContextMenuSelection()) { closeContextMenu(); return;