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>
732 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|