From 3f6f1815520c736a27caaca84fce4c3f7023a30f Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:06:40 -0800 Subject: [PATCH] feat(web): add monaco workspace code editor --- .../app/components/workspace/code-editor.tsx | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 apps/web/app/components/workspace/code-editor.tsx diff --git a/apps/web/app/components/workspace/code-editor.tsx b/apps/web/app/components/workspace/code-editor.tsx new file mode 100644 index 00000000000..a0d794470dc --- /dev/null +++ b/apps/web/app/components/workspace/code-editor.tsx @@ -0,0 +1,467 @@ +"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", + }, + }} + /> +
+
+ ); +}