Files
gitea/web_src/js/modules/codeeditor/main.ts
silverwind 47a0d88056 Remove leftover webpackChunkName comments from codeeditor (#37062)
Followup to https://github.com/go-gitea/gitea/pull/36764, forgot to
remove this from the vite migration.

---
This PR was written with the help of Claude Opus 4.6

Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
2026-03-31 21:31:11 -07:00

331 lines
14 KiB
TypeScript

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('@codemirror/autocomplete'),
import('@codemirror/commands'),
import('@codemirror/language'),
import('@codemirror/language-data'),
import('@codemirror/lint'),
import('@codemirror/search'),
import('@codemirror/state'),
import('@codemirror/view'),
import('@lezer/highlight'),
import('@replit/codemirror-indentation-markers'),
import('@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, []),
);
}