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
+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>
);
}