mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-02 00:18:35 +01:00
- 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>
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
import {isMac, keySymbols} from '../../utils.ts';
|
|
import {trimTrailingWhitespaceFromView} from './utils.ts';
|
|
import type {EditorView} from '@codemirror/view';
|
|
import type {CodemirrorModules} from './main.ts';
|
|
|
|
export type PaletteCommand = {
|
|
label: string;
|
|
keys: string;
|
|
run: (view: EditorView) => void;
|
|
};
|
|
|
|
function formatKeys(keys: string): string[][] {
|
|
return keys.split(' ').map((chord) => chord.split('+').map((k) => keySymbols[k] || k));
|
|
}
|
|
|
|
export function commandPalette(cm: CodemirrorModules) {
|
|
const commands: PaletteCommand[] = [
|
|
{label: 'Undo', keys: 'Mod+Z', run: cm.commands.undo},
|
|
{label: 'Redo', keys: 'Mod+Shift+Z', run: cm.commands.redo},
|
|
{label: 'Find', keys: 'Mod+F', run: cm.search.openSearchPanel},
|
|
{label: 'Go to line', keys: 'Mod+Alt+G', run: cm.search.gotoLine},
|
|
{label: 'Select All', keys: 'Mod+A', run: cm.commands.selectAll},
|
|
{label: 'Delete Line', keys: 'Mod+Shift+K', run: cm.commands.deleteLine},
|
|
{label: 'Move Line Up', keys: 'Alt+Up', run: cm.commands.moveLineUp},
|
|
{label: 'Move Line Down', keys: 'Alt+Down', run: cm.commands.moveLineDown},
|
|
{label: 'Copy Line Up', keys: 'Shift+Alt+Up', run: cm.commands.copyLineUp},
|
|
{label: 'Copy Line Down', keys: 'Shift+Alt+Down', run: cm.commands.copyLineDown},
|
|
{label: 'Toggle Comment', keys: 'Mod+/', run: cm.commands.toggleComment},
|
|
{label: 'Insert Blank Line', keys: 'Mod+Enter', run: cm.commands.insertBlankLine},
|
|
{label: 'Add Cursor Above', keys: isMac ? 'Mod+Alt+Up' : 'Ctrl+Alt+Up', run: cm.commands.addCursorAbove},
|
|
{label: 'Add Cursor Below', keys: isMac ? 'Mod+Alt+Down' : 'Ctrl+Alt+Down', run: cm.commands.addCursorBelow},
|
|
{label: 'Add Next Occurrence', keys: 'Mod+D', run: cm.search.selectNextOccurrence},
|
|
{label: 'Go to Matching Bracket', keys: 'Mod+Shift+\\', run: cm.commands.cursorMatchingBracket},
|
|
{label: 'Indent More', keys: 'Mod+]', run: cm.commands.indentMore},
|
|
{label: 'Indent Less', keys: 'Mod+[', run: cm.commands.indentLess},
|
|
{label: 'Fold Code', keys: isMac ? 'Mod+Alt+[' : 'Ctrl+Shift+[', run: cm.language.foldCode},
|
|
{label: 'Unfold Code', keys: isMac ? 'Mod+Alt+]' : 'Ctrl+Shift+]', run: cm.language.unfoldCode},
|
|
{label: 'Fold All', keys: 'Ctrl+Alt+[', run: cm.language.foldAll},
|
|
{label: 'Unfold All', keys: 'Ctrl+Alt+]', run: cm.language.unfoldAll},
|
|
{label: 'Trigger Autocomplete', keys: 'Ctrl+Space', run: cm.autocomplete.startCompletion},
|
|
{label: 'Trim Trailing Whitespace', keys: 'Mod+K Mod+X', run: trimTrailingWhitespaceFromView},
|
|
];
|
|
|
|
let overlay: HTMLElement | null = null;
|
|
let filtered: PaletteCommand[] = [];
|
|
let selectedIndex = 0;
|
|
let cleanupClickOutside: (() => void) | null = null;
|
|
|
|
function hide(view: EditorView) {
|
|
if (!overlay) return;
|
|
cleanupClickOutside?.();
|
|
cleanupClickOutside = null;
|
|
overlay.remove();
|
|
overlay = null;
|
|
view.focus();
|
|
}
|
|
|
|
function renderList(list: HTMLElement, query: string) {
|
|
list.textContent = '';
|
|
if (!filtered.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'cm-command-palette-empty';
|
|
empty.textContent = 'No matches';
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
for (const [index, cmd] of filtered.entries()) {
|
|
const item = document.createElement('div');
|
|
item.className = 'cm-command-palette-item';
|
|
item.setAttribute('role', 'option');
|
|
item.setAttribute('data-index', String(index));
|
|
if (index === selectedIndex) item.setAttribute('aria-selected', 'true');
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'cm-command-palette-label';
|
|
const matchIndex = query ? cmd.label.toLowerCase().indexOf(query) : -1;
|
|
if (matchIndex !== -1) {
|
|
label.append(cmd.label.slice(0, matchIndex));
|
|
const mark = document.createElement('mark');
|
|
mark.textContent = cmd.label.slice(matchIndex, matchIndex + query.length);
|
|
label.append(mark, cmd.label.slice(matchIndex + query.length));
|
|
} else {
|
|
label.textContent = cmd.label;
|
|
}
|
|
item.append(label);
|
|
|
|
if (cmd.keys) {
|
|
const keysEl = document.createElement('span');
|
|
keysEl.className = 'cm-command-palette-keys';
|
|
for (const [chordIndex, chord] of formatKeys(cmd.keys).entries()) {
|
|
if (chordIndex > 0) keysEl.append('→');
|
|
for (const k of chord) {
|
|
const kbd = document.createElement('kbd');
|
|
kbd.textContent = k;
|
|
keysEl.append(kbd);
|
|
}
|
|
}
|
|
item.append(keysEl);
|
|
}
|
|
list.append(item);
|
|
}
|
|
}
|
|
|
|
function show(view: EditorView, items?: PaletteCommand[], placeholder?: string) {
|
|
const container = view.dom.closest('.code-editor-container')!;
|
|
overlay = document.createElement('div');
|
|
overlay.className = 'cm-command-palette';
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'cm-command-palette-input';
|
|
input.placeholder = placeholder || 'Type a command...';
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'cm-command-palette-list';
|
|
list.setAttribute('role', 'listbox');
|
|
|
|
const source = items || commands;
|
|
filtered = source;
|
|
selectedIndex = 0;
|
|
|
|
const updateSelected = () => {
|
|
list.querySelector('[aria-selected]')?.removeAttribute('aria-selected');
|
|
const el = list.children[selectedIndex] as HTMLElement | undefined;
|
|
if (el) {
|
|
el.setAttribute('aria-selected', 'true');
|
|
if (el.offsetTop < list.scrollTop) {
|
|
list.scrollTop = el.offsetTop;
|
|
} else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) {
|
|
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
|
|
}
|
|
}
|
|
};
|
|
|
|
const execute = (cmd: PaletteCommand) => {
|
|
hide(view);
|
|
cmd.run(view);
|
|
};
|
|
|
|
list.addEventListener('pointerover', (e) => {
|
|
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
|
|
if (!item) return;
|
|
selectedIndex = Number(item.getAttribute('data-index'));
|
|
updateSelected();
|
|
});
|
|
|
|
list.addEventListener('mousedown', (e) => {
|
|
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
|
|
if (!item) return;
|
|
e.preventDefault();
|
|
const cmd = filtered[Number(item.getAttribute('data-index'))];
|
|
if (cmd) execute(cmd);
|
|
});
|
|
|
|
input.addEventListener('input', () => {
|
|
const q = input.value.toLowerCase();
|
|
filtered = q ? source.filter((cmd) => cmd.label.toLowerCase().includes(q)) : source;
|
|
selectedIndex = 0;
|
|
renderList(list, q);
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
selectedIndex = (selectedIndex + 1) % filtered.length;
|
|
updateSelected();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length;
|
|
updateSelected();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (filtered[selectedIndex]) execute(filtered[selectedIndex]);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
hide(view);
|
|
}
|
|
});
|
|
|
|
overlay.append(input, list);
|
|
container.append(overlay);
|
|
renderList(list, '');
|
|
input.focus();
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const target = e.target as Element;
|
|
if (overlay && !overlay.contains(target) && !target.closest('.js-code-command-palette')) {
|
|
hide(view);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
cleanupClickOutside = () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
function showWithItems(view: EditorView, items: PaletteCommand[], placeholder: string) {
|
|
if (overlay) hide(view);
|
|
show(view, items, placeholder);
|
|
}
|
|
|
|
function togglePalette(view: EditorView) {
|
|
if (overlay) {
|
|
hide(view);
|
|
} else {
|
|
show(view);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
extensions: cm.view.keymap.of([
|
|
{key: 'Mod-Shift-p', run: togglePalette, preventDefault: true},
|
|
{key: 'F1', run: togglePalette, preventDefault: true},
|
|
]),
|
|
togglePalette,
|
|
showWithItems,
|
|
};
|
|
}
|