Spaces:
Sleeping
Sleeping
mfn
Embedded IDE: file tree + CodeMirror editor, workspace terminal, markdown output; agent deploy/monitor/logs tools + git push; hardware-aware deploy
5f0975b Download src/app/FileExplorer.tsx from mrfakename/spacefactory2: direct link, hf CLI and curl.
- Browser
- Download file 15.3 kB
-
https://huggingface.co/spaces/mrfakename/spacefactory2/resolve/main/src/app/FileExplorer.tsx
- Command line
-
hf download hf://spaces/mrfakename/spacefactory2/src/app/FileExplorer.tsx
-
curl -L -o FileExplorer.tsx https://huggingface.co/spaces/mrfakename/spacefactory2/resolve/main/src/app/FileExplorer.tsx
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> | |
| ); | |
| } | |