Add dashboard file browser paths

This commit is contained in:
Shannon Sands 2026-06-09 11:58:58 +10:00 committed by Teknium
parent d986bb0c6d
commit 6fe4821926
5 changed files with 1039 additions and 0 deletions

View file

@ -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()

View file

@ -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

View file

@ -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<string, ComponentType> = {
"/": 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<string, ComponentType<{ className?: string }>> = {
Clock,
Cpu,
FileText,
FolderOpen,
KeyRound,
MessageSquare,
Package,

View file

@ -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<ManagedFilesResponse>(`/api/files${query}`);
},
readFile: (path: string) =>
fetchJSON<ManagedFileReadResponse>(
`/api/files/read?path=${encodeURIComponent(path)}`,
),
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
}),
createDirectory: (path: string) =>
fetchJSON<ManagedFileWriteResponse>("/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;

406
web/src/pages/FilesPage.tsx Normal file
View file

@ -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<string> {
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<HTMLInputElement | null>(null);
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [pathInput, setPathInput] = useState("");
const [listing, setListing] = useState<ManagedFilesResponse | null>(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<ManagedFileEntry | null>(null);
const [error, setError] = useState<string | null>(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(
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
{headerPath}
</Badge>,
);
setEnd(
<div className="flex items-center gap-2">
<Button
ghost
size="icon"
type="button"
onClick={() => void load()}
disabled={loading}
aria-label="Refresh files"
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>,
);
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 (
<div className="flex min-w-0 max-w-full flex-col gap-4">
<Toast toast={toast} />
<PluginSlot name="files:top" />
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => void uploadFiles(event.currentTarget.files)}
/>
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
{canChangePath ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void goToPath();
}}
>
<Input
value={pathInput}
onChange={(event) => setPathInput(event.target.value)}
aria-label="Path"
placeholder="Path"
className="h-9 min-w-0 flex-1 font-mono"
/>
<Button type="submit" size="sm" outlined className="uppercase">
Go
</Button>
</form>
) : (
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
{activePath}
</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !activePath}
size="sm"
outlined
className="uppercase"
prefix={uploading ? <Spinner /> : <Upload />}
>
Upload
</Button>
<Input
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void createDirectory();
}}
placeholder="New folder"
className="h-9 w-44"
/>
<Button
type="button"
onClick={() => void createDirectory()}
disabled={creating || !activePath}
size="sm"
outlined
className="uppercase"
prefix={creating ? <Spinner /> : <FolderPlus />}
>
Create
</Button>
</div>
</div>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="overflow-x-auto p-0">
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
<span>Name</span>
<span>Size</span>
<span>Modified</span>
<span className="text-right">Actions</span>
</div>
{listing?.parent && (
<button
type="button"
onClick={() => setCurrentPath(listing.parent ?? undefined)}
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
>
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
..
</span>
<span />
<span />
<span />
</button>
)}
{loading && !listing ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Spinner />
Loading files...
</div>
) : listing && listing.entries.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
) : (
listing?.entries.map((entry) => (
<div
key={entry.path}
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
>
<button
type="button"
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
>
{entry.is_directory ? (
<Folder className="h-4 w-4 shrink-0 text-warning" />
) : (
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
)}
<span className="truncate">{entry.name}</span>
</button>
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
<span className="truncate text-xs text-text-secondary">
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
</span>
<span className="flex justify-end gap-1">
{entry.is_directory ? (
<Button
ghost
size="icon"
type="button"
onClick={() => openDirectory(entry)}
aria-label={`Open ${entry.name}`}
>
<FolderOpen />
</Button>
) : (
<Button
ghost
size="icon"
type="button"
onClick={() => void downloadFile(entry)}
aria-label={`Download ${entry.name}`}
>
<Download />
</Button>
)}
<Button
ghost
size="icon"
type="button"
onClick={() => setPendingDelete(entry)}
aria-label={`Delete ${entry.name}`}
className="text-destructive hover:text-destructive"
>
<Trash2 />
</Button>
</span>
</div>
))
)}
</CardContent>
</Card>
<PluginSlot name="files:bottom" />
<DeleteConfirmDialog
open={Boolean(pendingDelete)}
loading={deleting}
onCancel={() => 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."
}
/>
</div>
);
}