From 6fe48219261a3c2bd937a0054310f5c7082dbe3a Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Tue, 9 Jun 2026 11:58:58 +1000 Subject: [PATCH] Add dashboard file browser paths --- hermes_cli/web_server.py | 318 +++++++++++++++++ tests/hermes_cli/test_web_server_files.py | 246 +++++++++++++ web/src/App.tsx | 5 + web/src/lib/api.ts | 64 ++++ web/src/pages/FilesPage.tsx | 406 ++++++++++++++++++++++ 5 files changed, 1039 insertions(+) create mode 100644 tests/hermes_cli/test_web_server_files.py create mode 100644 web/src/pages/FilesPage.tsx diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 001c3fed1d..e1f1c62051 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -20,9 +20,11 @@ import hmac import importlib.util import json import logging +import mimetypes import os import re import secrets +import shutil import stat import subprocess import sys @@ -657,6 +659,21 @@ class AudioTranscriptionRequest(BaseModel): mime_type: Optional[str] = None +class ManagedFileUpload(BaseModel): + path: str + data_url: str + overwrite: bool = True + + +class ManagedDirectoryCreate(BaseModel): + path: str + + +class ManagedFileDelete(BaseModel): + path: str + recursive: bool = False + + _AUDIO_MIME_EXTENSIONS: Dict[str, str] = { "audio/aac": ".aac", "audio/flac": ".flac", @@ -819,6 +836,16 @@ _MEDIA_CONTENT_TYPES = { ".ico": "image/x-icon", } _MEDIA_MAX_BYTES = 25 * 1024 * 1024 +_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT" +_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024 +_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data") + + +@dataclass(frozen=True) +class ManagedFilesPolicy: + default_path: Path + locked_root: Path | None + can_change_path: bool def _media_serve_roots() -> list[Path]: @@ -874,6 +901,297 @@ async def get_media(path: str): return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"} +def _canonical_path(path: Path, *, require_exists: bool = False) -> Path: + try: + return path.expanduser().resolve(strict=require_exists) + except FileNotFoundError: + if require_exists: + raise HTTPException(status_code=404, detail="Path not found") + raise + except (OSError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + + +def _ensure_managed_root(raw_path: str | Path) -> Path: + root = Path(raw_path).expanduser() + try: + root.mkdir(parents=True, exist_ok=True) + resolved = root.resolve() + except (OSError, RuntimeError) as exc: + raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}") + if not resolved.is_dir(): + raise HTTPException(status_code=500, detail="Managed files root is not a directory") + return resolved + + +def _path_is_under(root: Path, target: Path) -> bool: + return target == root or root in target.parents + + +def _path_text(raw_path: str | None) -> str: + text = str(raw_path or "").strip() + if "\x00" in text: + raise HTTPException(status_code=400, detail="Invalid path") + return text + + +def _local_dashboard_request(request: Request) -> bool: + if getattr(request.app.state, "auth_required", False): + return False + host = (request.url.hostname or "").lower() + client_host = (request.client.host if request.client else "").lower() + local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"} + return host in local_hosts or client_host in local_hosts + + +def _default_hermes_root_is_opt_data() -> bool: + raw = os.environ.get("HERMES_HOME", "").strip() + if not raw: + return False + try: + from hermes_constants import get_default_hermes_root + + root = get_default_hermes_root().expanduser().resolve(strict=False) + except (OSError, RuntimeError): + root = Path(raw).expanduser().resolve(strict=False) + return root == _HOSTED_MANAGED_FILES_ROOT + + +def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: + raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip() + if raw_forced_root: + root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root)) + return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) + + if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data(): + root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT + return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) + + home = _canonical_path(Path.home()) + return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True) + + +def _resolve_managed_path( + raw_path: str | None, + request: Request, + *, + for_write: bool = False, +) -> tuple[ManagedFilesPolicy, Path, str]: + policy = _managed_files_policy(request) + text = _path_text(raw_path) + root = policy.locked_root + + if root is not None and (not text or text in {".", "/"}): + candidate = root + elif not text: + candidate = policy.default_path + else: + candidate = Path(text).expanduser() + if root is not None and not candidate.is_absolute(): + if any(part == ".." for part in candidate.parts): + raise HTTPException(status_code=400, detail="Path cannot contain '..'") + candidate = root / candidate + elif not candidate.is_absolute(): + raise HTTPException(status_code=400, detail="Path must be absolute") + + if ".." in candidate.parts: + raise HTTPException(status_code=400, detail="Path cannot contain '..'") + + if for_write and not candidate.exists(): + parent = _canonical_path(candidate.parent) + resolved = parent / candidate.name + else: + resolved = _canonical_path(candidate, require_exists=not for_write) + + if root is not None and not _path_is_under(root, resolved): + raise HTTPException(status_code=403, detail="Path outside managed files root") + + return policy, resolved, str(resolved) + + +def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]: + locked_root = str(policy.locked_root) if policy.locked_root is not None else None + return { + "root": locked_root, + "locked_root": locked_root, + "can_change_path": policy.can_change_path, + } + + +def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]: + try: + resolved = target.resolve() + except (OSError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved): + raise HTTPException(status_code=403, detail="Path outside managed files root") + + try: + st = resolved.stat() + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}") + + is_dir = resolved.is_dir() + mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream") + return { + "name": target.name or resolved.name or str(resolved), + "path": str(resolved), + "is_directory": is_dir, + "size": None if is_dir else st.st_size, + "mtime": st.st_mtime, + "mime_type": mime_type, + } + + +def _decode_data_url(data_url: str) -> tuple[bytes, str]: + text = (data_url or "").strip() + if not text.startswith("data:") or "," not in text: + raise HTTPException(status_code=400, detail="Upload payload must be a data URL") + header, encoded = text.split(",", 1) + mime_type = header[5:].split(";", 1)[0] or "application/octet-stream" + if ";base64" not in header: + raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded") + try: + data = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="Upload payload is not valid base64") + if len(data) > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + return data, mime_type + + +@app.get("/api/files") +async def list_managed_files(request: Request, path: Optional[str] = None): + policy, target, display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="Path not found") + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + + try: + entries = [_managed_file_entry(policy, child) for child in target.iterdir()] + except PermissionError: + raise HTTPException(status_code=403, detail="Directory is not readable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}") + + entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower())) + locked_root = policy.locked_root + parent = None + if target.parent != target and (locked_root is None or target != locked_root): + parent = str(target.parent) + return { + "path": display_path, + "parent": parent, + "entries": entries, + **_managed_response_meta(policy), + } + + +@app.get("/api/files/read") +async def read_managed_file(request: Request, path: str): + policy, target, display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="File not found") + if not target.is_file(): + raise HTTPException(status_code=400, detail="Path is not a file") + + try: + size = target.stat().st_size + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") + if size > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + + mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + try: + encoded = base64.b64encode(target.read_bytes()).decode("ascii") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read file: {exc}") + + return { + "name": target.name, + "path": display_path, + "size": size, + "mime_type": mime_type, + "data_url": f"data:{mime_type};base64,{encoded}", + **_managed_response_meta(policy), + } + + +@app.post("/api/files/upload") +async def upload_managed_file(payload: ManagedFileUpload, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) + if target.exists() and target.is_dir(): + raise HTTPException(status_code=409, detail="A directory already exists at that path") + if target.exists() and not payload.overwrite: + raise HTTPException(status_code=409, detail="File already exists") + + data, _mime_type = _decode_data_url(payload.data_url) + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + except PermissionError: + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") + + return { + "ok": True, + "entry": _managed_file_entry(policy, target), + "path": display_path, + **_managed_response_meta(policy), + } + + +@app.post("/api/files/mkdir") +async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) + if target.exists() and not target.is_dir(): + raise HTTPException(status_code=409, detail="A file already exists at that path") + + try: + target.mkdir(parents=True, exist_ok=True) + except PermissionError: + raise HTTPException(status_code=403, detail="Directory is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}") + + return { + "ok": True, + "entry": _managed_file_entry(policy, target), + "path": display_path, + **_managed_response_meta(policy), + } + + +@app.delete("/api/files") +async def delete_managed_file(payload: ManagedFileDelete, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request) + if policy.locked_root is not None and target == policy.locked_root: + raise HTTPException(status_code=400, detail="Cannot delete the managed files root") + if target.parent == target: + raise HTTPException(status_code=400, detail="Cannot delete the filesystem root") + if not target.exists(): + raise HTTPException(status_code=404, detail="Path not found") + + try: + if target.is_dir(): + if payload.recursive: + shutil.rmtree(target) + else: + target.rmdir() + else: + target.unlink() + except OSError as exc: + status_code = 409 if target.is_dir() and not payload.recursive else 500 + raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}") + + return {"ok": True, "path": display_path, **_managed_response_meta(policy)} + + @app.get("/api/status") async def get_status(): current_ver, latest_ver = check_config_version() diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py new file mode 100644 index 0000000000..16b5538dd8 --- /dev/null +++ b/tests/hermes_cli/test_web_server_files.py @@ -0,0 +1,246 @@ +"""Tests for the dashboard-managed file browser API.""" + +from types import SimpleNamespace + +import pytest +from starlette.testclient import TestClient + +from hermes_cli import web_server + + +def _client_with_app_state(): + prev_auth_required = getattr(web_server.app.state, "auth_required", None) + prev_bound_host = getattr(web_server.app.state, "bound_host", None) + web_server.app.state.auth_required = False + web_server.app.state.bound_host = None + + client = TestClient(web_server.app) + client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN + return client, prev_auth_required, prev_bound_host + + +def _restore_app_state(prev_auth_required, prev_bound_host): + if prev_auth_required is None: + delattr(web_server.app.state, "auth_required") + else: + web_server.app.state.auth_required = prev_auth_required + if prev_bound_host is None: + if hasattr(web_server.app.state, "bound_host"): + delattr(web_server.app.state, "bound_host") + else: + web_server.app.state.bound_host = prev_bound_host + + +def _close_client(client): + close = getattr(client, "close", None) + if close is not None: + close() + + +@pytest.fixture +def forced_files_client(monkeypatch, tmp_path): + root = tmp_path / "data" + monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root)) + + client, prev_auth_required, prev_bound_host = _client_with_app_state() + try: + yield client, root + finally: + _close_client(client) + _restore_app_state(prev_auth_required, prev_bound_host) + + +@pytest.fixture +def local_files_client(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("HOME", str(home)) + + client, prev_auth_required, prev_bound_host = _client_with_app_state() + try: + yield client, home + finally: + _close_client(client) + _restore_app_state(prev_auth_required, prev_bound_host) + + +def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client): + client, root = forced_files_client + file_path = root / "out" / "hello.txt" + + created = client.post( + "/api/files/upload", + json={ + "path": str(file_path), + "data_url": "data:text/plain;base64,aGVsbG8=", + }, + ) + assert created.status_code == 200 + assert created.json()["entry"]["path"] == str(file_path) + assert created.json()["locked_root"] == str(root) + assert created.json()["can_change_path"] is False + assert file_path.read_text() == "hello" + + listing = client.get("/api/files", params={"path": str(root / "out")}) + assert listing.status_code == 200 + assert listing.json()["path"] == str(root / "out") + assert listing.json()["parent"] == str(root) + assert listing.json()["entries"] == [ + { + "name": "hello.txt", + "path": str(file_path), + "is_directory": False, + "size": 5, + "mtime": pytest.approx(file_path.stat().st_mtime), + "mime_type": "text/plain", + } + ] + + read = client.get("/api/files/read", params={"path": str(file_path)}) + assert read.status_code == 200 + assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8=" + + deleted = client.request( + "DELETE", + "/api/files", + json={"path": str(file_path)}, + ) + assert deleted.status_code == 200 + assert not file_path.exists() + + +def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client): + client, root = forced_files_client + runs_path = root / "runs" + checkpoints_path = runs_path / "checkpoints" + + created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)}) + assert created.status_code == 200 + assert checkpoints_path.is_dir() + + listing = client.get("/api/files", params={"path": str(runs_path)}) + assert listing.status_code == 200 + assert listing.json()["entries"][0]["path"] == str(checkpoints_path) + assert listing.json()["entries"][0]["is_directory"] is True + + non_recursive = client.request( + "DELETE", + "/api/files", + json={"path": str(runs_path), "recursive": False}, + ) + assert non_recursive.status_code == 409 + + recursive = client.request( + "DELETE", + "/api/files", + json={"path": str(runs_path), "recursive": True}, + ) + assert recursive.status_code == 200 + assert not runs_path.exists() + + +def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path): + client, root = forced_files_client + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("do not leak") + + traversal = client.get("/api/files", params={"path": "../outside"}) + assert traversal.status_code == 400 + + outside_absolute = client.get("/api/files", params={"path": str(outside)}) + assert outside_absolute.status_code == 403 + + root_delete = client.request( + "DELETE", + "/api/files", + json={"path": str(root), "recursive": True}, + ) + assert root_delete.status_code == 400 + + root.mkdir(exist_ok=True) + link = root / "escape" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("filesystem does not allow directory symlinks") + + escaped = client.get("/api/files", params={"path": str(link)}) + assert escaped.status_code == 403 + + +def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path): + client, home = local_files_client + (home / "home.txt").write_text("home") + + default_listing = client.get("/api/files") + assert default_listing.status_code == 200 + assert default_listing.json()["path"] == str(home) + assert default_listing.json()["locked_root"] is None + assert default_listing.json()["can_change_path"] is True + assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt") + + other = tmp_path / "other" + other.mkdir() + (other / "other.txt").write_text("other") + + other_listing = client.get("/api/files", params={"path": str(other)}) + assert other_listing.status_code == 200 + assert other_listing.json()["path"] == str(other) + assert other_listing.json()["parent"] == str(tmp_path) + assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt") + + +def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client): + client, home = local_files_client + folder = home / "workspace" + file_path = folder / "note.txt" + + created_folder = client.post("/api/files/mkdir", json={"path": str(folder)}) + assert created_folder.status_code == 200 + assert created_folder.json()["locked_root"] is None + assert created_folder.json()["can_change_path"] is True + assert folder.is_dir() + + uploaded = client.post( + "/api/files/upload", + json={ + "path": str(file_path), + "data_url": "data:text/plain;base64,bG9jYWw=", + }, + ) + assert uploaded.status_code == 200 + assert file_path.read_text() == "local" + + read = client.get("/api/files/read", params={"path": str(file_path)}) + assert read.status_code == 200 + assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw=" + + deleted = client.request( + "DELETE", + "/api/files", + json={"path": str(folder), "recursive": True}, + ) + assert deleted.status_code == 200 + assert not folder.exists() + + +def test_hosted_policy_locks_to_opt_data(monkeypatch): + monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) + monkeypatch.setenv("HERMES_HOME", "/opt/data") + client, prev_auth_required, prev_bound_host = _client_with_app_state() + try: + request = SimpleNamespace( + app=web_server.app, + client=SimpleNamespace(host="127.0.0.1"), + url=SimpleNamespace(hostname="127.0.0.1"), + ) + policy = web_server._managed_files_policy(request, create_root=False) + finally: + _restore_app_state(prev_auth_required, prev_bound_host) + client.close() + + assert str(policy.locked_root) == "/opt/data" + assert policy.can_change_path is False diff --git a/web/src/App.tsx b/web/src/App.tsx index affc0991e9..52108a22ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,6 +26,7 @@ import { Database, Download, Eye, + FolderOpen, FileText, Globe, Heart, @@ -68,6 +69,7 @@ import type { SystemAction } from "@/contexts/system-actions-context"; import ConfigPage from "@/pages/ConfigPage"; import DocsPage from "@/pages/DocsPage"; import EnvPage from "@/pages/EnvPage"; +import FilesPage from "@/pages/FilesPage"; import SessionsPage from "@/pages/SessionsPage"; import LogsPage from "@/pages/LogsPage"; import AnalyticsPage from "@/pages/AnalyticsPage"; @@ -125,6 +127,7 @@ const CHAT_NAV_ITEM: NavItem = { const BUILTIN_ROUTES_CORE: Record = { "/": RootRedirect, "/sessions": SessionsPage, + "/files": FilesPage, "/analytics": AnalyticsPage, "/models": ModelsPage, "/logs": LogsPage, @@ -158,6 +161,7 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Sessions", icon: MessageSquare, }, + { path: "/files", label: "Files", icon: FolderOpen }, { path: "/analytics", labelKey: "analytics", @@ -196,6 +200,7 @@ const ICON_MAP: Record> = { Clock, Cpu, FileText, + FolderOpen, KeyRound, MessageSquare, Package, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2d7a76f777..37a8f15eba 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -325,6 +325,32 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ older_than_days, source }), }), + listFiles: (path?: string) => { + const query = path ? `?path=${encodeURIComponent(path)}` : ""; + return fetchJSON(`/api/files${query}`); + }, + readFile: (path: string) => + fetchJSON( + `/api/files/read?path=${encodeURIComponent(path)}`, + ), + uploadFile: (path: string, dataUrl: string, overwrite = true) => + fetchJSON("/api/files/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, data_url: dataUrl, overwrite }), + }), + createDirectory: (path: string) => + fetchJSON("/api/files/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }), + deleteFile: (path: string, recursive = false) => + fetchJSON<{ ok: boolean; path: string }>("/api/files", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, recursive }), + }), getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => { const qs = new URLSearchParams(); if (params.file) qs.set("file", params.file); @@ -1515,6 +1541,44 @@ export interface LogsResponse { lines: string[]; } +export interface ManagedFileEntry { + name: string; + path: string; + is_directory: boolean; + size: number | null; + mtime: number; + mime_type: string | null; +} + +export interface ManagedFilesResponse { + root: string | null; + path: string; + parent: string | null; + locked_root: string | null; + can_change_path: boolean; + entries: ManagedFileEntry[]; +} + +export interface ManagedFileReadResponse { + name: string; + path: string; + size: number; + mime_type: string; + data_url: string; + root: string | null; + locked_root: string | null; + can_change_path: boolean; +} + +export interface ManagedFileWriteResponse { + ok: boolean; + path: string; + entry: ManagedFileEntry; + root: string | null; + locked_root: string | null; + can_change_path: boolean; +} + export interface AnalyticsDailyEntry { day: string; input_tokens: number; diff --git a/web/src/pages/FilesPage.tsx b/web/src/pages/FilesPage.tsx new file mode 100644 index 0000000000..d2947e098e --- /dev/null +++ b/web/src/pages/FilesPage.tsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ArrowUp, + Download, + FileIcon, + Folder, + FolderOpen, + FolderPlus, + RefreshCw, + Trash2, + Upload, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Card, CardContent } from "@nous-research/ui/ui/components/card"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Toast } from "@nous-research/ui/ui/components/toast"; +import { useToast } from "@nous-research/ui/hooks/use-toast"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { usePageHeader } from "@/contexts/usePageHeader"; +import { api } from "@/lib/api"; +import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api"; +import { PluginSlot } from "@/plugins"; + +const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function joinPath(base: string, name: string): string { + const cleanName = name.trim().replace(/^[\\/]+/, ""); + if (!cleanName) return base; + const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/"; + if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`; + return `${base}${separator}${cleanName}`; +} + +function formatBytes(size: number | null): string { + if (size === null) return "-"; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function readAsDataUrl(file: globalThis.File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("Could not read file")); + }); + reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file"))); + reader.readAsDataURL(file); + }); +} + +function downloadDataUrl(dataUrl: string, name: string) { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = name || "download"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function displayPath(path: string | null | undefined): string { + return path?.trim() || "Files"; +} + +export default function FilesPage() { + const { toast, showToast } = useToast(); + const { setAfterTitle, setEnd } = usePageHeader(); + const fileInputRef = useRef(null); + const [currentPath, setCurrentPath] = useState(undefined); + const [pathInput, setPathInput] = useState(""); + const [listing, setListing] = useState(null); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(false); + const [folderName, setFolderName] = useState(""); + const [pendingDelete, setPendingDelete] = useState(null); + const [error, setError] = useState(null); + + const activePath = listing?.path ?? currentPath ?? ""; + const canChangePath = listing?.can_change_path ?? false; + const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath); + + const load = useCallback( + async (path = currentPath) => { + setLoading(true); + setError(null); + try { + const result = await api.listFiles(path); + setListing(result); + setCurrentPath(result.path); + setPathInput(result.path); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, + [currentPath], + ); + + useEffect(() => { + // Existing dashboard data pages fetch from effects; keep this local and explicit + // until the shared lint profile is updated for async page loaders. + // eslint-disable-next-line react-hooks/set-state-in-effect + void load(currentPath); + }, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + setAfterTitle( + + {headerPath} + , + ); + setEnd( +
+ +
, + ); + return () => { + setAfterTitle(null); + setEnd(null); + }; + }, [headerPath, load, loading, setAfterTitle, setEnd]); + + const openDirectory = (entry: ManagedFileEntry) => { + if (entry.is_directory) { + setCurrentPath(entry.path); + } + }; + + const goToPath = async () => { + const nextPath = pathInput.trim(); + if (!nextPath) { + showToast("Path required", "error"); + return; + } + await load(nextPath); + }; + + const createDirectory = async () => { + const name = folderName.trim(); + if (!name) { + showToast("Folder name required", "error"); + return; + } + setCreating(true); + try { + await api.createDirectory(joinPath(activePath, name)); + setFolderName(""); + showToast("Folder created", "success"); + await load(); + } catch (e) { + showToast(`Create failed: ${e}`, "error"); + } finally { + setCreating(false); + } + }; + + const uploadFiles = async (files: FileList | null) => { + if (!files?.length) return; + setUploading(true); + try { + for (const file of Array.from(files)) { + const dataUrl = await readAsDataUrl(file); + await api.uploadFile(joinPath(activePath, file.name), dataUrl, true); + } + showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success"); + await load(); + } catch (e) { + showToast(`Upload failed: ${e}`, "error"); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const downloadFile = async (entry: ManagedFileEntry) => { + if (entry.is_directory) return; + try { + const file = await api.readFile(entry.path); + downloadDataUrl(file.data_url, file.name); + } catch (e) { + showToast(`Download failed: ${e}`, "error"); + } + }; + + const confirmDelete = async () => { + if (!pendingDelete) return; + setDeleting(true); + try { + await api.deleteFile(pendingDelete.path, pendingDelete.is_directory); + showToast("Deleted", "success"); + setPendingDelete(null); + await load(); + } catch (e) { + showToast(`Delete failed: ${e}`, "error"); + } finally { + setDeleting(false); + } + }; + + return ( +
+ + + void uploadFiles(event.currentTarget.files)} + /> + +
+ {canChangePath ? ( +
{ + event.preventDefault(); + void goToPath(); + }} + > + setPathInput(event.target.value)} + aria-label="Path" + placeholder="Path" + className="h-9 min-w-0 flex-1 font-mono" + /> + +
+ ) : ( +
+ {activePath} +
+ )} +
+ + setFolderName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void createDirectory(); + }} + placeholder="New folder" + className="h-9 w-44" + /> + +
+
+ + + + {error && ( +
+ {error} +
+ )} + +
+ Name + Size + Modified + Actions +
+ + {listing?.parent && ( + + )} + + {loading && !listing ? ( +
+ + Loading files... +
+ ) : listing && listing.entries.length === 0 ? ( +
No files
+ ) : ( + listing?.entries.map((entry) => ( +
+ + {formatBytes(entry.size)} + + {Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"} + + + {entry.is_directory ? ( + + ) : ( + + )} + + +
+ )) + )} +
+
+ + + + setPendingDelete(null)} + onConfirm={() => void confirmDelete()} + title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"} + description={ + pendingDelete?.is_directory + ? "This removes the folder and everything inside it." + : "This removes the file." + } + /> +
+ ); +}