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:
co-authored by
Claude Opus 4.8
parent
012614f5fb
commit
c5f591749f
+26
-22
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user