2026-03-04 11:06:40 -08:00

468 lines
13 KiB
TypeScript

"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<string, string> = {
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 (
<div className="max-w-4xl mx-auto px-6 py-8">
<DiffCard diff={content} />
</div>
);
}
return <EditorInner content={content} filename={filename} filePath={filePath} className={className} />;
}
type SaveState = "clean" | "dirty" | "saving" | "saved" | "error";
function EditorInner({ content, filename, filePath, className }: CodeEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof import("monaco-editor") | null>(null);
const [theme, setTheme] = useState<string>(isDarkMode() ? "ironclaw-dark" : "ironclaw-light");
const [saveState, setSaveState] = useState<SaveState>("clean");
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 });
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 <html> 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 (
<div className={`flex flex-col h-full ${className ?? ""}`}>
{/* Header bar */}
<div
className="flex items-center gap-2 px-4 py-2 flex-shrink-0"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
}}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{filename}
</span>
{/* Dirty indicator */}
{saveState === "dirty" && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: "var(--color-text-muted)" }}
title="Unsaved changes"
/>
)}
<div className="flex-1" />
{/* Language badge */}
<span
className="text-[11px] px-1.5 py-0.5 rounded font-medium"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{lang.toUpperCase()}
</span>
{/* Line count */}
<span
className="text-[11px] tabular-nums"
style={{ color: "var(--color-text-muted)" }}
>
{lineCount} lines
</span>
{/* Cursor position */}
<span
className="text-[11px] tabular-nums"
style={{ color: "var(--color-text-muted)" }}
>
Ln {cursorPos.line}, Col {cursorPos.col}
</span>
{/* Save status / button */}
{canSave && (
<>
{saveState === "saving" && (
<span className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Saving...
</span>
)}
{saveState === "saved" && (
<span className="text-[11px]" style={{ color: "var(--color-success)" }}>
Saved
</span>
)}
{saveState === "error" && (
<span className="text-[11px]" style={{ color: "var(--color-error)" }}>
Save failed
</span>
)}
<button
type="button"
onClick={() => void saveFile()}
disabled={saveState !== "dirty"}
className="text-[11px] px-2 py-0.5 rounded transition-colors"
style={{
background: saveState === "dirty" ? "var(--color-accent)" : "var(--color-surface-hover)",
color: saveState === "dirty" ? "#fff" : "var(--color-text-muted)",
cursor: saveState === "dirty" ? "pointer" : "default",
opacity: saveState === "dirty" ? 1 : 0.5,
}}
>
{saveState === "dirty" ? "Save" : "Save"}
</button>
</>
)}
</div>
{/* Monaco Editor */}
<div className="flex-1 min-h-0">
<Editor
defaultValue={content}
language={language}
theme={theme}
onMount={handleMount}
loading={
<div
className="flex items-center justify-center h-full"
style={{ background: "var(--color-bg)" }}
>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Loading editor...
</span>
</div>
}
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",
},
}}
/>
</div>
</div>
);
}