Phase 14: multi-host file browser (0.15.0)

The Files page gained a host switcher: when agents are registered, a Host
dropdown switches the whole browser between the local host and any online agent
(switching resets path + clipboard). Every file operation is sandboxed by the
selected agent's own ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX.

- agent_app.py: /agent/files/* (list/read/download/write/mkdir/touch/rename/
  copy/move/delete/upload) reusing file_service + device_service; BrowseError
  -> HTTP 400.
- routers/agents.py: proxy routes at /api/agents/{id}/files/* (audit-logged
  mutations); download streams via download_to_file, upload via upload_file.
  Reuses the WriteBody/NameBody/RenameBody/TransferBody models from routers.files.
- Frontend: filesApi methods take an optional trailing agentId; Files.tsx tracks
  a host and threads it through every call, query key, and the editor/dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 12:04:56 +00:00
co-authored by Claude Opus 4.8
parent 012614f5fb
commit c5f591749f
7 changed files with 428 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.14.0",
"version": "0.15.0",
"type": "module",
"scripts": {
"dev": "vite",
+26 -22
View File
@@ -20,52 +20,56 @@ function triggerDownload(blob: Blob, filename: string) {
URL.revokeObjectURL(url);
}
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/files` : "/api/files";
export const filesApi = {
list: (path: string, showHidden = false) =>
list: (path: string, showHidden = false, agentId?: number) =>
api
.get<HostPathResult>("/api/files/list", { params: { path, show_hidden: showHidden } })
.get<HostPathResult>(`${base(agentId)}/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),
read: (path: string, agentId?: number) =>
api.get<FileContent>(`${base(agentId)}/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),
write: (path: string, content: string, agentId?: number) =>
api.put<{ path: string; size: number }>(`${base(agentId)}/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),
mkdir: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/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),
touch: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/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),
rename: (path: string, newName: string, agentId?: number) =>
api.post<{ path: string }>(`${base(agentId)}/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),
remove: (path: string, recursive = false, agentId?: number) =>
api.delete(base(agentId), { params: { path, recursive } }).then((r) => r.data),
copy: (src: string, destDir: string, overwrite = false) =>
copy: (src: string, destDir: string, overwrite = false, agentId?: number) =>
api
.post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base(agentId)}/copy`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
move: (src: string, destDir: string, overwrite = false) =>
move: (src: string, destDir: string, overwrite = false, agentId?: number) =>
api
.post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite })
.post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (path: string, filename: string) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
download: async (path: string, filename: string, agentId?: number) => {
const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename);
},
upload: async (path: string, file: File, overwrite = false, relPath = "") => {
upload: async (path: string, file: File, overwrite = false, relPath = "", agentId?: number) => {
const form = new FormData();
form.append("path", path);
form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath);
form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form);
return res.data;
},
};
+68 -13
View File
@@ -21,12 +21,14 @@ import {
Copy,
Scissors,
ClipboardPaste,
Server,
} 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 { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
@@ -72,6 +74,7 @@ function crumbs(path: string): { label: string; path: string }[] {
export function Files() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const qc = useQueryClient();
const [host, setHost] = useState<number | undefined>(undefined); // undefined = local
const [path, setPath] = useState("/");
const [showHidden, setShowHidden] = useState(false);
const [editing, setEditing] = useState<HostPathEntry | null>(null);
@@ -83,15 +86,30 @@ export function Files() {
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
// Switch host: reset workspace state so we never mix paths/clipboards across hosts.
const switchHost = (h: number | undefined) => {
setHost(h);
setPath("/");
setEditing(null);
setClip(null);
};
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", path, showHidden],
queryFn: () => filesApi.list(path, showHidden),
queryKey: ["files", host ?? "local", path, showHidden],
queryFn: () => filesApi.list(path, showHidden, host),
});
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
const upload = useMutation({
mutationFn: (file: File) => filesApi.upload(path, file),
mutationFn: (file: File) => filesApi.upload(path, file, false, "", host),
onSuccess: (r) => {
toast.success(`Uploaded ${r.name}`);
refresh();
@@ -118,7 +136,7 @@ export function Files() {
for (const f of files) {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
try {
await filesApi.upload(path, f, true, rel);
await filesApi.upload(path, f, true, rel, host);
ok += 1;
} catch {
failed += 1;
@@ -140,7 +158,7 @@ export function Files() {
const paste = useMutation({
mutationFn: (overwrite: boolean) => {
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
return op(clip!.src, path, overwrite);
return op(clip!.src, path, overwrite, host);
},
onSuccess: () => {
toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
@@ -158,7 +176,7 @@ export function Files() {
});
const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir", host),
onSuccess: () => {
toast.success("Deleted");
setDeleting(null);
@@ -168,10 +186,35 @@ export function Files() {
});
const download = (e: HostPathEntry) =>
filesApi.download(join(path, e.name), e.name).catch((err) => toast.error(apiErrorMessage(err)));
filesApi
.download(join(path, e.name), e.name, host)
.catch((err) => toast.error(apiErrorMessage(err)));
return (
<div className="space-y-4">
{/* Host switcher (only when remote hosts are registered) */}
{(agents.data?.length ?? 0) > 0 && (
<div className="flex items-center gap-2 text-sm">
<Server className="h-4 w-4 text-slate-400" />
<span className="text-slate-500">Host</span>
<select
value={host ?? "local"}
onChange={(e) => switchHost(e.target.value === "local" ? undefined : Number(e.target.value))}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
{host != null && !onlineAgents.some((a) => a.id === host) && (
<span className="text-xs text-amber-500">selected host is offline</span>
)}
</div>
)}
{/* Roots + actions */}
<div className="flex flex-wrap items-center gap-2">
{data?.roots.map((r) => (
@@ -397,6 +440,7 @@ export function Files() {
path={join(path, editing.name)}
name={editing.name}
isAdmin={isAdmin}
agentId={host}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
@@ -405,6 +449,7 @@ export function Files() {
<NewEntryDialog
kind={newKind}
dir={path}
agentId={host}
onCancel={() => setNewKind(null)}
onDone={() => {
setNewKind(null);
@@ -416,6 +461,7 @@ export function Files() {
<RenameDialog
entry={renaming}
dir={path}
agentId={host}
onCancel={() => setRenaming(null)}
onDone={() => {
setRenaming(null);
@@ -459,12 +505,14 @@ function FileEditor({
path,
name,
isAdmin,
agentId,
onClose,
onSaved,
}: {
path: string;
name: string;
isAdmin: boolean;
agentId?: number;
onClose: () => void;
onSaved: () => void;
}) {
@@ -472,8 +520,8 @@ function FileEditor({
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["file-content", path],
queryFn: () => filesApi.read(path),
queryKey: ["file-content", agentId ?? "local", path],
queryFn: () => filesApi.read(path, agentId),
});
useEffect(() => {
@@ -481,7 +529,7 @@ function FileEditor({
}, [data]);
const save = useMutation({
mutationFn: () => filesApi.write(path, content),
mutationFn: () => filesApi.write(path, content, agentId),
onSuccess: () => {
toast.success("Saved");
setDirty(false);
@@ -531,7 +579,7 @@ function FileEditor({
? "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)}>
<Button variant="outline" onClick={() => filesApi.download(path, name, agentId)}>
<Download className="h-4 w-4" /> Download instead
</Button>
</div>
@@ -563,17 +611,22 @@ function FileEditor({
function NewEntryDialog({
kind,
dir,
agentId,
onCancel,
onDone,
}: {
kind: "dir" | "file";
dir: string;
agentId?: number;
onCancel: () => void;
onDone: () => void;
}) {
const [name, setName] = useState("");
const create = useMutation({
mutationFn: () => (kind === "dir" ? filesApi.mkdir(dir, name.trim()) : filesApi.touch(dir, name.trim())),
mutationFn: () =>
kind === "dir"
? filesApi.mkdir(dir, name.trim(), agentId)
: filesApi.touch(dir, name.trim(), agentId),
onSuccess: () => {
toast.success(kind === "dir" ? "Folder created" : "File created");
onDone();
@@ -603,17 +656,19 @@ function NewEntryDialog({
function RenameDialog({
entry,
dir,
agentId,
onCancel,
onDone,
}: {
entry: HostPathEntry;
dir: string;
agentId?: number;
onCancel: () => void;
onDone: () => void;
}) {
const [name, setName] = useState(entry.name);
const rename = useMutation({
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()),
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim(), agentId),
onSuccess: () => {
toast.success("Renamed");
onDone();