Add Phase 2 remote browse scaffolding for /Clients

This commit is contained in:
kodi
2026-03-27 11:39:26 +01:00
parent 841318c9e2
commit 4062cbf6c8
15 changed files with 635 additions and 31 deletions
Binary file not shown.
+2
View File
@@ -13,6 +13,7 @@ class Settings:
remote_client_offline_timeout_seconds: int
remote_client_agent_auth_header: str
remote_client_agent_auth_scheme: str
remote_client_agent_auth_token: str
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()
or "Authorization",
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()
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:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
+16 -1
View File
@@ -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.history_service import HistoryService
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.search_service import SearchService
from backend.app.services.settings_service import SettingsService
@@ -83,7 +84,11 @@ def get_archive_artifact_root() -> str:
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:
@@ -172,3 +177,13 @@ async def get_remote_client_service() -> RemoteClientService:
registration_token=settings.remote_client_registration_token,
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,
)
+11 -1
View File
@@ -3,14 +3,24 @@ from __future__ import annotations
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
from backend.app.services.remote_browse_service import RemoteBrowseService
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._filesystem = filesystem
self._remote_browse_service = remote_browse_service
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):
directories = [
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))
def list_clients(self) -> RemoteClientListResponse:
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),
)
self._refresh_stale_statuses()
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
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:
self._require_registration_auth(authorization)
payload = self._normalize_register_request(request)
@@ -123,6 +139,13 @@ class RemoteClientService:
"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
def _to_iso(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
Binary file not shown.
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
import unittest
@@ -11,11 +12,43 @@ import httpx
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.db.remote_client_repository import RemoteClientRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app
from backend.app.security.path_guard import PathGuard
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):
@@ -36,6 +69,12 @@ class BrowseApiGoldenTest(unittest.TestCase):
file_path.write_bytes(b"abc")
second_file = self.second_root / "archive.txt"
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.mkdir()
@@ -43,15 +82,70 @@ class BrowseApiGoldenTest(unittest.TestCase):
hidden_file.write_bytes(b"x")
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).chmod(0o755)
import os
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(
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
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:
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__":
unittest.main()
+86 -19
View File
@@ -141,6 +141,10 @@ const VALID_THEME_FAMILIES = [
"fluent-neon",
];
const VALID_COLOR_MODES = ["dark", "light"];
const VIRTUAL_SOURCES = [
{ path: "/Volumes", label: "Volumes" },
{ path: "/Clients", label: "Clients" },
];
let searchState = {
pane: "left",
path: "/Volumes",
@@ -200,6 +204,56 @@ function activePaneState() {
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) {
document.getElementById("status").textContent = msg;
}
@@ -716,6 +770,7 @@ function openContextMenu(pane, entry, event) {
const items = selectedPathsSet.has(entry.path)
? selectedItems.map((item) => ({ ...item }))
: [selectedEntryFromItem(entry)];
const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path));
contextMenuState.open = true;
contextMenuState.pane = pane;
@@ -723,26 +778,28 @@ function openContextMenu(pane, entry, event) {
contextMenuState.anchorPath = entry.path;
const isMulti = items.length > 1;
const openableSingle = items.length === 1 && isOpenableSelection(items[0]);
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
const downloadableSelection = items.length > 0;
const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]);
const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]);
const downloadableSelection = items.length > 0 && !remoteSelection;
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
elements.openButton.classList.toggle("hidden", isMulti);
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.downloadButton.classList.remove("hidden");
elements.downloadButton.classList.toggle("hidden", remoteSelection);
elements.downloadButton.disabled = !downloadableSelection;
elements.renameButton.classList.toggle("hidden", isMulti);
elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);
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.disabled = items.length === 0;
elements.copyButton.disabled = remoteSelection || items.length === 0;
elements.moveButton.classList.remove("hidden");
elements.moveButton.disabled = remoteSelection || items.length === 0;
elements.deleteButton.classList.remove("hidden");
elements.propertiesButton.classList.remove("hidden");
elements.propertiesButton.disabled = items.length === 0;
elements.deleteButton.disabled = remoteSelection || items.length === 0;
elements.propertiesButton.classList.toggle("hidden", remoteSelection);
elements.propertiesButton.disabled = remoteSelection || items.length === 0;
const menuWidth = 220;
const menuHeight = 120;
@@ -2050,12 +2107,17 @@ function updateActionButtons() {
const hasSelection = count > 0;
const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles;
document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
document.getElementById("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !hasSelection;
document.getElementById("move-btn").disabled = !hasSelection;
const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath);
document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles;
document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne;
document.getElementById("delete-btn").disabled = remoteBrowse || !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) {
@@ -2208,7 +2270,7 @@ function currentParentPath(path) {
if (!normalized) {
return null;
}
if (normalized === "/Volumes") {
if (normalized === "/Volumes" || normalized === "/Clients") {
return null;
}
if (normalized.startsWith("/")) {
@@ -2287,16 +2349,17 @@ function renderBreadcrumbs(pane, path) {
const isHostPath = normalized.startsWith("/");
const parts = normalized.split("/").filter(Boolean);
if (isHostPath) {
const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes";
const rootCrumb = createButton("/", () => {
setActivePane(pane);
navigateTo(pane, "/Volumes");
navigateTo(pane, rootTarget);
});
rootCrumb.type = "button";
rootCrumb.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
setActivePane(pane);
navigateTo(pane, "/Volumes");
navigateTo(pane, rootTarget);
};
nav.append(rootCrumb);
if (parts.length > 0) {
@@ -2619,6 +2682,7 @@ async function loadBrowsePane(pane) {
});
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
model.currentPath = data.path;
syncSourceSwitchers();
renderBreadcrumbs(pane, data.path);
const visibleItems = [];
@@ -2682,6 +2746,8 @@ function navigateTo(pane, path) {
model.currentRowIndex = 0;
clearSelectionAnchor(pane);
setSelectedItem(pane, null);
syncSourceSwitchers();
updateActionButtons();
loadBrowsePane(pane);
}
@@ -5305,6 +5371,7 @@ async function init() {
setError("actions-error", "");
applyTheme("default", "dark");
setActivePane("left");
ensureSourceSwitchers();
setupEvents();
await loadSettings();
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);