Phase 12: file browser (0.12.0)

Add a full host filesystem browser reachable from the sidebar (/files):
breadcrumb navigation, browse-root chips, show-hidden toggle, and a table
with size/permissions/mtime. Text files open in a Monaco editor (language by
extension); binary/oversized files fall back to download. Admins can create
folders/files, rename, delete (recursive for dirs), upload, and save edits;
download is available to all users. Every mutation is audit-logged.

Backend: new services/file_service.py reuses device_service's sandbox helpers
(confined to ALLOWED_BROWSE_ROOTS, mapped via HOST_ROOT_PREFIX) and rejects
path traversal and deleting a browse root. routers/files.py exposes
/api/files/{list,read,download,write,mkdir,touch,rename,upload,DELETE}
(reads: any user; mutations: admin). device_service.browse entries gained
mtime + symlink (non-breaking).

Deployment: ALLOWED_BROWSE_ROOTS + HOST_ROOT_PREFIX are now env-wired in
docker-compose.yml and .env.example, with a commented /:/host_root mount to
browse/manage the real host filesystem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 10:33:38 +00:00
co-authored by Claude Opus 4.8
parent e69c1fa065
commit e3313fb4ac
15 changed files with 1004 additions and 4 deletions
+9
View File
@@ -17,3 +17,12 @@ NOTIFY_WEBHOOKS=
# Throwaway image used to read/write named-volume contents during backups. # Throwaway image used to read/write named-volume contents during backups.
BACKUP_HELPER_IMAGE=alpine:latest BACKUP_HELPER_IMAGE=alpine:latest
# File browser (sidebar) + volume host-path picker.
# ALLOWED_BROWSE_ROOTS: comma-separated paths the browser may reach (sandbox).
# HOST_ROOT_PREFIX: where the host filesystem is mounted inside the backend
# container. Leave empty to browse the container's own filesystem. To browse
# the real host, uncomment the "/:/host_root" volume in docker-compose.yml and
# set HOST_ROOT_PREFIX=/host_root here (mount without :ro to allow edits).
ALLOWED_BROWSE_ROOTS=/,/mnt,/media,/srv,/opt
HOST_ROOT_PREFIX=
+3
View File
@@ -8,6 +8,9 @@ venv/
# Node / frontend # Node / frontend
node_modules/ node_modules/
frontend/dist/ frontend/dist/
*.tsbuildinfo
frontend/vite.config.d.ts
frontend/vite.config.js
# App data / secrets # App data / secrets
data/ data/
+18 -1
View File
@@ -7,7 +7,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup > Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
> network attach) complete. > network attach) + Phase 12 (File browser) complete.
## What works today (Phase 1) ## What works today (Phase 1)
@@ -145,6 +145,23 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
container or connect any container on the host (`POST /api/networks/{id}/connect` container or connect any container on the host (`POST /api/networks/{id}/connect`
/ `/disconnect`). / `/disconnect`).
### Phase 12 — File browser
- **Files page (sidebar)**: a full host filesystem browser with breadcrumb
navigation, clickable browse-root chips, an *Up* control, and a show/hide
hidden-files toggle. Listings show size, permissions and modified time.
- **View & edit**: clicking a text file opens it in a Monaco editor (with syntax
highlighting picked from the extension). Binary and oversized files are
detected and offered as a download instead. Admins can edit and **Save**.
- **Manage** (admin): create folders/files, rename, delete (recursive for
folders), upload files, and download any file. Every mutation is audit-logged.
- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
traversal and deleting a browse root are refused. To reach the real host
filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `upload`,
`download`, `DELETE`).
## Deploying an agent on another host ## Deploying an agent on another host
```bash ```bash
+1 -1
View File
@@ -40,7 +40,7 @@ from services import backup_service, compose_service
logger = logging.getLogger("stackpilot.agent") logger = logging.getLogger("stackpilot.agent")
AGENT_VERSION = "0.11.1" AGENT_VERSION = "0.12.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+3 -1
View File
@@ -20,6 +20,7 @@ from routers import (
backups, backups,
destinations, destinations,
editor, editor,
files,
images, images,
networks, networks,
ports, ports,
@@ -54,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.11.1", lifespan=lifespan) app = FastAPI(title="StackPilot", version="0.12.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -78,6 +79,7 @@ app.include_router(stacks.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(volumes.router) app.include_router(volumes.router)
app.include_router(editor.router) app.include_router(editor.router)
app.include_router(files.router)
app.include_router(images.router) app.include_router(images.router)
app.include_router(ports.router) app.include_router(ports.router)
app.include_router(templates.router) app.include_router(templates.router)
+194
View File
@@ -0,0 +1,194 @@
"""Full host filesystem browser: list, read, edit, manage, up/download.
Listing and reads require an authenticated user; every mutating operation
(write, mkdir, rename, delete, upload) requires admin and is audit-logged.
All paths are sandboxed by :mod:`services.file_service`.
"""
from __future__ import annotations
import os
import tempfile
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
Request,
UploadFile,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlmodel import Session
from auth import get_current_user, require_admin
from database import get_session
from models.user import User
from services import audit_service, device_service, file_service
router = APIRouter(prefix="/api/files", tags=["files"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _guard(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except file_service.BrowseError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# --------------------------------------------------------------------------- #
# Read-only
# --------------------------------------------------------------------------- #
@router.get("/list")
def list_dir(
path: str = Query("/"),
show_hidden: bool = Query(False),
_user: User = Depends(get_current_user),
) -> dict:
return _guard(device_service.browse, path, show_hidden)
@router.get("/read")
def read_file(
path: str = Query(...),
_user: User = Depends(get_current_user),
) -> dict:
return _guard(file_service.read_file, path)
@router.get("/download")
def download(
path: str = Query(...),
_user: User = Depends(get_current_user),
):
real, filename = _guard(file_service.resolve_download, path)
return FileResponse(real, filename=filename, media_type="application/octet-stream")
# --------------------------------------------------------------------------- #
# Mutating (admin only)
# --------------------------------------------------------------------------- #
class WriteBody(BaseModel):
path: str
content: str
class NameBody(BaseModel):
path: str
name: str
class RenameBody(BaseModel):
path: str
new_name: str
@router.put("/write")
def write_file(
body: WriteBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = _guard(file_service.write_file, body.path, body.content)
audit_service.record(
session, user=user.username, action="file.write", target=body.path, ip=_ip(request)
)
return result
@router.post("/mkdir")
def mkdir(
body: NameBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = _guard(file_service.create_dir, body.path, body.name)
audit_service.record(
session, user=user.username, action="file.mkdir", target=result["path"], ip=_ip(request)
)
return result
@router.post("/touch")
def touch(
body: NameBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = _guard(file_service.create_file, body.path, body.name)
audit_service.record(
session, user=user.username, action="file.create", target=result["path"], ip=_ip(request)
)
return result
@router.post("/rename")
def rename(
body: RenameBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = _guard(file_service.rename, body.path, body.new_name)
audit_service.record(
session, user=user.username, action="file.rename",
target=body.path, detail=f"-> {result['path']}", ip=_ip(request),
)
return result
@router.delete("")
def delete(
request: Request,
path: str = Query(...),
recursive: bool = Query(False),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = _guard(file_service.delete, path, recursive)
audit_service.record(
session, user=user.username, action="file.delete", target=path,
detail="recursive" if recursive else None, ip=_ip(request),
)
return result
@router.post("/upload")
async def upload(
request: Request,
path: str = Form(...),
overwrite: bool = Form(False),
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
real = _guard(file_service.upload_target, path, file.filename or "", overwrite)
# Stream to a temp file first, then move into place atomically.
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
try:
while chunk := await file.read(1024 * 1024):
tmp.write(chunk)
tmp.close()
os.replace(tmp.name, real)
except OSError as exc:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
audit_service.record(
session, user=user.username, action="file.upload",
target=path, detail=file.filename, ip=_ip(request),
)
return {"ok": True, "name": file.filename}
+2
View File
@@ -138,6 +138,8 @@ def browse(path: str = "/", show_hidden: bool = False) -> dict:
"type": "dir" if is_dir else "file", "type": "dir" if is_dir else "file",
"size": st.st_size if not is_dir else None, "size": st.st_size if not is_dir else None,
"permissions": oct(st.st_mode & 0o777), "permissions": oct(st.st_mode & 0o777),
"mtime": st.st_mtime,
"symlink": os.path.islink(full_real),
} }
) )
except OSError: except OSError:
+188
View File
@@ -0,0 +1,188 @@
"""Sandboxed host filesystem operations for the web file browser.
All paths are *logical* host paths (what the user sees, e.g. ``/opt/foo``).
They are validated against ``ALLOWED_BROWSE_ROOTS`` and then mapped into the
container's view via ``HOST_ROOT_PREFIX`` before any I/O. Directory listing is
provided by :func:`device_service.browse`; this module adds the read/write,
upload/download and management operations needed for a full browser.
"""
from __future__ import annotations
import os
import shutil
from services.device_service import BrowseError, _is_allowed, _real_root
# Largest file we will load into the in-browser text editor.
MAX_EDIT_BYTES = 2 * 1024 * 1024 # 2 MiB
def _safe_real(path: str) -> str:
"""Validate a logical path against the sandbox and return its real path."""
path = os.path.normpath(path or "/")
if not path.startswith("/"):
raise BrowseError("Path must be absolute")
if not _is_allowed(path):
raise BrowseError("Path is outside the allowed browse roots")
return _real_root(path)
def _child(path: str, name: str) -> str:
"""Return the logical path of ``name`` directly inside ``path``.
``name`` must be a single path component (no separators, no traversal).
"""
if not name or name in (".", "..") or "/" in name or "\\" in name:
raise BrowseError("Invalid name")
base = "" if path == "/" else path.rstrip("/")
return f"{base}/{name}"
def _looks_binary(chunk: bytes) -> bool:
return b"\x00" in chunk
# --------------------------------------------------------------------------- #
# Read / write text
# --------------------------------------------------------------------------- #
def read_file(path: str) -> dict:
real = _safe_real(path)
if not os.path.isfile(real):
raise BrowseError(f"Not a file: {path}")
size = os.path.getsize(real)
if size > MAX_EDIT_BYTES:
return {
"path": path,
"content": None,
"size": size,
"binary": False,
"too_large": True,
}
try:
with open(real, "rb") as fh:
raw = fh.read()
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
if _looks_binary(raw[:8192]):
return {"path": path, "content": None, "size": size, "binary": True, "too_large": False}
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
return {"path": path, "content": None, "size": size, "binary": True, "too_large": False}
return {"path": path, "content": content, "size": size, "binary": False, "too_large": False}
def write_file(path: str, content: str) -> dict:
real = _safe_real(path)
if os.path.isdir(real):
raise BrowseError(f"Is a directory: {path}")
parent = os.path.dirname(real)
if not os.path.isdir(parent):
raise BrowseError("Parent directory does not exist")
try:
with open(real, "w", encoding="utf-8") as fh:
fh.write(content)
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
return {"path": path, "size": os.path.getsize(real)}
# --------------------------------------------------------------------------- #
# Management
# --------------------------------------------------------------------------- #
def create_dir(path: str, name: str) -> dict:
child = _child(path, name)
real = _safe_real(child)
if os.path.exists(real):
raise BrowseError(f"Already exists: {name}")
try:
os.mkdir(real)
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
return {"path": child}
def create_file(path: str, name: str) -> dict:
child = _child(path, name)
real = _safe_real(child)
if os.path.exists(real):
raise BrowseError(f"Already exists: {name}")
try:
with open(real, "x", encoding="utf-8"):
pass
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
return {"path": child}
def rename(path: str, new_name: str) -> dict:
real = _safe_real(path)
if not os.path.lexists(real):
raise BrowseError(f"No such path: {path}")
parent = os.path.dirname(path) or "/"
dest = _child(parent, new_name)
dest_real = _safe_real(dest)
if os.path.lexists(dest_real):
raise BrowseError(f"Already exists: {new_name}")
try:
os.rename(real, dest_real)
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
return {"path": dest}
def delete(path: str, recursive: bool = False) -> dict:
real = _safe_real(path)
norm = os.path.normpath(path)
if norm == "/" or norm in {os.path.normpath(r) for r in _root_paths()}:
raise BrowseError("Refusing to delete a browse root")
if not os.path.lexists(real):
raise BrowseError(f"No such path: {path}")
try:
if os.path.isdir(real) and not os.path.islink(real):
if recursive:
shutil.rmtree(real)
else:
os.rmdir(real) # fails if non-empty
else:
os.remove(real)
except OSError as exc:
raise BrowseError(f"Could not delete {path}: {exc.strerror or exc}") from exc
return {"path": path}
def _root_paths() -> list[str]:
from config import settings
return settings.ALLOWED_BROWSE_ROOTS
# --------------------------------------------------------------------------- #
# Download / upload
# --------------------------------------------------------------------------- #
def resolve_download(path: str) -> tuple[str, str]:
"""Return (real_path, filename) for a file download, or raise BrowseError."""
real = _safe_real(path)
if not os.path.isfile(real):
raise BrowseError(f"Not a file: {path}")
return real, os.path.basename(path)
def upload_target(dir_path: str, filename: str, overwrite: bool = False) -> str:
"""Validate an upload destination and return the real path to write to."""
real_dir = _safe_real(dir_path)
if not os.path.isdir(real_dir):
raise BrowseError(f"Not a directory: {dir_path}")
name = os.path.basename(filename or "")
child = _child(dir_path, name)
real = _safe_real(child)
if os.path.exists(real) and not overwrite:
raise BrowseError(f"Already exists: {name}")
return real
+9
View File
@@ -14,6 +14,11 @@ services:
- NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-} - NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-}
# Throwaway image used to snapshot named-volume contents during backups. # Throwaway image used to snapshot named-volume contents during backups.
- BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest} - BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest}
# File browser (sidebar) + volume host-path picker. ALLOWED_BROWSE_ROOTS
# limits which paths are reachable; HOST_ROOT_PREFIX is where the host
# filesystem is mounted inside this container (see the volume below).
- ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/,/mnt,/media,/srv,/opt}
- HOST_ROOT_PREFIX=${HOST_ROOT_PREFIX:-}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./data:/data - ./data:/data
@@ -22,6 +27,10 @@ services:
# Host devices for GPU/device detection + passthrough (USB/TTY/DRI). # Host devices for GPU/device detection + passthrough (USB/TTY/DRI).
# Read-only; remove if you don't need GPU/device features. # Read-only; remove if you don't need GPU/device features.
- /dev:/dev:ro - /dev:/dev:ro
# File browser: to browse/manage the real host filesystem, mount it here
# and set HOST_ROOT_PREFIX=/host_root in .env. Use :ro for read-only
# browsing, or drop :ro to allow edits/uploads/deletes from the UI.
# - /:/host_root
expose: expose:
- "5008" - "5008"
# Uncomment to expose the API directly (normally proxied by the frontend): # Uncomment to expose the API directly (normally proxied by the frontend):
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.11.1", "version": "0.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -8,6 +8,7 @@ import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor"; import { StackEditor } from "@/pages/StackEditor";
import { RemoteStackDetail } from "@/pages/RemoteStackDetail"; import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
import { Images } from "@/pages/Images"; import { Images } from "@/pages/Images";
import { Files } from "@/pages/Files";
import { Templates } from "@/pages/Templates"; import { Templates } from "@/pages/Templates";
import { Settings } from "@/pages/Settings"; import { Settings } from "@/pages/Settings";
import { Audit } from "@/pages/Audit"; import { Audit } from "@/pages/Audit";
@@ -47,6 +48,7 @@ export default function App() {
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} /> <Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
<Route path="/networks" element={<Networks />} /> <Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} /> <Route path="/images" element={<Images />} />
<Route path="/files" element={<Files />} />
<Route path="/templates" element={<Templates />} /> <Route path="/templates" element={<Templates />} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={<Audit />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
+60
View File
@@ -0,0 +1,60 @@
import api from "./client";
import type { HostPathResult } from "@/types";
export interface FileContent {
path: string;
content: string | null;
size: number;
binary: boolean;
too_large: boolean;
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const filesApi = {
list: (path: string, showHidden = false) =>
api
.get<HostPathResult>("/api/files/list", { params: { path, show_hidden: showHidden } })
.then((r) => r.data),
read: (path: string) =>
api.get<FileContent>("/api/files/read", { params: { path } }).then((r) => r.data),
write: (path: string, content: string) =>
api.put<{ path: string; size: number }>("/api/files/write", { path, content }).then((r) => r.data),
mkdir: (path: string, name: string) =>
api.post<{ path: string }>("/api/files/mkdir", { path, name }).then((r) => r.data),
touch: (path: string, name: string) =>
api.post<{ path: string }>("/api/files/touch", { path, name }).then((r) => r.data),
rename: (path: string, newName: string) =>
api.post<{ path: string }>("/api/files/rename", { path, new_name: newName }).then((r) => r.data),
remove: (path: string, recursive = false) =>
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data),
download: async (path: string, filename: string) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename);
},
upload: async (path: string, file: File, overwrite = false) => {
const form = new FormData();
form.append("path", path);
form.append("overwrite", String(overwrite));
form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
return res.data;
},
};
@@ -4,6 +4,7 @@ import {
Boxes, Boxes,
Network, Network,
Image, Image,
FolderTree,
LayoutTemplate, LayoutTemplate,
ScrollText, ScrollText,
Settings, Settings,
@@ -22,6 +23,7 @@ const nav = [
{ to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network }, { to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image }, { to: "/images", label: "Images", icon: Image },
{ to: "/files", label: "Files", icon: FolderTree },
{ to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit log", icon: ScrollText }, { to: "/audit", label: "Audit log", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
+510
View File
@@ -0,0 +1,510 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Folder,
File as FileIcon,
ArrowUp,
RefreshCw,
FolderPlus,
FilePlus,
Upload,
Download,
Pencil,
Trash2,
Eye,
EyeOff,
Save,
X,
HardDrive,
Link2,
} from "lucide-react";
import { toast } from "sonner";
import Editor from "@monaco-editor/react";
import { Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { filesApi } from "@/api/files";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { formatBytes, relativeTime } from "@/lib/utils";
import type { HostPathEntry } from "@/types";
const LANG_BY_EXT: Record<string, string> = {
yml: "yaml", yaml: "yaml", json: "json", js: "javascript", ts: "typescript",
tsx: "typescript", jsx: "javascript", py: "python", sh: "shell", bash: "shell",
env: "ini", ini: "ini", conf: "ini", cfg: "ini", toml: "ini", md: "markdown",
html: "html", css: "css", xml: "xml", sql: "sql", dockerfile: "dockerfile",
};
function langForName(name: string): string {
const lower = name.toLowerCase();
if (lower === "dockerfile") return "dockerfile";
const ext = lower.includes(".") ? lower.split(".").pop()! : "";
return LANG_BY_EXT[ext] ?? "plaintext";
}
function join(path: string, name: string) {
return `${path === "/" ? "" : path}/${name}`;
}
function crumbs(path: string): { label: string; path: string }[] {
const parts = path.split("/").filter(Boolean);
const out = [{ label: "/", path: "/" }];
let acc = "";
for (const p of parts) {
acc += `/${p}`;
out.push({ label: p, path: acc });
}
return out;
}
export function Files() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const qc = useQueryClient();
const [path, setPath] = useState("/");
const [showHidden, setShowHidden] = useState(false);
const [editing, setEditing] = useState<HostPathEntry | null>(null);
const [renaming, setRenaming] = useState<HostPathEntry | null>(null);
const [deleting, setDeleting] = useState<HostPathEntry | null>(null);
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", path, showHidden],
queryFn: () => filesApi.list(path, showHidden),
});
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
const upload = useMutation({
mutationFn: (file: File) => filesApi.upload(path, file),
onSuccess: (r) => {
toast.success(`Uploaded ${r.name}`);
refresh();
},
onError: (e: unknown) => {
const msg = apiErrorMessage(e);
if (msg.startsWith("Already exists")) {
toast.error(`${msg} — rename or remove the existing file first.`);
} else {
toast.error(msg);
}
},
onSettled: () => {
if (fileInput.current) fileInput.current.value = "";
},
});
const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
onSuccess: () => {
toast.success("Deleted");
setDeleting(null);
refresh();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const download = (e: HostPathEntry) =>
filesApi.download(join(path, e.name), e.name).catch((err) => toast.error(apiErrorMessage(err)));
return (
<div className="space-y-4">
{/* Roots + actions */}
<div className="flex flex-wrap items-center gap-2">
{data?.roots.map((r) => (
<button
key={r}
onClick={() => setPath(r)}
className="inline-flex items-center gap-1 rounded-lg bg-slate-100 px-2.5 py-1 text-xs font-medium hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600"
>
<HardDrive className="h-3.5 w-3.5" />
{r}
</button>
))}
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowHidden((v) => !v)}>
{showHidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showHidden ? "Hide hidden" : "Show hidden"}
</Button>
<Button variant="outline" onClick={refresh}>
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} /> Refresh
</Button>
{isAdmin && (
<>
<Button variant="outline" onClick={() => setNewKind("dir")}>
<FolderPlus className="h-4 w-4" /> Folder
</Button>
<Button variant="outline" onClick={() => setNewKind("file")}>
<FilePlus className="h-4 w-4" /> File
</Button>
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
<Upload className="h-4 w-4" /> Upload
</Button>
<input
ref={fileInput}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
}}
/>
</>
)}
</div>
</div>
{/* Breadcrumb */}
<Card className="p-0">
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 px-3 py-2 text-sm dark:border-slate-700">
<button
onClick={() => data?.parent != null && setPath(data.parent)}
disabled={!data?.parent}
title="Up one level"
className="mr-1 rounded p-1 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
<ArrowUp className="h-4 w-4" />
</button>
{crumbs(path).map((c, i, arr) => (
<span key={c.path} className="flex items-center gap-1">
<button
onClick={() => setPath(c.path)}
className={
i === arr.length - 1
? "font-medium text-slate-800 dark:text-slate-100"
: "text-accent hover:underline dark:text-accent-dark"
}
>
{c.label}
</button>
{i < arr.length - 1 && i > 0 && <span className="text-slate-400">/</span>}
</span>
))}
</div>
{error ? (
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
) : isLoading ? (
<Spinner />
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Size</th>
<th className="px-4 py-2">Permissions</th>
<th className="px-4 py-2">Modified</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.entries.map((e) => (
<tr key={e.name} className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2">
<button
onClick={() =>
e.type === "dir" ? setPath(join(path, e.name)) : setEditing(e)
}
className="flex items-center gap-2 text-left"
>
{e.type === "dir" ? (
<Folder className="h-4 w-4 shrink-0 text-sky-500" />
) : (
<FileIcon className="h-4 w-4 shrink-0 text-slate-400" />
)}
<span className="truncate">{e.name}</span>
{e.symlink && <Link2 className="h-3 w-3 text-slate-400" />}
</button>
</td>
<td className="px-4 py-2 text-slate-500">
{e.type === "dir" ? "—" : formatBytes(e.size ?? 0)}
</td>
<td className="px-4 py-2 font-mono text-xs text-slate-500">{e.permissions}</td>
<td className="px-4 py-2 text-xs text-slate-500">
{e.mtime ? relativeTime(new Date(e.mtime * 1000).toISOString()) : "—"}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{e.type === "file" && (
<button
title="Download"
onClick={() => download(e)}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Download className="h-4 w-4 text-slate-500" />
</button>
)}
{isAdmin && (
<>
<button
title="Rename"
onClick={() => setRenaming(e)}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</button>
<button
title="Delete"
onClick={() => setDeleting(e)}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Trash2 className="h-4 w-4 text-red-500" />
</button>
</>
)}
</div>
</td>
</tr>
))}
{data?.entries.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-slate-500">
Empty directory.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</Card>
{editing && (
<FileEditor
path={join(path, editing.name)}
name={editing.name}
isAdmin={isAdmin}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
{newKind && (
<NewEntryDialog
kind={newKind}
dir={path}
onCancel={() => setNewKind(null)}
onDone={() => {
setNewKind(null);
refresh();
}}
/>
)}
{renaming && (
<RenameDialog
entry={renaming}
dir={path}
onCancel={() => setRenaming(null)}
onDone={() => {
setRenaming(null);
refresh();
}}
/>
)}
{deleting && (
<ConfirmDialog
title={`Delete “${deleting.name}”?`}
message={
deleting.type === "dir"
? "The folder and all of its contents will be permanently removed."
: "This file will be permanently removed."
}
confirmLabel="Delete"
danger
busy={remove.isPending}
onConfirm={() => remove.mutate(deleting)}
onCancel={() => setDeleting(null)}
/>
)}
</div>
);
}
// --------------------------------------------------------------------------- //
function FileEditor({
path,
name,
isAdmin,
onClose,
onSaved,
}: {
path: string;
name: string;
isAdmin: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const theme = useThemeStore((s) => s.theme);
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["file-content", path],
queryFn: () => filesApi.read(path),
});
useEffect(() => {
if (data?.content != null) setContent(data.content);
}, [data]);
const save = useMutation({
mutationFn: () => filesApi.write(path, content),
onSuccess: () => {
toast.success("Saved");
setDirty(false);
onSaved();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const readOnly = !isAdmin;
const unviewable = data && (data.binary || data.too_large);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="flex h-[85vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<div className="min-w-0">
<h2 className="truncate font-semibold">{name}</h2>
<p className="truncate font-mono text-xs text-slate-400">{path}</p>
</div>
<div className="flex items-center gap-2">
{!readOnly && !unviewable && (
<Button onClick={() => save.mutate()} loading={save.isPending} disabled={!dirty}>
<Save className="h-4 w-4" /> Save
</Button>
)}
<button
onClick={onClose}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{error ? (
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
) : isLoading ? (
<Spinner />
) : unviewable ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-sm text-slate-500">
<FileIcon className="h-10 w-10 text-slate-300" />
<p>
{data?.binary
? "This looks like a binary file and can't be edited here."
: `File is too large to edit (${formatBytes(data?.size ?? 0)}).`}
</p>
<Button variant="outline" onClick={() => filesApi.download(path, name)}>
<Download className="h-4 w-4" /> Download instead
</Button>
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden rounded-b-xl">
<Editor
height="100%"
language={langForName(name)}
theme={theme === "dark" ? "vs-dark" : "light"}
value={content}
onChange={(v) => {
setContent(v ?? "");
setDirty(true);
}}
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
tabSize: 2,
}}
/>
</div>
)}
</div>
</div>
);
}
function NewEntryDialog({
kind,
dir,
onCancel,
onDone,
}: {
kind: "dir" | "file";
dir: string;
onCancel: () => void;
onDone: () => void;
}) {
const [name, setName] = useState("");
const create = useMutation({
mutationFn: () => (kind === "dir" ? filesApi.mkdir(dir, name.trim()) : filesApi.touch(dir, name.trim())),
onSuccess: () => {
toast.success(kind === "dir" ? "Folder created" : "File created");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<ConfirmDialog
title={kind === "dir" ? "New folder" : "New file"}
confirmLabel="Create"
busy={create.isPending}
onConfirm={() => name.trim() && create.mutate()}
onCancel={onCancel}
>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && name.trim() && create.mutate()}
placeholder={kind === "dir" ? "folder-name" : "file.txt"}
/>
</ConfirmDialog>
);
}
function RenameDialog({
entry,
dir,
onCancel,
onDone,
}: {
entry: HostPathEntry;
dir: string;
onCancel: () => void;
onDone: () => void;
}) {
const [name, setName] = useState(entry.name);
const rename = useMutation({
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()),
onSuccess: () => {
toast.success("Renamed");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<ConfirmDialog
title={`Rename “${entry.name}`}
confirmLabel="Rename"
busy={rename.isPending}
onConfirm={() => name.trim() && name.trim() !== entry.name && rename.mutate()}
onCancel={onCancel}
>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && name.trim() && name.trim() !== entry.name && rename.mutate()
}
/>
</ConfirmDialog>
);
}
+2
View File
@@ -126,6 +126,8 @@ export interface HostPathEntry {
type: "dir" | "file"; type: "dir" | "file";
size?: number | null; size?: number | null;
permissions: string; permissions: string;
mtime?: number;
symlink?: boolean;
} }
export interface HostPathResult { export interface HostPathResult {