"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import Button from "@mui/material/Button"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; import TextField from "@mui/material/TextField"; import NoteAddIcon from "@mui/icons-material/NoteAdd"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import RefreshIcon from "@mui/icons-material/Refresh"; import FolderIcon from "@mui/icons-material/Folder"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import DescriptionIcon from "@mui/icons-material/Description"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import SaveIcon from "@mui/icons-material/Save"; import CodeMirror from "@uiw/react-codemirror"; import { python } from "@codemirror/lang-python"; import { markdown } from "@codemirror/lang-markdown"; import { json } from "@codemirror/lang-json"; import { javascript } from "@codemirror/lang-javascript"; import { yaml } from "@codemirror/lang-yaml"; import type { Extension } from "@codemirror/state"; interface TreeNode { name: string; path: string; type: "file" | "dir"; bytes?: number; children?: TreeNode[]; } function langFor(path: string): Extension[] { const p = path.toLowerCase(); if (p.endsWith(".py")) return [python()]; if (p.endsWith(".md")) return [markdown()]; if (p.endsWith(".json")) return [json()]; if (p.endsWith(".ts") || p.endsWith(".tsx") || p.endsWith(".js") || p.endsWith(".mjs")) return [javascript({ jsx: true, typescript: p.includes("ts") })]; if (p.endsWith(".yaml") || p.endsWith(".yml")) return [yaml()]; return []; } export default function FileExplorer({ buildId, fsVersion, onToast, }: { buildId: string; fsVersion: number; onToast: (msg: string) => void; }) { const [tree, setTree] = useState([]); const [expanded, setExpanded] = useState>(new Set()); const [selected, setSelected] = useState(null); const [content, setContent] = useState(""); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const dirtyRef = useRef(false); const selectedRef = useRef(null); const fileInputRef = useRef(null); const [menu, setMenu] = useState<{ anchor: HTMLElement; node: TreeNode } | null>(null); const [dialog, setDialog] = useState<{ title: string; value: string; onSubmit: (v: string) => void; } | null>(null); useEffect(() => { dirtyRef.current = dirty; }, [dirty]); useEffect(() => { selectedRef.current = selected; }, [selected]); const api = useCallback( async (body: Record) => { const res = await fetch("/api/files", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ buildId, ...body }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "File operation failed"); if (data.tree) setTree(data.tree); return data; }, [buildId], ); const refreshTree = useCallback(async () => { const res = await fetch(`/api/files?buildId=${buildId}`); if (res.ok) { const data = await res.json(); setTree(data.tree ?? []); } }, [buildId]); const openFile = useCallback( async (path: string, force = false) => { if (!force && dirtyRef.current && selectedRef.current) { if (!confirm("Discard unsaved changes?")) return; } const res = await fetch( `/api/files?buildId=${buildId}&path=${encodeURIComponent(path)}`, ); const data = await res.json(); if (!res.ok) { onToast(data.error ?? "Could not open file"); return; } setSelected(path); setContent(data.content ?? ""); setDirty(false); }, [buildId, onToast], ); // Refresh the tree when the workspace changes (agent wrote files, etc.). useEffect(() => { refreshTree(); }, [refreshTree, fsVersion]); // Reload the open file when the agent changes it — unless the user is editing. useEffect(() => { if (selectedRef.current && !dirtyRef.current) { openFile(selectedRef.current, true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fsVersion]); const save = useCallback(async () => { if (!selected) return; setSaving(true); try { await api({ op: "save", path: selected, content }); setDirty(false); onToast(`Saved ${selected}`); } catch (err) { onToast(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }, [api, selected, content, onToast]); const promptNew = (kind: "file" | "folder", baseDir = "") => { setDialog({ title: kind === "file" ? "New file" : "New folder", value: baseDir ? `${baseDir}/` : "", onSubmit: async (v) => { const path = v.trim(); if (!path) return; try { if (kind === "file") { await api({ op: "newfile", path, content: "" }); openFile(path, true); } else { await api({ op: "mkdir", path }); } } catch (err) { onToast(err instanceof Error ? err.message : String(err)); } }, }); }; const doRename = (node: TreeNode) => { setDialog({ title: `Rename ${node.name}`, value: node.path, onSubmit: async (v) => { try { await api({ op: "rename", path: node.path, newPath: v.trim() }); if (selected === node.path) setSelected(v.trim()); } catch (err) { onToast(err instanceof Error ? err.message : String(err)); } }, }); }; const doDelete = async (node: TreeNode) => { if (!confirm(`Delete ${node.path}?`)) return; try { await api({ op: "delete", path: node.path }); if (selected === node.path) { setSelected(null); setContent(""); setDirty(false); } } catch (err) { onToast(err instanceof Error ? err.message : String(err)); } }; const onUpload = async (files: FileList | null) => { if (!files) return; for (const file of Array.from(files)) { const buf = await file.arrayBuffer(); let binary = ""; const bytes = new Uint8Array(buf); for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); try { await api({ op: "upload", path: file.name, contentBase64: btoa(binary) }); } catch (err) { onToast(err instanceof Error ? err.message : String(err)); } } onToast("Uploaded"); }; const extensions = useMemo(() => (selected ? langFor(selected) : []), [selected]); const renderNodes = (nodes: TreeNode[], depth: number): React.ReactNode => nodes.map((node) => { const isDir = node.type === "dir"; const isOpen = expanded.has(node.path); return ( isDir ? setExpanded((s) => { const n = new Set(s); if (n.has(node.path)) n.delete(node.path); else n.add(node.path); return n; }) : openFile(node.path) } sx={{ display: "flex", alignItems: "center", gap: 0.5, pl: 0.5 + depth * 1.25, pr: 0.5, py: 0.35, cursor: "pointer", borderRadius: 1, bgcolor: selected === node.path ? "rgba(251,191,36,0.12)" : "transparent", "&:hover": { bgcolor: "rgba(255,255,255,0.05)", "& .row-actions": { opacity: 1 } }, }} > {isDir ? ( isOpen ? ( ) : ( ) ) : ( )} {node.name} { e.stopPropagation(); setMenu({ anchor: e.currentTarget, node }); }} > {isDir && isOpen && node.children ? renderNodes(node.children, depth + 1) : null} ); }); return ( {/* Sidebar */} FILES promptNew("file")}> promptNew("folder")}> fileInputRef.current?.click()}> {tree.length ? ( renderNodes(tree, 0) ) : ( No files yet. )} { onUpload(e.target.files); e.target.value = ""; }} /> {/* Editor */} {selected ? ( <> {selected} {dirty ? " •" : ""} { setContent(v); setDirty(true); }} height="100%" style={{ fontSize: 13, height: "100%" }} /> ) : ( Select a file to edit )} {/* Per-node actions */} setMenu(null)} > {menu?.node.type === "dir" ? ( { promptNew("file", menu.node.path); setMenu(null); }} > New file here ) : null} { if (menu) doRename(menu.node); setMenu(null); }} > Rename { if (menu) doDelete(menu.node); setMenu(null); }} > Delete {/* Name dialog for new/rename */} setDialog(null)} maxWidth="xs" fullWidth> {dialog?.title} setDialog((d) => (d ? { ...d, value: e.target.value } : d))} onKeyDown={(e) => { if (e.key === "Enter" && dialog) { dialog.onSubmit(dialog.value); setDialog(null); } }} slotProps={{ htmlInput: { spellCheck: false, style: { fontFamily: "monospace" } } }} sx={{ mt: 1 }} /> ); }