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 = { 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(undefined); // undefined = local const [path, setPath] = useState("/"); const [showHidden, setShowHidden] = useState(false); const [editing, setEditing] = useState(null); const [renaming, setRenaming] = useState(null); const [deleting, setDeleting] = useState(null); const [newKind, setNewKind] = useState<"dir" | "file" | null>(null); const [clip, setClip] = useState(null); const [pasteConflict, setPasteConflict] = useState(false); const [progress, setProgress] = useState<{ label: string; pct: number } | null>(null); const fileInput = useRef(null); const folderInput = useRef(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 (
{/* Host switcher (only when remote hosts are registered) */} {(agents.data?.length ?? 0) > 0 && (
Host {host != null && !onlineAgents.some((a) => a.id === host) && ( selected host is offline )}
)} {/* Roots + actions */}
{data?.roots.map((r) => ( ))}
{isAdmin && ( <> { const f = e.target.files?.[0]; if (f) upload.mutate(f); }} /> )} onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) uploadFolder.mutate(files); }} /> )}
{/* Upload progress */} {progress && (
Uploading {progress.label} {Math.round(progress.pct)}%
)} {/* Breadcrumb */}
{crumbs(path).map((c, i, arr) => ( {i < arr.length - 1 && i > 0 && /} ))}
{clip && (
{clip.mode === "copy" ? ( ) : ( )} {clip.mode === "copy" ? "Copy" : "Move"} {clip.name} to{" "} {path}
)} {error ? (

{apiErrorMessage(error)}

) : isLoading ? ( ) : (
{data?.entries.map((e) => ( ))} {data?.entries.length === 0 && ( )}
Name Size Permissions Modified
{e.type === "dir" ? "—" : formatBytes(e.size ?? 0)} {e.permissions} {e.mtime ? relativeTime(new Date(e.mtime * 1000).toISOString()) : "—"}
{e.type === "file" && ( )} {isAdmin && ( <> )}
Empty directory.
)}
{editing && ( setEditing(null)} onSaved={refresh} /> )} {newKind && ( setNewKind(null)} onDone={() => { setNewKind(null); refresh(); }} /> )} {renaming && ( setRenaming(null)} onDone={() => { setRenaming(null); refresh(); }} /> )} {deleting && ( remove.mutate(deleting)} onCancel={() => setDeleting(null)} /> )} {pasteConflict && clip && ( paste.mutate(true)} onCancel={() => setPasteConflict(false)} /> )}
); } // --------------------------------------------------------------------------- // 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 (
e.stopPropagation()} >

{name}

{path}

{!readOnly && !unviewable && ( )}
{error ? (

{apiErrorMessage(error)}

) : isLoading ? ( ) : unviewable ? (

{data?.binary ? "This looks like a binary file and can't be edited here." : `File is too large to edit (${formatBytes(data?.size ?? 0)}).`}

) : (
{ setContent(v ?? ""); setDirty(true); }} options={{ readOnly, minimap: { enabled: false }, fontSize: 13, tabSize: 2, }} />
)}
); } 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 ( name.trim() && create.mutate()} onCancel={onCancel} > setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && name.trim() && create.mutate()} placeholder={kind === "dir" ? "folder-name" : "file.txt"} /> ); } 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 ( name.trim() && name.trim() !== entry.name && rename.mutate()} onCancel={onCancel} > setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && name.trim() && name.trim() !== entry.name && rename.mutate() } /> ); }