← vibesdk
src/components/monaco-editor/monaco-editor.tsx
17.8 KB
Download file
import React, { memo, useEffect, useRef } from 'react';
import * as monaco from 'monaco-editor';
import { useTheme } from '../../contexts/theme-context';
import 'monaco-editor/editor/editor.api';
import editorWorker from 'monaco-editor/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/language/typescript/ts.worker?worker';
import './monaco-editor.module.css';
import { vesperTheme } from './vesper-theme';
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new jsonWorker();
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new cssWorker();
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new htmlWorker();
}
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
},
};
/** Resolve Monaco language id, including markdown when only a path is known. */
function resolveLanguage(
language: string | undefined,
path: string | undefined,
): string {
if (language && language !== 'plaintext') {
return language;
}
if (path) {
const ext = path.split('.').pop()?.toLowerCase();
if (ext === 'md' || ext === 'markdown') return 'markdown';
if (ext === 'mdx') return 'mdx';
}
return language || 'typescript';
}
monaco.editor.defineTheme('vesper', vesperTheme);
monaco.editor.defineTheme('vibesdk', {
base: 'vs',
inherit: true,
rules: [
{ token: '', foreground: '000000', background: 'fbfbfc' },
{ token: 'comment', foreground: '6e7781', fontStyle: 'italic' },
{ token: 'keyword', foreground: '0092b8' },
{ token: 'number', foreground: '0550ae' },
{ token: 'string', foreground: '0a3069' },
{ token: 'type', foreground: '0092b8' },
{ token: 'class', foreground: '0092b8' },
{ token: 'interface', foreground: '0092b8' },
{ token: 'function', foreground: '953800' },
{ token: 'member', foreground: '0550ae' },
{ token: 'variable', foreground: '24292f' },
{ token: 'constant', foreground: '0550ae' },
{ token: 'operator', foreground: '0092b8' },
{ token: 'namespace', foreground: '0092b8' },
{ token: 'predefined', foreground: '0092b8' },
{ token: 'invalid', foreground: 'ff0000' },
],
colors: {
'editor.background': '#fbfbfc',
'editor.foreground': '#24292f',
'editorLineNumber.foreground': '#8c959f',
'editorLineNumber.activeForeground': '#24292f',
'editorCursor.foreground': '#0092b8',
'editorIndentGuide.background': '#d0d7de',
'editorIndentGuide.activeBackground': '#8c959f',
'editor.selectionBackground': '#0092b820',
'editor.inactiveSelectionBackground': '#0092b810',
'editor.lineHighlightBackground': '#0092b808',
'editor.wordHighlightBackground': '#0092b815',
'editor.wordHighlightStrongBackground': '#0092b820',
'editor.findMatchBackground': '#0092b830',
'editor.findMatchHighlightBackground': '#0092b815',
},
});
/** Ref-count editors that want TS IntelliSense so global defaults stay consistent. */
let typescriptFeaturesUsers = 0;
function applyTypeScriptDefaults(enabled: boolean) {
const tsDefaults = monaco.typescript.typescriptDefaults;
const jsDefaults = monaco.typescript.javascriptDefaults;
if (enabled) {
tsDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
const compilerOptions: monaco.typescript.CompilerOptions = {
jsx: monaco.typescript.JsxEmit.React,
allowJs: true,
allowSyntheticDefaultImports: true,
esModuleInterop: true,
moduleResolution: monaco.typescript.ModuleResolutionKind.NodeJs,
module: monaco.typescript.ModuleKind.ESNext,
target: monaco.typescript.ScriptTarget.ESNext,
jsxFactory: 'React.createElement',
jsxFragmentFactory: 'React.Fragment',
};
tsDefaults.setCompilerOptions(compilerOptions);
jsDefaults.setCompilerOptions(compilerOptions);
} else {
// Keep workers quiet for read-only viewers — avoids inmemory://model races
tsDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true,
noSuggestionDiagnostics: true,
});
jsDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true,
noSuggestionDiagnostics: true,
});
tsDefaults.setCompilerOptions({
jsx: monaco.typescript.JsxEmit.React,
target: monaco.typescript.ScriptTarget.ESNext,
noLib: true,
allowNonTsExtensions: true,
});
jsDefaults.setCompilerOptions({
jsx: monaco.typescript.JsxEmit.React,
target: monaco.typescript.ScriptTarget.ESNext,
noLib: true,
allowNonTsExtensions: true,
});
}
}
// Default to lightweight diagnostics until an editable editor mounts.
applyTypeScriptDefaults(false);
function resolveEditorTheme(resolvedTheme: string | undefined) {
return resolvedTheme === 'dark' ? 'vesper' : 'vs';
}
function toModelUri(path: string | undefined): monaco.Uri {
if (path) {
const normalized = path.replace(/^\/+/, '');
return monaco.Uri.parse(`file:///${normalized}`);
}
// Anonymous model — unique URI so TS worker never reuses a disposed id
return monaco.Uri.parse(
`inmemory://vibesdk/${crypto.randomUUID()}.tsx`,
);
}
/**
* Get or create a model at a stable URI. Switching files via setModel (not
* setValue on one model) avoids TS worker "Could not find source file" races.
*/
function getOrCreateModel(
value: string,
language: string,
path: string | undefined,
): monaco.editor.ITextModel {
const uri = toModelUri(path);
const existing = monaco.editor.getModel(uri);
if (existing) {
if (existing.getValue() !== value) {
existing.setValue(value);
}
if (existing.getLanguageId() !== language) {
monaco.editor.setModelLanguage(existing, language);
}
return existing;
}
return monaco.editor.createModel(value, language, uri);
}
/** Dispose model after detaching so the TS worker can finish in-flight work. */
function disposeModelDeferred(model: monaco.editor.ITextModel | null) {
if (!model || model.isDisposed()) return;
// Detach first; delay dispose so worker callbacks don't hit a dead model
window.setTimeout(() => {
if (!model.isDisposed()) {
model.dispose();
}
}, 0);
}
export type MonacoEditorProps = React.ComponentProps<'div'> & {
createOptions?: monaco.editor.IStandaloneEditorConstructionOptions;
/** Stable file path — used as model URI for clean file switches */
path?: string;
/**
* When true, path switches keep the viewport at the bottom (live generation).
* When false, path switches jump to the top (browsing complete files).
*/
stickToBottom?: boolean;
find?: string;
replace?: string;
enableTypeScriptFeatures?: 'auto' | boolean;
};
const EMPTY_CREATE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions =
{};
export const MonacoEditor = memo<MonacoEditorProps>(function MonacoEditor({
createOptions = EMPTY_CREATE_OPTIONS,
path,
stickToBottom = false,
find,
replace,
enableTypeScriptFeatures = 'auto',
...props
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor>(undefined);
const ownedModel = useRef<monaco.editor.ITextModel | null>(null);
const prevPath = useRef<string | undefined>(path);
const prevValue = useRef<string>(createOptions.value || '');
const stickyScroll = useRef(true);
const findDecorationIds = useRef<string[]>([]);
const { resolvedTheme } = useTheme();
const createOptionsRef = useRef(createOptions);
createOptionsRef.current = createOptions;
const pathRef = useRef(path);
pathRef.current = path;
const shouldEnableTypeScript = React.useMemo(() => {
if (enableTypeScriptFeatures === 'auto') {
return !createOptions.readOnly;
}
return enableTypeScriptFeatures;
}, [enableTypeScriptFeatures, createOptions.readOnly]);
// Configure TypeScript diagnostics with multi-instance ref-counting
useEffect(() => {
if (shouldEnableTypeScript) {
typescriptFeaturesUsers += 1;
if (typescriptFeaturesUsers === 1) {
applyTypeScriptDefaults(true);
}
return () => {
typescriptFeaturesUsers -= 1;
if (typescriptFeaturesUsers === 0) {
applyTypeScriptDefaults(false);
}
};
}
}, [shouldEnableTypeScript]);
useEffect(() => {
const options = createOptionsRef.current;
const {
theme: _ignoredTheme,
value,
language,
model: _ignoredModel,
...restOptions
} = options;
const initialLanguage = resolveLanguage(language, pathRef.current);
const initialValue = value ?? '';
const model = getOrCreateModel(
initialValue,
initialLanguage,
pathRef.current,
);
ownedModel.current = model;
editor.current = monaco.editor.create(containerRef.current!, {
model,
minimap: { enabled: false },
theme: resolveEditorTheme(resolvedTheme),
automaticLayout: true,
fontSize: 13,
...restOptions,
});
prevValue.current = initialValue;
prevPath.current = pathRef.current;
const editorDomNode = editor.current.getDomNode();
const handleWheel = () => {
stickyScroll.current = false;
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key.includes('Arrow') || e.key.includes('Page')) {
stickyScroll.current = false;
}
};
if (editorDomNode) {
editorDomNode.addEventListener('wheel', handleWheel);
editorDomNode.addEventListener('keydown', handleKeydown);
}
return () => {
if (editorDomNode) {
editorDomNode.removeEventListener('wheel', handleWheel);
editorDomNode.removeEventListener('keydown', handleKeydown);
}
findDecorationIds.current = [];
// Detach model before dispose so worker doesn't race
editor.current?.setModel(null);
editor.current?.dispose();
editor.current = undefined;
disposeModelDeferred(ownedModel.current);
ownedModel.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keep non-value construction options in sync after mount
const {
theme: _ignoredTheme,
value: _value,
language: _language,
model: _model,
...updateableOptions
} = createOptions;
const updateableOptionsKey = JSON.stringify(updateableOptions);
useEffect(() => {
if (!editor.current) return;
editor.current.updateOptions(updateableOptions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateableOptionsKey]);
// Path / value / language — switch models on path change; update content otherwise
useEffect(() => {
if (!editor.current) return;
const nextValue = createOptions.value ?? '';
const nextLanguage = resolveLanguage(createOptions.language, path);
const pathChanged = path !== prevPath.current;
const valueChanged = nextValue !== prevValue.current;
if (pathChanged) {
const previous = ownedModel.current;
const nextModel = getOrCreateModel(nextValue, nextLanguage, path);
// Clear decorations tied to the old model
findDecorationIds.current = [];
editor.current.setModel(nextModel);
ownedModel.current = nextModel;
// Don't dispose if getOrCreate reused the same instance
if (previous && previous !== nextModel) {
disposeModelDeferred(previous);
}
if (stickToBottom) {
// Live generation: follow the end of the file
stickyScroll.current = true;
editor.current.revealLine(nextModel.getLineCount());
} else {
// Browsing complete files: start at top
stickyScroll.current = false;
editor.current.setScrollTop(0);
editor.current.setPosition({ lineNumber: 1, column: 1 });
}
prevPath.current = path;
prevValue.current = nextValue;
return;
}
const model = editor.current.getModel();
if (!model) return;
if (valueChanged) {
const isAppend =
prevValue.current.length > 0 &&
nextValue.startsWith(prevValue.current);
if (isAppend) {
// Streaming: preserve sticky-to-bottom behavior
if (createOptions.readOnly) {
model.setValue(nextValue);
} else {
model.pushEditOperations(
[],
[
{
range: model.getFullModelRange(),
text: nextValue,
},
],
() => null,
);
}
if (stickyScroll.current) {
editor.current.revealLine(model.getLineCount());
}
} else {
// Content replaced on same path
if (createOptions.readOnly) {
model.setValue(nextValue);
} else {
model.pushEditOperations(
[],
[
{
range: model.getFullModelRange(),
text: nextValue,
},
],
() => null,
);
}
stickyScroll.current = true;
if (stickyScroll.current) {
editor.current.revealLine(model.getLineCount());
}
}
prevValue.current = nextValue;
}
if (model.getLanguageId() !== nextLanguage) {
monaco.editor.setModelLanguage(model, nextLanguage);
}
}, [
path,
stickToBottom,
createOptions.value,
createOptions.language,
createOptions.readOnly,
]);
// Find/replace decorations — owned IDs only; clear when find is gone; refresh on value
useEffect(() => {
if (!editor.current) return;
const model = editor.current.getModel();
if (!model) return;
if (!find) {
findDecorationIds.current = editor.current.deltaDecorations(
findDecorationIds.current,
[],
);
return;
}
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
const text = model.getValue();
const regex = new RegExp(
find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g',
);
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const startPos = model.getPositionAt(match.index);
const endPos = model.getPositionAt(match.index + match[0].length);
const range = new monaco.Range(
startPos.lineNumber,
startPos.column,
endPos.lineNumber,
endPos.column,
);
decorations.push({
range,
options: {
inlineClassName: 'diffDelete',
hoverMessage: {
value: replace
? `Will be replaced with: ${replace}`
: 'Will be deleted',
},
},
});
if (replace) {
decorations.push({
range,
options: {
after: {
content: replace,
inlineClassName: 'diffInsert',
},
},
});
}
}
findDecorationIds.current = editor.current.deltaDecorations(
findDecorationIds.current,
decorations,
);
}, [find, replace, createOptions.value, path]);
useEffect(() => {
if (editor.current) {
monaco.editor.setTheme(resolveEditorTheme(resolvedTheme));
}
}, [resolvedTheme]);
return <div {...props} ref={containerRef}></div>;
});
export type MonacoDiffEditorProps = React.ComponentProps<'div'> & {
/** Stable file path — only used to key model recreation. */
path?: string;
/** Left/original side (e.g. the parent commit's version). */
originalValue: string;
/** Right/modified side (e.g. the selected commit's version). */
modifiedValue: string;
language?: string;
/** Side-by-side (true) vs inline (false). Defaults to side-by-side. */
renderSideBySide?: boolean;
};
/**
* Read-only Monaco diff editor. Shares the theme and worker setup defined in
* this module (light -> `vs`, dark -> `vesper`) so diffs match the code editor.
* Models use anonymous URIs so they never collide with the main editor's
* `file:///` models for the same path.
*/
export const MonacoDiffEditor = memo<MonacoDiffEditorProps>(
function MonacoDiffEditor({
path,
originalValue,
modifiedValue,
language,
renderSideBySide = true,
...props
}) {
const containerRef = useRef<HTMLDivElement>(null);
const diffEditor = useRef<monaco.editor.IStandaloneDiffEditor>(undefined);
const originalModel = useRef<monaco.editor.ITextModel | null>(null);
const modifiedModel = useRef<monaco.editor.ITextModel | null>(null);
const { resolvedTheme } = useTheme();
const renderSideBySideRef = useRef(renderSideBySide);
renderSideBySideRef.current = renderSideBySide;
useEffect(() => {
diffEditor.current = monaco.editor.createDiffEditor(containerRef.current!, {
readOnly: true,
originalEditable: false,
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
renderSideBySide: renderSideBySideRef.current,
theme: resolveEditorTheme(resolvedTheme),
});
return () => {
diffEditor.current?.setModel(null);
diffEditor.current?.dispose();
diffEditor.current = undefined;
disposeModelDeferred(originalModel.current);
disposeModelDeferred(modifiedModel.current);
originalModel.current = null;
modifiedModel.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!diffEditor.current) return;
const lang = resolveLanguage(language, path);
const prevOriginal = originalModel.current;
const prevModified = modifiedModel.current;
const original = monaco.editor.createModel(
originalValue,
lang,
monaco.Uri.parse(`inmemory://diff-original/${crypto.randomUUID()}`),
);
const modified = monaco.editor.createModel(
modifiedValue,
lang,
monaco.Uri.parse(`inmemory://diff-modified/${crypto.randomUUID()}`),
);
originalModel.current = original;
modifiedModel.current = modified;
diffEditor.current.setModel({ original, modified });
disposeModelDeferred(prevOriginal);
disposeModelDeferred(prevModified);
}, [path, originalValue, modifiedValue, language]);
useEffect(() => {
if (diffEditor.current) {
diffEditor.current.updateOptions({ renderSideBySide });
}
}, [renderSideBySide]);
useEffect(() => {
if (diffEditor.current) {
monaco.editor.setTheme(resolveEditorTheme(resolvedTheme));
}
}, [resolvedTheme]);
return <div {...props} ref={containerRef}></div>;
},
);