feat(web): add monaco workspace code editor
This commit is contained in:
parent
8513b9a9b9
commit
3f6f181552
467
apps/web/app/components/workspace/code-editor.tsx
Normal file
467
apps/web/app/components/workspace/code-editor.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user