"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Editor, { type OnMount } from "@monaco-editor/react"; import type { editor } from "monaco-editor"; import { DiffCard } from "../diff-viewer"; const EXT_TO_MONACO_LANG: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift", c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp", css: "css", scss: "scss", less: "less", html: "html", htm: "html", xml: "xml", svg: "xml", json: "json", jsonc: "json", yaml: "yaml", yml: "yaml", toml: "plaintext", md: "markdown", mdx: "markdown", sh: "shell", bash: "shell", zsh: "shell", fish: "shell", ps1: "powershell", sql: "sql", graphql: "graphql", gql: "graphql", dockerfile: "dockerfile", docker: "dockerfile", makefile: "plaintext", cmake: "plaintext", r: "r", lua: "lua", php: "php", vue: "html", svelte: "html", diff: "plaintext", patch: "plaintext", ini: "ini", env: "ini", tf: "plaintext", proto: "protobuf", zig: "plaintext", elixir: "plaintext", ex: "plaintext", erl: "plaintext", hs: "plaintext", scala: "scala", clj: "clojure", dart: "dart", }; export function extFromFilename(filename: string): string { const lower = filename.toLowerCase(); if (lower === "dockerfile" || lower.startsWith("dockerfile.")) {return "dockerfile";} if (lower === "makefile" || lower === "gnumakefile") {return "makefile";} if (lower === "cmakelists.txt") {return "cmake";} return lower.split(".").pop() ?? ""; } export function monacoLangFromFilename(filename: string): string { const ext = extFromFilename(filename); return EXT_TO_MONACO_LANG[ext] ?? "plaintext"; } export function displayLang(filename: string): string { const lang = monacoLangFromFilename(filename); if (lang === "plaintext") { const ext = extFromFilename(filename); return ext || "TEXT"; } return lang; } let themesRegistered = false; function registerThemes(monaco: typeof import("monaco-editor")) { if (themesRegistered) {return;} themesRegistered = true; monaco.editor.defineTheme("ironclaw-light", { base: "vs", inherit: true, rules: [], colors: { "editor.background": "#ffffff", "editor.foreground": "#1c1c1a", "editor.lineHighlightBackground": "#f5f4f1", "editor.selectionBackground": "#2563eb20", "editor.inactiveSelectionBackground": "#2563eb10", "editorLineNumber.foreground": "#8a8a82", "editorLineNumber.activeForeground": "#44443e", "editorIndentGuide.background": "#00000010", "editorIndentGuide.activeBackground": "#00000020", "editor.selectionHighlightBackground": "#2563eb12", "editorCursor.foreground": "#2563eb", "editorWhitespace.foreground": "#00000010", "editorBracketMatch.background": "#2563eb15", "editorBracketMatch.border": "#2563eb40", "editorGutter.background": "#ffffff", "editorWidget.background": "#ffffff", "editorWidget.border": "#00000014", "editorSuggestWidget.background": "#ffffff", "editorSuggestWidget.border": "#00000014", "editorSuggestWidget.selectedBackground": "#f5f4f1", "editorHoverWidget.background": "#ffffff", "editorHoverWidget.border": "#00000014", "input.background": "#f5f5f4", "input.border": "#00000014", "input.foreground": "#1c1c1a", "minimap.background": "#ffffff", "scrollbarSlider.background": "#00000012", "scrollbarSlider.hoverBackground": "#00000020", "scrollbarSlider.activeBackground": "#0000002a", }, }); monaco.editor.defineTheme("ironclaw-dark", { base: "vs-dark", inherit: true, rules: [], colors: { "editor.background": "#0c0c0b", "editor.foreground": "#ececea", "editor.lineHighlightBackground": "#1e1e1c", "editor.selectionBackground": "#3b82f630", "editor.inactiveSelectionBackground": "#3b82f618", "editorLineNumber.foreground": "#78776f", "editorLineNumber.activeForeground": "#b8b8b0", "editorIndentGuide.background": "#ffffff08", "editorIndentGuide.activeBackground": "#ffffff14", "editor.selectionHighlightBackground": "#3b82f618", "editorCursor.foreground": "#3b82f6", "editorWhitespace.foreground": "#ffffff08", "editorBracketMatch.background": "#3b82f620", "editorBracketMatch.border": "#3b82f650", "editorGutter.background": "#0c0c0b", "editorWidget.background": "#161615", "editorWidget.border": "#ffffff14", "editorSuggestWidget.background": "#161615", "editorSuggestWidget.border": "#ffffff14", "editorSuggestWidget.selectedBackground": "#1e1e1c", "editorHoverWidget.background": "#161615", "editorHoverWidget.border": "#ffffff14", "input.background": "#1e1e1c", "input.border": "#ffffff14", "input.foreground": "#ececea", "minimap.background": "#0c0c0b", "scrollbarSlider.background": "#ffffff12", "scrollbarSlider.hoverBackground": "#ffffff20", "scrollbarSlider.activeBackground": "#ffffff2a", }, }); } function isDarkMode(): boolean { if (typeof document === "undefined") {return false;} return document.documentElement.classList.contains("dark"); } type CodeEditorProps = { content: string; filename: string; filePath?: string; className?: string; }; export function MonacoCodeEditor({ content, filename, filePath, className }: CodeEditorProps) { const ext = extFromFilename(filename); if (ext === "diff" || ext === "patch") { return (
); } return ; } type SaveState = "clean" | "dirty" | "saving" | "saved" | "error"; function EditorInner({ content, filename, filePath, className }: CodeEditorProps) { const editorRef = useRef(null); const monacoRef = useRef(null); const [theme, setTheme] = useState(isDarkMode() ? "ironclaw-dark" : "ironclaw-light"); const [saveState, setSaveState] = useState("clean"); const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 }); const saveTimeoutRef = useRef | null>(null); const currentContentRef = useRef(content); const language = monacoLangFromFilename(filename); const canSave = !!filePath; useEffect(() => { currentContentRef.current = content; }, [content]); // Watch for theme changes via MutationObserver on class useEffect(() => { const html = document.documentElement; const update = () => setTheme(isDarkMode() ? "ironclaw-dark" : "ironclaw-light"); const observer = new MutationObserver(update); observer.observe(html, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); const saveFile = useCallback(async () => { if (!filePath || !editorRef.current) {return;} const value = editorRef.current.getValue(); setSaveState("saving"); try { const res = await fetch("/api/workspace/file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: filePath, content: value }), }); if (!res.ok) {throw new Error("Save failed");} currentContentRef.current = value; setSaveState("saved"); if (saveTimeoutRef.current) {clearTimeout(saveTimeoutRef.current);} saveTimeoutRef.current = setTimeout(() => setSaveState("clean"), 2000); } catch { setSaveState("error"); if (saveTimeoutRef.current) {clearTimeout(saveTimeoutRef.current);} saveTimeoutRef.current = setTimeout(() => setSaveState("dirty"), 3000); } }, [filePath]); const handleMount: OnMount = useCallback((ed, monaco) => { editorRef.current = ed; monacoRef.current = monaco; registerThemes(monaco); monaco.editor.setTheme(isDarkMode() ? "ironclaw-dark" : "ironclaw-light"); // Cmd+S / Ctrl+S save ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { void saveFile(); }); // Track cursor position ed.onDidChangeCursorPosition((e) => { setCursorPos({ line: e.position.lineNumber, col: e.position.column }); }); // Track dirty state ed.onDidChangeModelContent(() => { const current = ed.getValue(); if (current !== currentContentRef.current) { setSaveState("dirty"); } else { setSaveState("clean"); } }); ed.focus(); }, [saveFile]); // Apply theme when it changes useEffect(() => { if (monacoRef.current) { monacoRef.current.editor.setTheme(theme); } }, [theme]); const lang = displayLang(filename); const lineCount = content.split("\n").length; return (
{/* Header bar */}
{filename} {/* Dirty indicator */} {saveState === "dirty" && ( )}
{/* Language badge */} {lang.toUpperCase()} {/* Line count */} {lineCount} lines {/* Cursor position */} Ln {cursorPos.line}, Col {cursorPos.col} {/* Save status / button */} {canSave && ( <> {saveState === "saving" && ( Saving... )} {saveState === "saved" && ( Saved )} {saveState === "error" && ( Save failed )} )}
{/* Monaco Editor */}
Loading editor...
} options={{ readOnly: !canSave, fontSize: 13, lineHeight: 20, fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Menlo, Monaco, 'Courier New', monospace", fontLigatures: true, minimap: { enabled: true, scale: 1, showSlider: "mouseover" }, scrollBeyondLastLine: true, smoothScrolling: true, cursorBlinking: "smooth", cursorSmoothCaretAnimation: "on", renderLineHighlight: "all", renderWhitespace: "selection", bracketPairColorization: { enabled: true }, guides: { bracketPairs: true, indentation: true, highlightActiveIndentation: true, }, stickyScroll: { enabled: true }, folding: true, foldingHighlight: true, showFoldingControls: "mouseover", links: true, wordWrap: "off", padding: { top: 8, bottom: 8 }, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10, verticalSliderSize: 10, horizontalSliderSize: 10, }, overviewRulerBorder: false, hideCursorInOverviewRuler: true, automaticLayout: true, tabSize: 2, insertSpaces: false, detectIndentation: true, formatOnPaste: false, formatOnType: false, suggestOnTriggerCharacters: true, quickSuggestions: true, contextmenu: true, mouseWheelZoom: true, find: { addExtraSpaceOnTop: false, autoFindInSelection: "multiline", seedSearchStringFromSelection: "selection", }, }} />
); }