mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-05 12:11:04 +02:00
31cee60cc7
1. Make the content area stretch the box, enabling text selection to start over empty space. 2. Disable linter for markdown, it can never produce lint errors, this hides the unnecessary lint gutter on markdown files. 3. Verified all languages linter enablement, all accurate. 4. Refactor `getLinterExtension` to not rely on file extensions. 5. Include jsonc/json5 extensions in regex. --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: Nicolas <bircni@icloud.com>
334 lines
15 KiB
TypeScript
334 lines
15 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, LanguageSupport} 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 the JSON parser is too strict for (comments, trailing commas)
|
|
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;
|
|
|
|
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
|
|
if (!loadedLang) return [];
|
|
const lang = loadedLang.language;
|
|
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
|
if (lang instanceof cm.language.StreamLanguage) return [];
|
|
if (lang.name === 'json') {
|
|
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
|
}
|
|
// markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
|
|
if (lang.name === 'markdown') 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, []),
|
|
);
|
|
}
|