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; step: (direction: number, count?: number) => Promise; back: () => Promise; }; // 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 = { 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(); } }; }