mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-02 08:24:18 +01:00
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>
331 lines
14 KiB
TypeScript
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, []),
|
|
);
|
|
}
|