spacefactory2 / src /app /FileExplorer.tsx
mfn
Embedded IDE: file tree + CodeMirror editor, workspace terminal, markdown output; agent deploy/monitor/logs tools + git push; hardware-aware deploy
5f0975b
Raw History Blame Contribute Delete
15.3 kB
"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<TreeNode[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string | null>(null);
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const dirtyRef = useRef(false);
const selectedRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<string, unknown>) => {
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 (
<Box key={node.path}>
<Box
onClick={() =>
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 ? (
<FolderOpenIcon sx={{ fontSize: 16, color: "primary.main" }} />
) : (
<FolderIcon sx={{ fontSize: 16, color: "text.secondary" }} />
)
) : (
<DescriptionIcon sx={{ fontSize: 16, color: "text.secondary" }} />
)}
<Typography
variant="caption"
sx={{
flexGrow: 1,
fontFamily: "monospace",
color: selected === node.path ? "primary.main" : "text.primary",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{node.name}
</Typography>
<IconButton
size="small"
className="row-actions"
sx={{ opacity: 0, p: 0.25 }}
onClick={(e) => {
e.stopPropagation();
setMenu({ anchor: e.currentTarget, node });
}}
>
<MoreVertIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
{isDir && isOpen && node.children ? renderNodes(node.children, depth + 1) : null}
</Box>
);
});
return (
<Box sx={{ display: "flex", minHeight: 420, height: 480 }}>
{/* Sidebar */}
<Box
sx={{
width: 230,
flexShrink: 0,
borderRight: "1px solid",
borderColor: "divider",
display: "flex",
flexDirection: "column",
}}
>
<Stack
direction="row"
sx={{ alignItems: "center", px: 1, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}
>
<Typography variant="caption" sx={{ flexGrow: 1, color: "text.secondary" }}>
FILES
</Typography>
<Tooltip title="New file">
<IconButton size="small" onClick={() => promptNew("file")}>
<NoteAddIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
<Tooltip title="New folder">
<IconButton size="small" onClick={() => promptNew("folder")}>
<CreateNewFolderIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
<Tooltip title="Upload files">
<IconButton size="small" onClick={() => fileInputRef.current?.click()}>
<UploadFileIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
<Tooltip title="Refresh">
<IconButton size="small" onClick={refreshTree}>
<RefreshIcon sx={{ fontSize: 17 }} />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ flexGrow: 1, overflowY: "auto", py: 0.5 }}>
{tree.length ? (
renderNodes(tree, 0)
) : (
<Typography variant="caption" sx={{ px: 1.5, color: "text.secondary" }}>
No files yet.
</Typography>
)}
</Box>
<input
ref={fileInputRef}
type="file"
multiple
hidden
onChange={(e) => {
onUpload(e.target.files);
e.target.value = "";
}}
/>
</Box>
{/* Editor */}
<Box sx={{ flexGrow: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
{selected ? (
<>
<Stack
direction="row"
sx={{
alignItems: "center",
px: 1.5,
py: 0.5,
borderBottom: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="caption" sx={{ flexGrow: 1, fontFamily: "monospace" }}>
{selected}
{dirty ? " •" : ""}
</Typography>
<Button
size="small"
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
onClick={save}
disabled={!dirty || saving}
>
{saving ? "Saving…" : "Save"}
</Button>
</Stack>
<Box sx={{ flexGrow: 1, overflow: "auto", "& .cm-editor": { height: "100%" } }}>
<CodeMirror
value={content}
theme="dark"
extensions={extensions}
onChange={(v) => {
setContent(v);
setDirty(true);
}}
height="100%"
style={{ fontSize: 13, height: "100%" }}
/>
</Box>
</>
) : (
<Box
sx={{
flexGrow: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
}}
>
<Typography variant="body2">Select a file to edit</Typography>
</Box>
)}
</Box>
{/* Per-node actions */}
<Menu
open={Boolean(menu)}
anchorEl={menu?.anchor}
onClose={() => setMenu(null)}
>
{menu?.node.type === "dir" ? (
<MenuItem
onClick={() => {
promptNew("file", menu.node.path);
setMenu(null);
}}
>
New file here
</MenuItem>
) : null}
<MenuItem
onClick={() => {
if (menu) doRename(menu.node);
setMenu(null);
}}
>
Rename
</MenuItem>
<MenuItem
onClick={() => {
if (menu) doDelete(menu.node);
setMenu(null);
}}
>
Delete
</MenuItem>
</Menu>
{/* Name dialog for new/rename */}
<Dialog open={Boolean(dialog)} onClose={() => setDialog(null)} maxWidth="xs" fullWidth>
<DialogTitle>{dialog?.title}</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
value={dialog?.value ?? ""}
onChange={(e) => 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 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(null)}>Cancel</Button>
<Button
variant="contained"
onClick={() => {
if (dialog) dialog.onSubmit(dialog.value);
setDialog(null);
}}
>
OK
</Button>
</DialogActions>
</Dialog>
</Box>
);
}