Add Phase 2 remote browse scaffolding for /Clients
This commit is contained in:
@@ -18,7 +18,7 @@ RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1
|
|||||||
|
|
||||||
# Installeer een lichtgewicht Python API framework (FastAPI)
|
# Installeer een lichtgewicht Python API framework (FastAPI)
|
||||||
# We gebruiken --break-system-packages omdat we in een container zitten
|
# We gebruiken --break-system-packages omdat we in een container zitten
|
||||||
RUN pip3 install fastapi uvicorn python-multipart --break-system-packages
|
RUN pip3 install fastapi uvicorn python-multipart httpx --break-system-packages
|
||||||
|
|
||||||
# Exposeer de poort voor de webinterface
|
# Exposeer de poort voor de webinterface
|
||||||
EXPOSE 8030
|
EXPOSE 8030
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -9,6 +10,7 @@ import shutil
|
|||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
@@ -94,6 +96,97 @@ def _now_iso() -> str:
|
|||||||
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def remote_agent_config() -> dict:
|
||||||
|
config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip()
|
||||||
|
if not config_path:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(Path(config_path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def remote_agent_access_token() -> str:
|
||||||
|
return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str(
|
||||||
|
remote_agent_config().get("agent_access_token", "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_agent_shares() -> dict[str, str]:
|
||||||
|
raw = remote_agent_config().get("shares", {})
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
normalized: dict[str, str] = {}
|
||||||
|
for key, value in raw.items():
|
||||||
|
share_key = str(key).strip()
|
||||||
|
share_root = str(value).strip()
|
||||||
|
if share_key and share_root:
|
||||||
|
normalized[share_key] = share_root
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def require_remote_agent_auth(request: Request) -> None:
|
||||||
|
expected_token = remote_agent_access_token()
|
||||||
|
if not expected_token:
|
||||||
|
return
|
||||||
|
authorization = request.headers.get("authorization", "").strip()
|
||||||
|
if authorization != f"Bearer {expected_token}":
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid agent token")
|
||||||
|
|
||||||
|
|
||||||
|
def share_root(share: str) -> Path:
|
||||||
|
shares = remote_agent_shares()
|
||||||
|
normalized_share = (share or "").strip()
|
||||||
|
if normalized_share not in shares:
|
||||||
|
raise HTTPException(status_code=404, detail="Share not found")
|
||||||
|
return Path(shares[normalized_share]).expanduser().resolve(strict=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_within_share(root: Path, candidate: Path) -> Path:
|
||||||
|
try:
|
||||||
|
candidate.relative_to(root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="Path escapes share root") from exc
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path:
|
||||||
|
root = share_root(share)
|
||||||
|
normalized_raw_path = (raw_path or "").strip().replace("\\", "/")
|
||||||
|
if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid share-relative path")
|
||||||
|
candidate = (root / normalized_raw_path).resolve(strict=False)
|
||||||
|
candidate = ensure_within_share(root, candidate)
|
||||||
|
if must_exist and not candidate.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Path not found")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def remote_entry_payload(path: Path) -> dict:
|
||||||
|
st = path.lstat()
|
||||||
|
return {
|
||||||
|
"name": path.name,
|
||||||
|
"kind": "directory" if path.is_dir() else "file",
|
||||||
|
"size": st.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]:
|
||||||
|
try:
|
||||||
|
children = list(path.iterdir())
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc
|
||||||
|
filtered = []
|
||||||
|
for child in children:
|
||||||
|
if not show_hidden and child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
filtered.append(child)
|
||||||
|
filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||||
|
return [remote_entry_payload(child) for child in filtered]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def rel_from_home(path: Path) -> str:
|
def rel_from_home(path: Path) -> str:
|
||||||
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
|
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
|
||||||
@@ -314,7 +407,8 @@ async def harden_headers(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health(request: Request) -> dict:
|
||||||
|
require_remote_agent_auth(request)
|
||||||
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
|
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
|
||||||
|
|
||||||
|
|
||||||
@@ -332,7 +426,17 @@ def index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/list")
|
@app.get("/api/list")
|
||||||
def api_list(path: str = "", show_hidden: bool = False) -> dict:
|
def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict:
|
||||||
|
if share.strip():
|
||||||
|
require_remote_agent_auth(request)
|
||||||
|
target = resolve_share_path(share, path)
|
||||||
|
if not target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
|
return {
|
||||||
|
"share": share.strip(),
|
||||||
|
"path": path.strip().replace("\\", "/").strip("/"),
|
||||||
|
"entries": sorted_share_entries(target, show_hidden=show_hidden),
|
||||||
|
}
|
||||||
target = resolve_user_path(path)
|
target = resolve_user_path(path)
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,7 @@ class Settings:
|
|||||||
remote_client_offline_timeout_seconds: int
|
remote_client_offline_timeout_seconds: int
|
||||||
remote_client_agent_auth_header: str
|
remote_client_agent_auth_header: str
|
||||||
remote_client_agent_auth_scheme: str
|
remote_client_agent_auth_scheme: str
|
||||||
|
remote_client_agent_auth_token: str
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROOT_ALIASES = {
|
DEFAULT_ROOT_ALIASES = {
|
||||||
@@ -57,4 +58,5 @@ def get_settings() -> Settings:
|
|||||||
remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip()
|
remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip()
|
||||||
or "Authorization",
|
or "Authorization",
|
||||||
remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer",
|
remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer",
|
||||||
|
remote_client_agent_auth_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_TOKEN", "").strip(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ class RemoteClientRepository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [self._to_dict(row) for row in rows]
|
return [self._to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_client(self, client_id: str) -> dict | None:
|
||||||
|
with self._connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM remote_clients
|
||||||
|
WHERE client_id = ?
|
||||||
|
""",
|
||||||
|
(client_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._to_dict(row)
|
||||||
|
|
||||||
def _ensure_schema(self) -> None:
|
def _ensure_schema(self) -> None:
|
||||||
db_path = Path(self._db_path)
|
db_path = Path(self._db_path)
|
||||||
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from backend.app.services.duplicate_task_service import DuplicateTaskService
|
|||||||
from backend.app.services.file_ops_service import FileOpsService
|
from backend.app.services.file_ops_service import FileOpsService
|
||||||
from backend.app.services.history_service import HistoryService
|
from backend.app.services.history_service import HistoryService
|
||||||
from backend.app.services.move_task_service import MoveTaskService
|
from backend.app.services.move_task_service import MoveTaskService
|
||||||
|
from backend.app.services.remote_browse_service import RemoteBrowseService
|
||||||
from backend.app.services.remote_client_service import RemoteClientService
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
from backend.app.services.search_service import SearchService
|
from backend.app.services.search_service import SearchService
|
||||||
from backend.app.services.settings_service import SettingsService
|
from backend.app.services.settings_service import SettingsService
|
||||||
@@ -83,7 +84,11 @@ def get_archive_artifact_root() -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def get_browse_service() -> BrowseService:
|
async def get_browse_service() -> BrowseService:
|
||||||
return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
return BrowseService(
|
||||||
|
path_guard=get_path_guard(),
|
||||||
|
filesystem=get_filesystem_adapter(),
|
||||||
|
remote_browse_service=await get_remote_browse_service(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_file_ops_service() -> FileOpsService:
|
async def get_file_ops_service() -> FileOpsService:
|
||||||
@@ -172,3 +177,13 @@ async def get_remote_client_service() -> RemoteClientService:
|
|||||||
registration_token=settings.remote_client_registration_token,
|
registration_token=settings.remote_client_registration_token,
|
||||||
offline_timeout_seconds=settings.remote_client_offline_timeout_seconds,
|
offline_timeout_seconds=settings.remote_client_offline_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_remote_browse_service() -> RemoteBrowseService:
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return RemoteBrowseService(
|
||||||
|
remote_client_service=await get_remote_client_service(),
|
||||||
|
agent_auth_header=settings.remote_client_agent_auth_header,
|
||||||
|
agent_auth_scheme=settings.remote_client_agent_auth_scheme,
|
||||||
|
agent_auth_token=settings.remote_client_agent_auth_token,
|
||||||
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -3,14 +3,24 @@ from __future__ import annotations
|
|||||||
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
|
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
from backend.app.services.remote_browse_service import RemoteBrowseService
|
||||||
|
|
||||||
|
|
||||||
class BrowseService:
|
class BrowseService:
|
||||||
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path_guard: PathGuard,
|
||||||
|
filesystem: FilesystemAdapter,
|
||||||
|
remote_browse_service: RemoteBrowseService | None = None,
|
||||||
|
):
|
||||||
self._path_guard = path_guard
|
self._path_guard = path_guard
|
||||||
self._filesystem = filesystem
|
self._filesystem = filesystem
|
||||||
|
self._remote_browse_service = remote_browse_service
|
||||||
|
|
||||||
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
||||||
|
if self._remote_browse_service and self._remote_browse_service.handles_path(path):
|
||||||
|
return self._remote_browse_service.browse(path=path, show_hidden=show_hidden)
|
||||||
|
|
||||||
if self._path_guard.is_virtual_volumes_path(path):
|
if self._path_guard.is_virtual_volumes_path(path):
|
||||||
directories = [
|
directories = [
|
||||||
DirectoryEntry(name=item["name"], path=item["path"], modified="")
|
DirectoryEntry(name=item["name"], path=item["path"], modified="")
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.app.api.errors import AppError
|
||||||
|
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry, RemoteClientItem
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteBrowseService:
|
||||||
|
ROOT_PATH = "/Clients"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
remote_client_service: RemoteClientService,
|
||||||
|
agent_auth_header: str,
|
||||||
|
agent_auth_scheme: str,
|
||||||
|
agent_auth_token: str,
|
||||||
|
agent_timeout_seconds: float = 2.0,
|
||||||
|
):
|
||||||
|
self._remote_client_service = remote_client_service
|
||||||
|
self._agent_auth_header = (agent_auth_header or "Authorization").strip() or "Authorization"
|
||||||
|
self._agent_auth_scheme = (agent_auth_scheme or "Bearer").strip() or "Bearer"
|
||||||
|
self._agent_auth_token = (agent_auth_token or "").strip()
|
||||||
|
self._agent_timeout_seconds = max(0.1, float(agent_timeout_seconds))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handles_path(cls, path: str) -> bool:
|
||||||
|
normalized = (path or "").strip()
|
||||||
|
return normalized == cls.ROOT_PATH or normalized.startswith(f"{cls.ROOT_PATH}/")
|
||||||
|
|
||||||
|
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
||||||
|
parts = self._path_parts(path)
|
||||||
|
if not parts:
|
||||||
|
return self._browse_clients_root()
|
||||||
|
if len(parts) == 1:
|
||||||
|
return self._browse_client(parts[0])
|
||||||
|
return self._browse_remote_share(parts[0], parts[1], parts[2:], show_hidden)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _path_parts(cls, path: str) -> list[str]:
|
||||||
|
normalized = (path or "").strip().rstrip("/")
|
||||||
|
if normalized == cls.ROOT_PATH:
|
||||||
|
return []
|
||||||
|
return normalized[len(cls.ROOT_PATH) + 1 :].split("/")
|
||||||
|
|
||||||
|
def _browse_clients_root(self) -> BrowseResponse:
|
||||||
|
clients = self._remote_client_service.list_clients().items
|
||||||
|
directories = [
|
||||||
|
DirectoryEntry(
|
||||||
|
name=client.display_name,
|
||||||
|
path=f"{self.ROOT_PATH}/{client.client_id}",
|
||||||
|
modified=client.last_seen or client.updated_at,
|
||||||
|
)
|
||||||
|
for client in clients
|
||||||
|
]
|
||||||
|
return BrowseResponse(path=self.ROOT_PATH, directories=directories, files=[])
|
||||||
|
|
||||||
|
def _browse_client(self, client_id: str) -> BrowseResponse:
|
||||||
|
client = self._remote_client_service.get_client(client_id)
|
||||||
|
directories = [
|
||||||
|
DirectoryEntry(
|
||||||
|
name=share.label,
|
||||||
|
path=f"{self.ROOT_PATH}/{client.client_id}/{share.key}",
|
||||||
|
modified=client.last_seen or client.updated_at,
|
||||||
|
)
|
||||||
|
for share in client.shares
|
||||||
|
]
|
||||||
|
return BrowseResponse(path=f"{self.ROOT_PATH}/{client.client_id}", directories=directories, files=[])
|
||||||
|
|
||||||
|
def _browse_remote_share(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
share_key: str,
|
||||||
|
relative_parts: list[str],
|
||||||
|
show_hidden: bool,
|
||||||
|
) -> BrowseResponse:
|
||||||
|
client = self._remote_client_service.get_client(client_id)
|
||||||
|
if client.status != "online":
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_unavailable",
|
||||||
|
message=f"Remote client '{client.display_name}' is offline",
|
||||||
|
status_code=503,
|
||||||
|
details={"client_id": client.client_id, "status": client.status},
|
||||||
|
)
|
||||||
|
share = next((item for item in client.shares if item.key == share_key), None)
|
||||||
|
if share is None:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote share was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": client.client_id, "share_key": share_key},
|
||||||
|
)
|
||||||
|
if not self._agent_auth_token:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_agent_auth_not_configured",
|
||||||
|
message="Remote client agent auth token is not configured",
|
||||||
|
status_code=503,
|
||||||
|
details={"client_id": client.client_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
base_path = f"{self.ROOT_PATH}/{client.client_id}/{share.key}"
|
||||||
|
relative_path = "/".join(relative_parts)
|
||||||
|
agent_payload = self._fetch_remote_listing(client=client, share_key=share.key, relative_path=relative_path, show_hidden=show_hidden)
|
||||||
|
|
||||||
|
directories: list[DirectoryEntry] = []
|
||||||
|
files: list[FileEntry] = []
|
||||||
|
for entry in agent_payload.get("entries", []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = str(entry.get("name", "")).strip()
|
||||||
|
kind = str(entry.get("kind", "")).strip()
|
||||||
|
if not name or kind not in {"directory", "file"}:
|
||||||
|
continue
|
||||||
|
child_path = f"{base_path}/{name}"
|
||||||
|
modified = str(entry.get("modified", "") or "")
|
||||||
|
if kind == "directory":
|
||||||
|
directories.append(DirectoryEntry(name=name, path=child_path, modified=modified))
|
||||||
|
continue
|
||||||
|
size = entry.get("size", 0)
|
||||||
|
try:
|
||||||
|
normalized_size = max(0, int(size))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized_size = 0
|
||||||
|
files.append(FileEntry(name=name, path=child_path, size=normalized_size, modified=modified))
|
||||||
|
|
||||||
|
response_path = base_path if not relative_path else f"{base_path}/{relative_path}"
|
||||||
|
return BrowseResponse(path=response_path, directories=directories, files=files)
|
||||||
|
|
||||||
|
def _fetch_remote_listing(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: RemoteClientItem,
|
||||||
|
share_key: str,
|
||||||
|
relative_path: str,
|
||||||
|
show_hidden: bool,
|
||||||
|
) -> dict:
|
||||||
|
normalized_endpoint = client.endpoint.rstrip("/")
|
||||||
|
query = urlencode({"share": share_key, "path": relative_path, "show_hidden": str(show_hidden).lower()})
|
||||||
|
url = f"{normalized_endpoint}/api/list?{query}"
|
||||||
|
headers = {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"}
|
||||||
|
timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout, headers=headers) as client_http:
|
||||||
|
response = client_http.get(url)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_timeout",
|
||||||
|
message=f"Remote client '{client.display_name}' timed out",
|
||||||
|
status_code=504,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_unreachable",
|
||||||
|
message=f"Remote client '{client.display_name}' is unreachable",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote path was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": client.client_id, "share_key": share_key},
|
||||||
|
)
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_forbidden",
|
||||||
|
message=f"Remote client '{client.display_name}' rejected authentication",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' browse failed",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code)},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' returned invalid JSON",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' returned an invalid response",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
@@ -27,14 +27,30 @@ class RemoteClientService:
|
|||||||
self._now = now or (lambda: datetime.now(tz=timezone.utc))
|
self._now = now or (lambda: datetime.now(tz=timezone.utc))
|
||||||
|
|
||||||
def list_clients(self) -> RemoteClientListResponse:
|
def list_clients(self) -> RemoteClientListResponse:
|
||||||
now = self._now()
|
self._refresh_stale_statuses()
|
||||||
self._repository.mark_stale_clients_offline(
|
|
||||||
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
|
|
||||||
now_iso=self._to_iso(now),
|
|
||||||
)
|
|
||||||
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
|
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
|
||||||
return RemoteClientListResponse(items=items)
|
return RemoteClientListResponse(items=items)
|
||||||
|
|
||||||
|
def get_client(self, client_id: str) -> RemoteClientItem:
|
||||||
|
normalized_client_id = (client_id or "").strip()
|
||||||
|
if not normalized_client_id:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message="client_id is required",
|
||||||
|
status_code=400,
|
||||||
|
details={"client_id": client_id},
|
||||||
|
)
|
||||||
|
self._refresh_stale_statuses()
|
||||||
|
item = self._repository.get_client(normalized_client_id)
|
||||||
|
if item is None:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote client was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": normalized_client_id},
|
||||||
|
)
|
||||||
|
return RemoteClientItem(**item)
|
||||||
|
|
||||||
def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem:
|
def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem:
|
||||||
self._require_registration_auth(authorization)
|
self._require_registration_auth(authorization)
|
||||||
payload = self._normalize_register_request(request)
|
payload = self._normalize_register_request(request)
|
||||||
@@ -123,6 +139,13 @@ class RemoteClientService:
|
|||||||
"shares": shares,
|
"shares": shares,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _refresh_stale_statuses(self) -> None:
|
||||||
|
now = self._now()
|
||||||
|
self._repository.mark_stale_clients_offline(
|
||||||
|
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
|
||||||
|
now_iso=self._to_iso(now),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_iso(value: datetime) -> str:
|
def _to_iso(value: datetime) -> str:
|
||||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -11,11 +12,43 @@ import httpx
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
from backend.app.api.errors import AppError
|
||||||
from backend.app.dependencies import get_browse_service
|
from backend.app.dependencies import get_browse_service
|
||||||
|
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
from backend.app.main import app
|
from backend.app.main import app
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
from backend.app.services.browse_service import BrowseService
|
from backend.app.services.browse_service import BrowseService
|
||||||
|
from backend.app.services.remote_browse_service import RemoteBrowseService
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
|
||||||
|
class _StubRemoteBrowseService(RemoteBrowseService):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
remote_client_service: RemoteClientService,
|
||||||
|
listings: dict[tuple[str, str, str], dict],
|
||||||
|
failing_client_ids: set[str],
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
remote_client_service=remote_client_service,
|
||||||
|
agent_auth_header="Authorization",
|
||||||
|
agent_auth_scheme="Bearer",
|
||||||
|
agent_auth_token="agent-secret",
|
||||||
|
agent_timeout_seconds=0.25,
|
||||||
|
)
|
||||||
|
self._listings = listings
|
||||||
|
self._failing_client_ids = failing_client_ids
|
||||||
|
|
||||||
|
def _fetch_remote_listing(self, *, client, share_key: str, relative_path: str, show_hidden: bool) -> dict:
|
||||||
|
if client.client_id in self._failing_client_ids:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_unreachable",
|
||||||
|
message=f"Remote client '{client.display_name}' is unreachable",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
)
|
||||||
|
return self._listings[(client.client_id, share_key, relative_path)]
|
||||||
|
|
||||||
|
|
||||||
class BrowseApiGoldenTest(unittest.TestCase):
|
class BrowseApiGoldenTest(unittest.TestCase):
|
||||||
@@ -36,6 +69,12 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
file_path.write_bytes(b"abc")
|
file_path.write_bytes(b"abc")
|
||||||
second_file = self.second_root / "archive.txt"
|
second_file = self.second_root / "archive.txt"
|
||||||
second_file.write_text("z", encoding="utf-8")
|
second_file.write_text("z", encoding="utf-8")
|
||||||
|
remote_root = Path(self.temp_dir.name) / "remote-downloads"
|
||||||
|
remote_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
remote_dir = remote_root / "Series"
|
||||||
|
remote_dir.mkdir()
|
||||||
|
remote_file = remote_root / "episode.mkv"
|
||||||
|
remote_file.write_bytes(b"remote")
|
||||||
|
|
||||||
hidden_dir = self.root / ".hidden_dir"
|
hidden_dir = self.root / ".hidden_dir"
|
||||||
hidden_dir.mkdir()
|
hidden_dir.mkdir()
|
||||||
@@ -43,15 +82,70 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
hidden_file.write_bytes(b"x")
|
hidden_file.write_bytes(b"x")
|
||||||
|
|
||||||
mtime = 1710000000
|
mtime = 1710000000
|
||||||
for path in [folder, file_path, hidden_dir, hidden_file, second_file]:
|
for path in [folder, file_path, hidden_dir, hidden_file, second_file, remote_dir, remote_file]:
|
||||||
Path(path).touch()
|
Path(path).touch()
|
||||||
Path(path).chmod(0o755)
|
Path(path).chmod(0o755)
|
||||||
import os
|
|
||||||
os.utime(path, (mtime, mtime))
|
os.utime(path, (mtime, mtime))
|
||||||
|
|
||||||
|
repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db"))
|
||||||
|
now_iso = "2026-03-26T12:00:00Z"
|
||||||
|
repository.upsert_client(
|
||||||
|
client_id="client-123",
|
||||||
|
display_name="Jan MacBook",
|
||||||
|
platform="macos",
|
||||||
|
agent_version="1.1.0",
|
||||||
|
endpoint="http://agent.test",
|
||||||
|
shares=[{"key": "downloads", "label": "Downloads"}],
|
||||||
|
now_iso=now_iso,
|
||||||
|
)
|
||||||
|
repository.upsert_client(
|
||||||
|
client_id="broken-client",
|
||||||
|
display_name="Offline iMac",
|
||||||
|
platform="macos",
|
||||||
|
agent_version="1.1.0",
|
||||||
|
endpoint="http://127.0.0.1:1",
|
||||||
|
shares=[{"key": "downloads", "label": "Downloads"}],
|
||||||
|
now_iso=now_iso,
|
||||||
|
)
|
||||||
|
|
||||||
service = BrowseService(
|
service = BrowseService(
|
||||||
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
||||||
filesystem=FilesystemAdapter(),
|
filesystem=FilesystemAdapter(),
|
||||||
|
remote_browse_service=_StubRemoteBrowseService(
|
||||||
|
remote_client_service=RemoteClientService(
|
||||||
|
repository=repository,
|
||||||
|
registration_token="secret-token",
|
||||||
|
offline_timeout_seconds=60,
|
||||||
|
now=lambda: datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
listings={
|
||||||
|
(
|
||||||
|
"client-123",
|
||||||
|
"downloads",
|
||||||
|
"",
|
||||||
|
): {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Series",
|
||||||
|
"kind": "directory",
|
||||||
|
"size": remote_dir.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(remote_dir.stat().st_mtime, tz=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "episode.mkv",
|
||||||
|
"kind": "file",
|
||||||
|
"size": remote_file.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(remote_file.stat().st_mtime, tz=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
failing_client_ids={"broken-client"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def _override_browse_service() -> BrowseService:
|
async def _override_browse_service() -> BrowseService:
|
||||||
return service
|
return service
|
||||||
@@ -151,6 +245,80 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_browse_virtual_clients_and_remote_share(self) -> None:
|
||||||
|
clients_response = self._get("/Clients")
|
||||||
|
self.assertEqual(clients_response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
clients_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Jan MacBook",
|
||||||
|
"path": "/Clients/client-123",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Offline iMac",
|
||||||
|
"path": "/Clients/broken-client",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
shares_response = self._get("/Clients/client-123")
|
||||||
|
self.assertEqual(shares_response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
shares_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients/client-123",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Downloads",
|
||||||
|
"path": "/Clients/client-123/downloads",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
browse_response = self._get("/Clients/client-123/downloads")
|
||||||
|
self.assertEqual(browse_response.status_code, 200)
|
||||||
|
modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
self.assertEqual(
|
||||||
|
browse_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients/client-123/downloads",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Series",
|
||||||
|
"path": "/Clients/client-123/downloads/Series",
|
||||||
|
"modified": modified,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "episode.mkv",
|
||||||
|
"path": "/Clients/client-123/downloads/episode.mkv",
|
||||||
|
"size": 6,
|
||||||
|
"modified": modified,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_client_failure_stays_local_to_remote_subtree(self) -> None:
|
||||||
|
broken_response = self._get("/Clients/broken-client/downloads")
|
||||||
|
self.assertEqual(broken_response.status_code, 502)
|
||||||
|
self.assertEqual(broken_response.json()["error"]["code"], "remote_client_unreachable")
|
||||||
|
|
||||||
|
volumes_response = self._get("/Volumes")
|
||||||
|
self.assertEqual(volumes_response.status_code, 200)
|
||||||
|
self.assertEqual(volumes_response.json()["path"], "/Volumes")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+86
-19
@@ -141,6 +141,10 @@ const VALID_THEME_FAMILIES = [
|
|||||||
"fluent-neon",
|
"fluent-neon",
|
||||||
];
|
];
|
||||||
const VALID_COLOR_MODES = ["dark", "light"];
|
const VALID_COLOR_MODES = ["dark", "light"];
|
||||||
|
const VIRTUAL_SOURCES = [
|
||||||
|
{ path: "/Volumes", label: "Volumes" },
|
||||||
|
{ path: "/Clients", label: "Clients" },
|
||||||
|
];
|
||||||
let searchState = {
|
let searchState = {
|
||||||
pane: "left",
|
pane: "left",
|
||||||
path: "/Volumes",
|
path: "/Volumes",
|
||||||
@@ -200,6 +204,56 @@ function activePaneState() {
|
|||||||
return paneState(state.activePane);
|
return paneState(state.activePane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceRootForPath(path) {
|
||||||
|
const normalized = (path || "").trim();
|
||||||
|
if (normalized === "/Clients" || normalized.startsWith("/Clients/")) {
|
||||||
|
return "/Clients";
|
||||||
|
}
|
||||||
|
return "/Volumes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoteBrowsePath(path) {
|
||||||
|
return sourceRootForPath(path) === "/Clients";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSourceSwitchers() {
|
||||||
|
["left", "right"].forEach((pane) => {
|
||||||
|
const container = document.getElementById(`${pane}-source-switcher`);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeSource = sourceRootForPath(paneState(pane).currentPath);
|
||||||
|
[...container.querySelectorAll("button[data-source-path]")].forEach((button) => {
|
||||||
|
const isActive = button.dataset.sourcePath === activeSource;
|
||||||
|
button.disabled = isActive;
|
||||||
|
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSourceSwitchers() {
|
||||||
|
["left", "right"].forEach((pane) => {
|
||||||
|
const toolbar = document.querySelector(`#${pane}-pane .pane-topbar`);
|
||||||
|
if (!toolbar || document.getElementById(`${pane}-source-switcher`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = `${pane}-source-switcher`;
|
||||||
|
container.className = "pane-source-switcher";
|
||||||
|
VIRTUAL_SOURCES.forEach((source) => {
|
||||||
|
const button = createButton(source.label, () => {
|
||||||
|
setActivePane(pane);
|
||||||
|
navigateTo(pane, source.path);
|
||||||
|
});
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.sourcePath = source.path;
|
||||||
|
container.append(button);
|
||||||
|
});
|
||||||
|
toolbar.prepend(container);
|
||||||
|
});
|
||||||
|
syncSourceSwitchers();
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
document.getElementById("status").textContent = msg;
|
document.getElementById("status").textContent = msg;
|
||||||
}
|
}
|
||||||
@@ -716,6 +770,7 @@ function openContextMenu(pane, entry, event) {
|
|||||||
const items = selectedPathsSet.has(entry.path)
|
const items = selectedPathsSet.has(entry.path)
|
||||||
? selectedItems.map((item) => ({ ...item }))
|
? selectedItems.map((item) => ({ ...item }))
|
||||||
: [selectedEntryFromItem(entry)];
|
: [selectedEntryFromItem(entry)];
|
||||||
|
const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path));
|
||||||
|
|
||||||
contextMenuState.open = true;
|
contextMenuState.open = true;
|
||||||
contextMenuState.pane = pane;
|
contextMenuState.pane = pane;
|
||||||
@@ -723,26 +778,28 @@ function openContextMenu(pane, entry, event) {
|
|||||||
contextMenuState.anchorPath = entry.path;
|
contextMenuState.anchorPath = entry.path;
|
||||||
|
|
||||||
const isMulti = items.length > 1;
|
const isMulti = items.length > 1;
|
||||||
const openableSingle = items.length === 1 && isOpenableSelection(items[0]);
|
const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]);
|
||||||
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
|
const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]);
|
||||||
const downloadableSelection = items.length > 0;
|
const downloadableSelection = items.length > 0 && !remoteSelection;
|
||||||
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
|
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
|
||||||
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
|
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
|
||||||
elements.openButton.classList.toggle("hidden", isMulti);
|
elements.openButton.classList.toggle("hidden", isMulti);
|
||||||
elements.openButton.disabled = !openableSingle;
|
elements.openButton.disabled = !openableSingle;
|
||||||
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");
|
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection);
|
||||||
elements.editButton.disabled = !editableSingle;
|
elements.editButton.disabled = !editableSingle;
|
||||||
elements.downloadButton.classList.remove("hidden");
|
elements.downloadButton.classList.toggle("hidden", remoteSelection);
|
||||||
elements.downloadButton.disabled = !downloadableSelection;
|
elements.downloadButton.disabled = !downloadableSelection;
|
||||||
elements.renameButton.classList.toggle("hidden", isMulti);
|
elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);
|
||||||
elements.duplicateButton.classList.remove("hidden");
|
elements.duplicateButton.classList.remove("hidden");
|
||||||
elements.duplicateButton.disabled = items.length === 0;
|
elements.duplicateButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.copyButton.classList.remove("hidden");
|
elements.copyButton.classList.remove("hidden");
|
||||||
elements.copyButton.disabled = items.length === 0;
|
elements.copyButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.moveButton.classList.remove("hidden");
|
elements.moveButton.classList.remove("hidden");
|
||||||
|
elements.moveButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.deleteButton.classList.remove("hidden");
|
elements.deleteButton.classList.remove("hidden");
|
||||||
elements.propertiesButton.classList.remove("hidden");
|
elements.deleteButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.propertiesButton.disabled = items.length === 0;
|
elements.propertiesButton.classList.toggle("hidden", remoteSelection);
|
||||||
|
elements.propertiesButton.disabled = remoteSelection || items.length === 0;
|
||||||
|
|
||||||
const menuWidth = 220;
|
const menuWidth = 220;
|
||||||
const menuHeight = 120;
|
const menuHeight = 120;
|
||||||
@@ -2050,12 +2107,17 @@ function updateActionButtons() {
|
|||||||
const hasSelection = count > 0;
|
const hasSelection = count > 0;
|
||||||
const exactlyOne = count === 1;
|
const exactlyOne = count === 1;
|
||||||
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
|
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
|
||||||
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles;
|
const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath);
|
||||||
document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
|
document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles;
|
||||||
document.getElementById("rename-btn").disabled = !exactlyOne;
|
document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
|
||||||
document.getElementById("delete-btn").disabled = !hasSelection;
|
document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne;
|
||||||
document.getElementById("copy-btn").disabled = !hasSelection;
|
document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
document.getElementById("move-btn").disabled = !hasSelection;
|
document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
|
document.getElementById("move-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
|
document.getElementById("mkdir-btn").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-btn").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-menu-toggle").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-folder-btn").disabled = remoteBrowse;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableSelection(item) {
|
function isEditableSelection(item) {
|
||||||
@@ -2208,7 +2270,7 @@ function currentParentPath(path) {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (normalized === "/Volumes") {
|
if (normalized === "/Volumes" || normalized === "/Clients") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (normalized.startsWith("/")) {
|
if (normalized.startsWith("/")) {
|
||||||
@@ -2287,16 +2349,17 @@ function renderBreadcrumbs(pane, path) {
|
|||||||
const isHostPath = normalized.startsWith("/");
|
const isHostPath = normalized.startsWith("/");
|
||||||
const parts = normalized.split("/").filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
if (isHostPath) {
|
if (isHostPath) {
|
||||||
|
const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes";
|
||||||
const rootCrumb = createButton("/", () => {
|
const rootCrumb = createButton("/", () => {
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, "/Volumes");
|
navigateTo(pane, rootTarget);
|
||||||
});
|
});
|
||||||
rootCrumb.type = "button";
|
rootCrumb.type = "button";
|
||||||
rootCrumb.onclick = (ev) => {
|
rootCrumb.onclick = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, "/Volumes");
|
navigateTo(pane, rootTarget);
|
||||||
};
|
};
|
||||||
nav.append(rootCrumb);
|
nav.append(rootCrumb);
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
@@ -2619,6 +2682,7 @@ async function loadBrowsePane(pane) {
|
|||||||
});
|
});
|
||||||
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
|
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
|
||||||
model.currentPath = data.path;
|
model.currentPath = data.path;
|
||||||
|
syncSourceSwitchers();
|
||||||
renderBreadcrumbs(pane, data.path);
|
renderBreadcrumbs(pane, data.path);
|
||||||
|
|
||||||
const visibleItems = [];
|
const visibleItems = [];
|
||||||
@@ -2682,6 +2746,8 @@ function navigateTo(pane, path) {
|
|||||||
model.currentRowIndex = 0;
|
model.currentRowIndex = 0;
|
||||||
clearSelectionAnchor(pane);
|
clearSelectionAnchor(pane);
|
||||||
setSelectedItem(pane, null);
|
setSelectedItem(pane, null);
|
||||||
|
syncSourceSwitchers();
|
||||||
|
updateActionButtons();
|
||||||
loadBrowsePane(pane);
|
loadBrowsePane(pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5305,6 +5371,7 @@ async function init() {
|
|||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
applyTheme("default", "dark");
|
applyTheme("default", "dark");
|
||||||
setActivePane("left");
|
setActivePane("left");
|
||||||
|
ensureSourceSwitchers();
|
||||||
setupEvents();
|
setupEvents();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
||||||
|
|||||||
Reference in New Issue
Block a user