Files
gitea/web_src/js/modules/codeeditor/command-palette.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

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,
};
}