Initial Commit

This commit is contained in:
2026-09-07 15:16:52 -04:00
commit e8ce633e5e
54 changed files with 13079 additions and 0 deletions

84
frontend/vim.ts Normal file
View File

@@ -0,0 +1,84 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations';
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching';
type Navigation = {
navigate: (kind: 'definition' | 'references' | 'callers' | 'hover', openFirst?: boolean) => Promise<void>;
step: (direction: number, count?: number) => Promise<void>;
back: () => Promise<void>;
};
// A reading-only key layer over Monaco's existing motions, search, and folding.
// No insert mode, editing operators, or separate text buffer.
export function installVim(editor: monaco.editor.IStandaloneCodeEditor, navigation: Navigation) {
let prefix = '', count = '', repeat: 'search' | 'locations' = 'search';
const indicator = document.createElement('span'); indicator.id = 'vim-status';
indicator.title = 'Vim reading: hjkl, w/b/e, gg/G, gd definition, gc callers, gr references, n/N next/previous, / search, Ctrl+o back, za fold. No insert mode.';
document.querySelector('footer')!.insertBefore(indicator, document.getElementById('cursor'));
const update = () => { indicator.textContent = `VIM · READ ONLY${count || prefix ? ` · ${count}${prefix}` : ''} · n: ${repeat}`; };
const clear = () => { prefix = ''; count = ''; update(); };
const trigger = (id: string, amount = 1) => { for (let i = 0; i < amount; i++) editor.trigger('vim', id, null); };
editor.onDidBlurEditorText(clear);
editor.onDidChangeModel(clear);
update();
const root = editor.getContainerDomNode();
root.addEventListener('keydown', event => {
if (event.isComposing || event.altKey || event.metaKey) return;
// Find and other input widgets keep ordinary text entry and shortcuts.
if (!editor.hasTextFocus()) {
if ((event.key === 'Enter' || event.key === 'Escape') && (event.target as HTMLElement).closest('.find-widget')) {
event.preventDefault(); event.stopImmediatePropagation();
trigger('closeFindWidget'); editor.focus(); repeat = 'search'; clear();
}
return;
}
if (!editor.getModel()) return;
const key = event.key;
if (event.ctrlKey && !['d', 'u', 'f', 'b', 'o', '['].includes(key.toLowerCase())) return;
if (!event.ctrlKey && (key.length !== 1 && key !== 'Escape')) { clear(); return; }
event.preventDefault(); event.stopImmediatePropagation();
const amount = Math.min(Number(count) || 1, 1000);
if (event.ctrlKey) {
clear();
if (key === 'o') { void navigation.back(); return; }
if (key === '[') { trigger('cancelSelection'); return; }
const down = key === 'd' || key === 'f';
const lines = Math.max(1, Math.floor(editor.getLayoutInfo().height / editor.getOption(monaco.editor.EditorOption.lineHeight) / (key === 'd' || key === 'u' ? 2 : 1)));
editor.trigger('vim', 'cursorMove', { to: down ? 'down' : 'up', by: 'wrappedLine', value: Math.min(lines * amount, 100000) });
return;
}
if (key === 'Escape') { clear(); trigger('cancelSelection'); return; }
if (/^[0-9]$/.test(key) && (key !== '0' || count)) { count = String(Math.min(Number(count + key), 1000)); update(); return; }
if (prefix) {
const chord = prefix + key; clear();
const semantic = { gd: 'definition', gc: 'callers', gr: 'references' } as const;
if (chord in semantic) { repeat = 'locations'; update(); void navigation.navigate(semantic[chord as keyof typeof semantic], true); }
else if (chord === 'gg') { editor.setPosition({ lineNumber: amount, column: 1 }); editor.revealPositionInCenter(editor.getPosition()!); }
else if (chord === 'ge') trigger('cursorWordEndLeft', amount);
else if (chord === 'za') trigger('editor.toggleFold');
else if (chord === 'zc') trigger('editor.fold');
else if (chord === 'zo') trigger('editor.unfold');
else if (chord === 'zM') trigger('editor.foldAll');
else if (chord === 'zR') trigger('editor.unfoldAll');
else if (chord === 'zz') editor.revealPositionInCenter(editor.getPosition()!);
return;
}
if (key === 'g' || key === 'z') { prefix = key; update(); return; }
const hadCount = !!count; clear();
if (key === 'G') {
editor.setPosition({ lineNumber: hadCount ? Math.min(amount, editor.getModel()!.getLineCount()) : editor.getModel()!.getLineCount(), column: 1 }); editor.revealPositionInCenter(editor.getPosition()!);
} else if (key === '/') { repeat = 'search'; update(); trigger('actions.find'); }
else if (key === 'n' || key === 'N') {
if (repeat === 'locations') void navigation.step(key === 'n' ? 1 : -1, amount);
else trigger(key === 'n' ? 'editor.action.nextMatchFindAction' : 'editor.action.previousMatchFindAction', amount);
} else if (key === 'K') void navigation.navigate('hover');
else {
const motions: Record<string, string> = { h: 'cursorLeft', j: 'cursorDown', k: 'cursorUp', l: 'cursorRight', w: 'cursorWordStartRight', b: 'cursorWordStartLeft', e: 'cursorWordEndRight', '0': 'cursorHome', '^': 'cursorHome', '$': 'cursorEnd', '%': 'editor.action.jumpToBracket' };
if (key === '0' || key === '^') {
const p = editor.getPosition()!;
editor.setPosition({ lineNumber: p.lineNumber, column: key === '0' ? 1 : editor.getModel()!.getLineFirstNonWhitespaceColumn(p.lineNumber) || 1 });
} else if (motions[key]) trigger(motions[key], amount);
// Other printable keys are consumed: i/a/o/d/c/x never edit or enter a mode.
}
}, true);
return { reset() { repeat = 'search'; clear(); } };
}