0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-04 23:10:38 +02:00
gitea/web_src/js/features/repo-editor.ts
silverwind e2e8509239
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>
2026-03-31 21:50:45 +00:00

214 lines
9.4 KiB
TypeScript

import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from '../modules/codeeditor/main.ts';
import {trimTrailingWhitespaceFromView} from '../modules/codeeditor/utils.ts';
import {hideElem, queryElems, showElem, createElementFromHTML, onInputDebounce} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
if (!elPreviewTab || !elPreviewPanel) return;
elPreviewTab.addEventListener('click', async () => {
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
let previewContext = `${previewContextRef}/${elTreePath.value}`;
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
formData.append('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
}
export function initRepoEditor() {
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
if (dropzoneUpload) initDropzone(dropzoneUpload);
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
if (el.value === 'commit-to-new-branch') {
showElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = true;
} else {
hideElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = false;
}
document.querySelector('#commit-button')!.textContent = el.getAttribute('data-button-text');
});
}
// ATTENTION: two pages have this filename input
// * new/edit file page: there is a code editor
// * upload page: there is no code editor, but a uploader
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
if (!filenameInput) return;
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
function joinTreePath() {
const parts = [];
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
const link = el.querySelector('a');
parts.push(link ? link.textContent : el.textContent);
}
if (filenameInput.value) {
parts.push(filenameInput.value);
}
document.querySelector<HTMLInputElement>('#tree_path')!.value = parts.join('/');
}
filenameInput.addEventListener('input', function () {
const parts = filenameInput.value.split('/');
const links = Array.from(document.querySelectorAll('.breadcrumb span.section'));
const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider'));
let warningDiv = document.querySelector<HTMLDivElement>('.ui.warning.message.flash-message.flash-warning.space-related');
let containSpace = false;
if (parts.length > 1) {
for (let i = 0; i < parts.length; ++i) {
const value = parts[i];
const trimValue = value.trim();
if (trimValue === '..') {
// remove previous tree path
if (links.length > 0) {
const link = links.pop()!;
const divider = dividers.pop()!;
link.remove();
divider.remove();
}
continue;
}
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
filenameInput.before(linkElement);
filenameInput.before(dividerElement);
}
} else {
filenameInput.value = value;
}
this.setSelectionRange(0, 0);
containSpace = containSpace || (trimValue !== value && trimValue !== '');
}
}
containSpace = containSpace || Array.from(links).some((link) => {
const value = link.querySelector('a')!.textContent;
return value.trim() !== value;
});
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
if (containSpace) {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Change to `block` display because it is set to 'none' in fomantic/build/semantic.css
warningDiv.classList.add('tw-block');
const inputContainer = document.querySelector('.repo-editor-header')!;
inputContainer.insertAdjacentElement('beforebegin', warningDiv);
}
showElem(warningDiv);
} else if (warningDiv) {
hideElem(warningDiv);
}
joinTreePath();
});
filenameInput.addEventListener('keydown', function (e) {
const sections = queryElems(document, '.breadcrumb span.section');
const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider');
// Jump back to last directory once the filename is empty
if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) {
e.preventDefault();
const lastSection = sections[sections.length - 1];
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
const value = lastSection.querySelector('a')!.textContent;
filenameInput.value = value + filenameInput.value;
this.setSelectionRange(value.length, value.length);
lastDivider?.remove();
lastSection.remove();
joinTreePath();
}
});
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form')!;
// see the ATTENTION above, on the upload page, there is no editor(textarea)
// so only the filename input above is initialized, the code below (for the code editor) will be skipped
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
// to enable or disable the commit button
const commitButton = document.querySelector<HTMLButtonElement>('#commit-button')!;
const dirtyFileClass = 'dirty-file';
const syncCommitButtonState = () => {
const dirty = elForm.classList.contains(dirtyFileClass);
commitButton.disabled = !dirty;
};
// Registering a custom listener for the file path and the file content
// FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
applyAreYouSure(elForm, {
silent: true,
dirtyClass: dirtyFileClass,
fieldSelector: ':input:not(.commit-form-wrapper :input)',
change: syncCommitButtonState,
});
syncCommitButtonState(); // disable the "commit" button when no content changes
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
filenameInput.addEventListener('input', onInputDebounce(() => editor.updateFilename(filenameInput.value)));
// Update the editor from query params, if available,
// only after the dirtyFileClass initialization
const params = new URLSearchParams(window.location.search);
const value = params.get('value');
if (value) {
editor.view.dispatch({
changes: {from: 0, to: editor.view.state.doc.length, insert: value},
});
}
commitButton.addEventListener('click', async (e) => {
if (editor.trimTrailingWhitespace) {
trimTrailingWhitespaceFromView(editor.view);
}
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
if (await confirmModal({
header: elForm.getAttribute('data-text-empty-confirm-header')!,
content: elForm.getAttribute('data-text-empty-confirm-content')!,
})) {
ignoreAreYouSure(elForm);
submitFormFetchAction(elForm);
}
}
});
})();
}
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
// the content is from the server, so it is safe to use innerHTML
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
}