mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 15:57:15 +01:00
Replace Monaco with CodeMirror (#36764)
- Replace monaco-editor with CodeMirror 6 - Add `--color-syntax-*` CSS variables for all syntax token types, shared by CodeMirror, Chroma and EasyMDE - Consolidate chroma CSS into a single theme-independent file (`modules/chroma.css`) - Syntax colors in the code editor now match the code view and light/dark themes - Code editor is now 12px instead of 14px font size to match code view and GitHub - Use a global style for kbd elements - When editing existing files, focus will be on codemirror instead of filename input. - Keyboard shortcuts are roughtly the same as VSCode - Add a "Find" button, useful for mobile - Add context menu similar to Monaco - Add a command palette (Ctrl/Cmd+Shift+P or F1) or via button - Add clickable URLs via Ctrl/Cmd+click - Add e2e test for the code editor - Remove `window.codeEditors` global - The main missing Monaco features are hover types and semantic rename but these were not fully working because monaco operated only on single files and only for JS/TS/HTML/CSS/JSON. | | Monaco (main) | CodeMirror (cm) | Delta | |---|---|---|---| | **Build time** | 7.8s | 5.3s | **-32%** | | **JS output** | 25 MB | 14 MB | **-44%** | | **CSS output** | 1.2 MB | 1012 KB | **-17%** | | **Total (no maps)** | 23.3 MB | 12.1 MB | **-48%** | Fixes: #36311 Fixes: #14776 Fixes: #12171 <img width="1333" height="555" alt="image" src="https://github.com/user-attachments/assets/f0fe3a28-1ed9-4f22-bf25-2b161501d7ce" /> --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
330
web_src/js/modules/codeeditor/main.ts
Normal file
330
web_src/js/modules/codeeditor/main.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import {extname} from '../../utils.ts';
|
||||
import {createElementFromHTML, toggleElem} from '../../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {svg} from '../../svg.ts';
|
||||
import {commandPalette} from './command-palette.ts';
|
||||
import type {PaletteCommand} from './command-palette.ts';
|
||||
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
|
||||
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
|
||||
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {LanguageDescription} from '@codemirror/language';
|
||||
import type {Compartment, Extension} from '@codemirror/state';
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
|
||||
// CodeEditorConfig is also used by backend, defined in "editor_util.go"
|
||||
const codeEditorConfigDefault = {
|
||||
filename: '', // the current filename (base name, not full path), used for language detection
|
||||
autofocus: false, // whether to autofocus the editor on load
|
||||
previewableExtensions: [] as string[], // file extensions that support preview rendering
|
||||
lineWrapExtensions: [] as string[], // file extensions that enable line wrapping by default
|
||||
lineWrap: false, // whether line wrapping is enabled for the current file
|
||||
|
||||
indentStyle: '', // "space" or "tab", from .editorconfig, or empty for not specified (detect from source code)
|
||||
indentSize: 0, // number of spaces per indent level, from .editorconfig, or 0 for not specified (detect from source code)
|
||||
tabWidth: 4, // display width of a tab character, from .editorconfig, defaults to 4
|
||||
trimTrailingWhitespace: false, // whether to trim trailing whitespace on save, from .editorconfig
|
||||
};
|
||||
type CodeEditorConfig = typeof codeEditorConfigDefault;
|
||||
|
||||
export type CodemirrorEditor = {
|
||||
view: EditorView;
|
||||
trimTrailingWhitespace: boolean;
|
||||
togglePalette: (view: EditorView) => boolean;
|
||||
updateFilename: (filename: string) => Promise<void>;
|
||||
languages: LanguageDescription[];
|
||||
compartments: {
|
||||
wordWrap: Compartment;
|
||||
language: Compartment;
|
||||
tabSize: Compartment;
|
||||
indentUnit: Compartment;
|
||||
lint: Compartment;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/autocomplete'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/language-data'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/lint'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/search'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/state'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/view'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@lezer/highlight'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@replit/codemirror-indentation-markers'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@replit/codemirror-vscode-keymap'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
|
||||
}
|
||||
|
||||
function togglePreviewDisplay(previewable: boolean): void {
|
||||
// FIXME: here and below, the selector is too broad, it should only query in the editor related scope
|
||||
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
|
||||
// the "preview tab" exists for "file code editor", but doesn't exist for "git hook editor"
|
||||
if (!previewTab) return;
|
||||
|
||||
toggleElem(previewTab, previewable);
|
||||
if (previewable) return;
|
||||
|
||||
// If not previewable but the "preview" tab was active (user changes the filename to a non-previewable one),
|
||||
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
|
||||
if (previewTab.classList.contains('active')) {
|
||||
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
|
||||
writeTab!.click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput?: HTMLInputElement): Promise<CodemirrorEditor> {
|
||||
const config: CodeEditorConfig = {
|
||||
...codeEditorConfigDefault,
|
||||
...JSON.parse(textarea.getAttribute('data-code-editor-config')!),
|
||||
};
|
||||
const previewableExts = new Set(config.previewableExtensions || []);
|
||||
const lineWrapExts = config.lineWrapExtensions || [];
|
||||
const cm = await importCodemirror();
|
||||
|
||||
const languageDescriptions: LanguageDescription[] = [
|
||||
...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', filename: /^(GNUm|M|m)akefile$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'code-editor-container';
|
||||
container.setAttribute('data-language', matchedLang?.name.toLowerCase() || '');
|
||||
// Replace the loading placeholder with the editor container in one operation
|
||||
// to avoid a flash where neither element is in the DOM.
|
||||
const loading = textarea.parentNode!.querySelector<HTMLElement>('.editor-loading');
|
||||
if (loading) {
|
||||
loading.replaceWith(container);
|
||||
} else {
|
||||
textarea.parentNode!.append(container);
|
||||
}
|
||||
|
||||
const loadedLang = matchedLang ? await matchedLang.load() : null;
|
||||
const wordWrap = new cm.state.Compartment();
|
||||
const language = new cm.state.Compartment();
|
||||
const tabSize = new cm.state.Compartment();
|
||||
const indentUnitComp = new cm.state.Compartment();
|
||||
const lintComp = new cm.state.Compartment();
|
||||
const palette = commandPalette(cm);
|
||||
|
||||
const goToSymbol = (view: EditorView) => {
|
||||
const symbols = collectSymbols(cm, view);
|
||||
const items: PaletteCommand[] = symbols.map((sym) => ({
|
||||
label: `${sym.label} (${sym.kind})`,
|
||||
keys: '',
|
||||
run: (v: EditorView) => v.dispatch({selection: {anchor: sym.from}, scrollIntoView: true}),
|
||||
}));
|
||||
palette.showWithItems(view, items, 'Go to symbol…');
|
||||
return true;
|
||||
};
|
||||
|
||||
const view = new cm.view.EditorView({
|
||||
doc: textarea.defaultValue, // use defaultValue to prevent browser from restoring form values on refresh
|
||||
parent: container,
|
||||
extensions: [
|
||||
cm.view.lineNumbers(),
|
||||
cm.language.codeFolding({
|
||||
placeholderDOM(_view: EditorView, onclick: (event: Event) => void) {
|
||||
const el = createElementFromHTML(html`<span class="cm-foldPlaceholder">${htmlRaw(svg('octicon-kebab-horizontal', 13))}</span>`);
|
||||
el.addEventListener('click', onclick);
|
||||
return el as unknown as HTMLElement;
|
||||
},
|
||||
}),
|
||||
cm.language.foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
|
||||
},
|
||||
}),
|
||||
cm.view.highlightActiveLineGutter(),
|
||||
cm.view.highlightSpecialChars(),
|
||||
cm.view.highlightActiveLine(),
|
||||
cm.view.drawSelection(),
|
||||
cm.view.dropCursor(),
|
||||
cm.view.rectangularSelection(),
|
||||
cm.view.crosshairCursor(),
|
||||
cm.view.placeholder(textarea.placeholder),
|
||||
config.trimTrailingWhitespace ? cm.view.highlightTrailingWhitespace() : [],
|
||||
cm.search.search({top: true}),
|
||||
cm.search.highlightSelectionMatches(),
|
||||
cm.view.keymap.of([
|
||||
...cm.vscodeKeymap.vscodeKeymap,
|
||||
...cm.search.searchKeymap,
|
||||
...cm.lint.lintKeymap,
|
||||
cm.commands.indentWithTab,
|
||||
{key: 'Mod-k Mod-x', run: (view) => { trimTrailingWhitespaceFromView(view); return true }, preventDefault: true},
|
||||
{key: 'Mod-Enter', run: cm.commands.insertBlankLine, preventDefault: true},
|
||||
{key: 'Mod-k Mod-k', run: cm.commands.deleteToLineEnd, preventDefault: true},
|
||||
{key: 'Mod-k Mod-Backspace', run: cm.commands.deleteToLineStart, preventDefault: true},
|
||||
]),
|
||||
cm.state.EditorState.allowMultipleSelections.of(true),
|
||||
cm.language.indentOnInput(),
|
||||
cm.language.syntaxHighlighting(cm.highlight.classHighlighter),
|
||||
cm.language.bracketMatching(),
|
||||
indentUnitComp.of(
|
||||
cm.language.indentUnit.of(
|
||||
config.indentStyle === 'tab' ? '\t' : ' '.repeat(config.indentSize || 4),
|
||||
),
|
||||
),
|
||||
cm.autocomplete.closeBrackets(),
|
||||
cm.autocomplete.autocompletion(),
|
||||
cm.state.EditorState.languageData.of(() => [{autocomplete: cm.autocomplete.completeAnyWord}]),
|
||||
cm.indentMarkers.indentationMarkers({
|
||||
colors: {
|
||||
light: 'transparent',
|
||||
dark: 'transparent',
|
||||
activeLight: 'var(--color-secondary-dark-3)',
|
||||
activeDark: 'var(--color-secondary-dark-3)',
|
||||
},
|
||||
}),
|
||||
cm.commands.history(),
|
||||
palette.extensions,
|
||||
cm.view.keymap.of([
|
||||
{key: 'Mod-Shift-o', run: goToSymbol, preventDefault: true},
|
||||
{key: 'Mod-F2', run: (v) => { selectAllOccurrences(cm, v); return true }, preventDefault: true},
|
||||
{key: 'F12', run: (v) => goToDefinitionAt(cm, v, v.state.selection.main.from), preventDefault: true},
|
||||
]),
|
||||
contextMenu(cm, palette.togglePalette, goToSymbol),
|
||||
clickableUrls(cm),
|
||||
tabSize.of(cm.state.EditorState.tabSize.of(config.tabWidth || 4)),
|
||||
wordWrap.of(config.lineWrap ? cm.view.EditorView.lineWrapping : []),
|
||||
language.of(loadedLang ?? []),
|
||||
lintComp.of(await getLinterExtension(cm, config.filename, loadedLang)),
|
||||
cm.view.EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (update.docChanged) {
|
||||
textarea.value = update.state.doc.toString();
|
||||
textarea.dispatchEvent(new Event('change')); // needed for jquery-are-you-sure
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const editor: CodemirrorEditor = {
|
||||
view,
|
||||
trimTrailingWhitespace: config.trimTrailingWhitespace,
|
||||
togglePalette: palette.togglePalette,
|
||||
updateFilename: async (filename: string) => {
|
||||
togglePreviewDisplay(previewableExts.has(extname(filename)));
|
||||
await updateEditorLanguage(cm, editor, filename, lineWrapExts);
|
||||
},
|
||||
languages: languageDescriptions,
|
||||
compartments: {wordWrap, language, tabSize, indentUnit: indentUnitComp, lint: lintComp},
|
||||
};
|
||||
|
||||
const elEditorOptions = textarea.closest('form')!.querySelector('.code-editor-options');
|
||||
if (elEditorOptions) {
|
||||
const indentStyleSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-style-select')!;
|
||||
const indentSizeSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-size-select')!;
|
||||
|
||||
const applyIndentSettings = (style: string, size: number) => {
|
||||
view.dispatch({
|
||||
effects: [
|
||||
indentUnitComp.reconfigure(cm.language.indentUnit.of(style === 'tab' ? '\t' : ' '.repeat(size))),
|
||||
tabSize.reconfigure(cm.state.EditorState.tabSize.of(size)),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
indentStyleSelect.addEventListener('change', () => {
|
||||
applyIndentSettings(indentStyleSelect.value, Number(indentSizeSelect.value) || 4);
|
||||
});
|
||||
|
||||
indentSizeSelect.addEventListener('change', () => {
|
||||
applyIndentSettings(indentStyleSelect.value || 'space', Number(indentSizeSelect.value) || 4);
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector('.js-code-find')!.addEventListener('click', () => {
|
||||
if (cm.search.searchPanelOpen(view.state)) {
|
||||
cm.search.closeSearchPanel(view);
|
||||
} else {
|
||||
cm.search.openSearchPanel(view);
|
||||
}
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector('.js-code-command-palette')!.addEventListener('click', () => {
|
||||
palette.togglePalette(view);
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector<HTMLSelectElement>('.js-line-wrap-select')!.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
view.dispatch({
|
||||
effects: wordWrap.reconfigure(target.value === 'on' ? cm.view.EditorView.lineWrapping : []),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
togglePreviewDisplay(previewableExts.has(extname(config.filename)));
|
||||
|
||||
if (config.autofocus) {
|
||||
editor.view.focus();
|
||||
} else if (filenameInput) {
|
||||
filenameInput.focus();
|
||||
}
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
// files that are JSONC despite having a .json extension
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext === '.json' || ext === '.map') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return [];
|
||||
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
|
||||
}
|
||||
|
||||
async function updateEditorLanguage(cm: CodemirrorModules, editor: CodemirrorEditor, filename: string, lineWrapExts: string[]): Promise<void> {
|
||||
const {compartments, view, languages: editorLanguages} = editor;
|
||||
|
||||
const newLanguage = cm.language.LanguageDescription.matchFilename(editorLanguages, filename);
|
||||
const newLoadedLang = newLanguage ? await newLanguage.load() : null;
|
||||
view.dom.closest('.code-editor-container')!.setAttribute('data-language', newLanguage?.name.toLowerCase() || '');
|
||||
view.dispatch(
|
||||
{
|
||||
effects: [
|
||||
compartments.wordWrap.reconfigure(
|
||||
lineWrapExts.includes(extname(filename).toLowerCase()) ? cm.view.EditorView.lineWrapping : [],
|
||||
),
|
||||
compartments.language.reconfigure(newLoadedLang ?? []),
|
||||
compartments.lint.reconfigure(await getLinterExtension(cm, filename, newLoadedLang)),
|
||||
],
|
||||
},
|
||||
// clear stale diagnostics from the previous language on filename change
|
||||
cm.lint.setDiagnostics(view.state, []),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user