Files
stackpilot/frontend/src/pages/Files.tsx
T
menzeljandClaude Opus 4.8 cf046648bd File browser: upload progress bar (0.21.5)
filesApi.upload now accepts an onProgress callback wired to axios
onUploadProgress; the Files page shows a progress bar with percentage
while uploading. Single-file upload tracks that file's bytes; folder
upload tracks overall progress across the N files (file i/N + current
file's bytes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:26:09 +00:00

732 lines
25 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Folder,
File as FileIcon,
ArrowUp,
RefreshCw,
FolderPlus,
FilePlus,
Upload,
FolderUp,
Download,
Pencil,
Trash2,
Eye,
EyeOff,
Save,
X,
HardDrive,
Link2,
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";
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";
}
interface Clipboard {
src: string;
name: string;
type: "dir" | "file";
mode: "copy" | "cut";
}
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 [host, setHost] = useState<number | undefined>(undefined); // undefined = local
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 [clip, setClip] = useState<Clipboard | null>(null);
const [pasteConflict, setPasteConflict] = useState(false);
const [progress, setProgress] = useState<{ label: string; pct: number } | null>(null);
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", host ?? "local", path, showHidden],
queryFn: () => filesApi.list(path, showHidden, host),
});
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
const upload = useMutation({
mutationFn: (file: File) => {
setProgress({ label: file.name, pct: 0 });
return filesApi.upload(path, file, false, "", host, (pct) =>
setProgress({ label: file.name, pct })
);
},
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: () => {
setProgress(null);
if (fileInput.current) fileInput.current.value = "";
},
});
// Folder upload: send each file with its relative path so the backend
// recreates the directory structure. Continues past individual failures.
const uploadFolder = useMutation({
mutationFn: async (files: File[]) => {
const total = files.length;
let ok = 0;
let failed = 0;
for (let i = 0; i < files.length; i++) {
const f = files[i];
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
try {
await filesApi.upload(path, f, true, rel, host, (filePct) =>
setProgress({
label: `${f.name} (${i + 1}/${total})`,
pct: ((i + filePct / 100) / total) * 100,
})
);
ok += 1;
} catch {
failed += 1;
}
}
return { ok, failed };
},
onSuccess: ({ ok, failed }) => {
if (failed) toast.warning(`Uploaded ${ok} file(s), ${failed} failed`);
else toast.success(`Uploaded ${ok} file(s)`);
refresh();
},
onError: (e) => toast.error(apiErrorMessage(e)),
onSettled: () => {
setProgress(null);
if (folderInput.current) folderInput.current.value = "";
},
});
const paste = useMutation({
mutationFn: (overwrite: boolean) => {
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
return op(clip!.src, path, overwrite, host);
},
onSuccess: () => {
toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
setClip(null);
setPasteConflict(false);
refresh();
},
onError: (e) => {
if (apiErrorMessage(e).startsWith("Already exists")) {
setPasteConflict(true);
} else {
toast.error(apiErrorMessage(e));
}
},
});
const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir", host),
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, 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) => (
<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>
<Button
variant="outline"
onClick={() => folderInput.current?.click()}
loading={uploadFolder.isPending}
>
<FolderUp className="h-4 w-4" /> Upload folder
</Button>
<input
ref={fileInput}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
}}
/>
<input
ref={folderInput}
type="file"
className="hidden"
multiple
// webkitdirectory/directory are non-standard but widely supported.
{...({ webkitdirectory: "", directory: "" } as Record<string, string>)}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) uploadFolder.mutate(files);
}}
/>
</>
)}
</div>
</div>
{/* Upload progress */}
{progress && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
<span className="flex min-w-0 items-center gap-1.5">
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">Uploading {progress.label}</span>
</span>
<span className="tabular-nums">{Math.round(progress.pct)}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-accent transition-all dark:bg-accent-dark"
style={{ width: `${progress.pct}%` }}
/>
</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>
{clip && (
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-sky-50 px-3 py-2 text-sm dark:border-slate-700 dark:bg-sky-950/30">
{clip.mode === "copy" ? (
<Copy className="h-4 w-4 text-sky-500" />
) : (
<Scissors className="h-4 w-4 text-sky-500" />
)}
<span className="text-slate-600 dark:text-slate-300">
{clip.mode === "copy" ? "Copy" : "Move"} <span className="font-medium">{clip.name}</span> to{" "}
<span className="font-mono text-xs">{path}</span>
</span>
<div className="ml-auto flex gap-2">
<Button onClick={() => paste.mutate(false)} loading={paste.isPending}>
<ClipboardPaste className="h-4 w-4" /> Paste here
</Button>
<Button variant="outline" onClick={() => setClip(null)} disabled={paste.isPending}>
Cancel
</Button>
</div>
</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="Copy"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Copy className="h-4 w-4 text-slate-500" />
</button>
<button
title="Cut (move)"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Scissors className="h-4 w-4 text-slate-500" />
</button>
<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}
agentId={host}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
{newKind && (
<NewEntryDialog
kind={newKind}
dir={path}
agentId={host}
onCancel={() => setNewKind(null)}
onDone={() => {
setNewKind(null);
refresh();
}}
/>
)}
{renaming && (
<RenameDialog
entry={renaming}
dir={path}
agentId={host}
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)}
/>
)}
{pasteConflict && clip && (
<ConfirmDialog
title={`“${clip.name}” already exists here`}
message={`Overwrite the existing ${clip.type === "dir" ? "folder" : "file"} at ${path}?`}
confirmLabel="Overwrite"
danger
busy={paste.isPending}
onConfirm={() => paste.mutate(true)}
onCancel={() => setPasteConflict(false)}
/>
)}
</div>
);
}
// --------------------------------------------------------------------------- //
function FileEditor({
path,
name,
isAdmin,
agentId,
onClose,
onSaved,
}: {
path: string;
name: string;
isAdmin: boolean;
agentId?: number;
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", agentId ?? "local", path],
queryFn: () => filesApi.read(path, agentId),
});
useEffect(() => {
if (data?.content != null) setContent(data.content);
}, [data]);
const save = useMutation({
mutationFn: () => filesApi.write(path, content, agentId),
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, agentId)}>
<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,
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(), agentId)
: filesApi.touch(dir, name.trim(), agentId),
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,
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(), agentId),
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>
);
}