import * as vscode from 'vscode'; import { CompositionState } from './src/state/compositionState'; import { Globals } from './src/globals'; import { Jump } from './src/jumps/jump'; import { ModeHandler } from './src/mode/modeHandler'; import { ModeHandlerMap } from './src/mode/modeHandlerMap'; import { Mode } from './src/mode/mode'; import { Notation } from './src/configuration/notation'; import { Logger } from './src/util/logger'; import { StatusBar } from './src/statusBar'; import { VSCodeContext } from './src/util/vscodeContext'; import { ExCommandLine, SearchCommandLine } from './src/cmd_line/commandLine'; import { configuration } from './src/configuration/configuration'; import { globalState } from './src/state/globalState'; import { taskQueue } from './src/taskQueue'; import { Register } from './src/register/register'; import { SpecialKeys } from './src/util/specialKeys'; import { HistoryTracker } from './src/history/historyTracker'; let extensionContext: vscode.ExtensionContext; let previousActiveEditorUri: vscode.Uri | undefined; let lastClosedModeHandler: ModeHandler | null = null; interface ICodeKeybinding { after?: string[]; commands?: Array<{ command: string; args: any[] }>; } export async function getAndUpdateModeHandler( forceSyncAndUpdate = false ): Promise { const activeTextEditor = vscode.window.activeTextEditor; if (activeTextEditor === undefined || activeTextEditor.document.isClosed) { return undefined; } const [curHandler, isNew] = await ModeHandlerMap.getOrCreate(activeTextEditor); if (isNew) { extensionContext.subscriptions.push(curHandler); } curHandler.vimState.editor = activeTextEditor; if ( forceSyncAndUpdate || !previousActiveEditorUri || previousActiveEditorUri !== activeTextEditor.document.uri ) { // We sync the cursors here because ModeHandler is specific to a document, not an editor, so we // need to update our representation of the cursors when switching between editors for the same document. // This will be unnecessary once #4889 is fixed. curHandler.syncCursors(); await curHandler.updateView({ drawSelection: false, revealRange: false }); } previousActiveEditorUri = activeTextEditor.document.uri; if (curHandler.focusChanged) { curHandler.focusChanged = false; if (previousActiveEditorUri) { const prevHandler = ModeHandlerMap.get(previousActiveEditorUri); prevHandler!.focusChanged = true; } } return curHandler; } /** * Loads and validates the user's configuration */ async function loadConfiguration() { const validatorResults = await configuration.load(); Logger.configChanged(configuration); const logger = Logger.get('Configuration'); logger.debug(`${validatorResults.numErrors} errors found with vim configuration`); if (validatorResults.numErrors > 0) { for (const validatorResult of validatorResults.get()) { switch (validatorResult.level) { case 'error': logger.error(validatorResult.message); break; case 'warning': logger.warn(validatorResult.message); break; } } } } /** * The extension's entry point */ export async function activate(context: vscode.ExtensionContext, handleLocal: boolean = true) { // before we do anything else, we need to load the configuration await loadConfiguration(); const logger = Logger.get('Extension Startup'); logger.debug('Start'); extensionContext = context; extensionContext.subscriptions.push(StatusBar); // Load state Register.loadFromDisk(handleLocal); await Promise.all([ExCommandLine.loadHistory(context), SearchCommandLine.loadHistory(context)]); if (vscode.window.activeTextEditor) { const filepathComponents = vscode.window.activeTextEditor.document.fileName.split(/\\|\//); Register.setReadonlyRegister('%', filepathComponents[filepathComponents.length - 1]); } // workspace events registerEventListener( context, vscode.workspace.onDidChangeConfiguration, async () => { await loadConfiguration(); }, false ); registerEventListener(context, vscode.workspace.onDidChangeTextDocument, async (event) => { const textWasDeleted = (changeEvent: vscode.TextDocumentChangeEvent) => changeEvent.contentChanges.length === 1 && changeEvent.contentChanges[0].text === '' && changeEvent.contentChanges[0].range.start.line !== changeEvent.contentChanges[0].range.end.line; const textWasAdded = (changeEvent: vscode.TextDocumentChangeEvent) => changeEvent.contentChanges.length === 1 && (changeEvent.contentChanges[0].text === '\n' || changeEvent.contentChanges[0].text === '\r\n') && changeEvent.contentChanges[0].range.start.line === changeEvent.contentChanges[0].range.end.line; if (textWasDeleted(event)) { globalState.jumpTracker.handleTextDeleted(event.document, event.contentChanges[0].range); } else if (textWasAdded(event)) { globalState.jumpTracker.handleTextAdded( event.document, event.contentChanges[0].range, event.contentChanges[0].text ); } // Change from VSCode editor should set document.isDirty to true but they initially don't! // There is a timing issue in VSCode codebase between when the isDirty flag is set and // when registered callbacks are fired. https://github.com/Microsoft/vscode/issues/11339 const contentChangeHandler = (modeHandler: ModeHandler) => { if (modeHandler.vimState.currentMode === Mode.Insert) { if (modeHandler.vimState.historyTracker.currentContentChanges === undefined) { modeHandler.vimState.historyTracker.currentContentChanges = []; } modeHandler.vimState.historyTracker.currentContentChanges = modeHandler.vimState.historyTracker.currentContentChanges.concat(event.contentChanges); } }; ModeHandlerMap.getAll() .filter((modeHandler) => modeHandler.vimState.documentUri === event.document.uri) .forEach((modeHandler) => { contentChangeHandler(modeHandler); }); if (handleLocal) { setTimeout(() => { if ( !event.document.isDirty && !event.document.isUntitled && event.document.uri.scheme !== 'vscode-notebook-cell' && // TODO: Notebooks never seem to be marked dirty... event.contentChanges.length ) { handleContentChangedFromDisk(event.document); } }, 0); } }); registerEventListener( context, vscode.workspace.onDidCloseTextDocument, async (closedDocument) => { const documents = vscode.workspace.textDocuments; // Delete modehandler once all tabs of this document have been closed for (const uri of ModeHandlerMap.keys()) { const modeHandler = ModeHandlerMap.get(uri); let shouldDelete = false; if (modeHandler == null) { shouldDelete = true; } else { const document = modeHandler.vimState.document; if (!documents.includes(document)) { shouldDelete = true; if (closedDocument === document) { lastClosedModeHandler = modeHandler; } } } if (shouldDelete) { ModeHandlerMap.delete(uri); } } }, false ); // window events registerEventListener( context, vscode.window.onDidChangeActiveTextEditor, async () => { const mhPrevious: ModeHandler | undefined = previousActiveEditorUri ? ModeHandlerMap.get(previousActiveEditorUri) : undefined; // Track the closed editor so we can use it the next time an open event occurs. // When vscode changes away from a temporary file, onDidChangeActiveTextEditor first twice. // First it fires when leaving the closed editor. Then onDidCloseTextDocument first, and we delete // the old ModeHandler. Then a new editor opens. // // This also applies to files that are merely closed, which allows you to jump back to that file similarly // once a new file is opened. lastClosedModeHandler = mhPrevious || lastClosedModeHandler; const activeTextEditor = vscode.window.activeTextEditor; const oldFileRegister = (await Register.get('%'))?.text; const relativePath = activeTextEditor ? vscode.workspace.asRelativePath(activeTextEditor.document.uri, false) : ''; if (relativePath !== oldFileRegister) { if (oldFileRegister && oldFileRegister !== '') { Register.setReadonlyRegister('#', oldFileRegister as string); } Register.setReadonlyRegister('%', relativePath); } if (activeTextEditor === undefined) { return; } taskQueue.enqueueTask(async () => { const mh = await getAndUpdateModeHandler(true); if (mh) { globalState.jumpTracker.handleFileJump( lastClosedModeHandler ? Jump.fromStateNow(lastClosedModeHandler.vimState) : null, Jump.fromStateNow(mh.vimState) ); } }); }, true, true ); registerEventListener( context, vscode.window.onDidChangeTextEditorSelection, async (e: vscode.TextEditorSelectionChangeEvent) => { if ( vscode.window.activeTextEditor === undefined || e.textEditor.document !== vscode.window.activeTextEditor.document ) { // We don't care if user selection changed in a paneled window (e.g debug console/terminal) return; } const mh = ModeHandlerMap.get(vscode.window.activeTextEditor.document.uri); if (mh === undefined) { // We don't care if there is no active editor return; } if (e.kind !== vscode.TextEditorSelectionChangeKind.Mouse) { const selectionsHash = e.selections.reduce( (hash, s) => hash + `[${s.anchor.line}, ${s.anchor.character}; ${s.active.line}, ${s.active.character}]`, '' ); const idx = mh.selectionsChanged.ourSelections.indexOf(selectionsHash); if (idx > -1) { mh.selectionsChanged.ourSelections.splice(idx, 1); logger.debug( `Selections: Ignoring selection: ${selectionsHash}, Count left: ${mh.selectionsChanged.ourSelections.length}` ); return; } else if (mh.selectionsChanged.ignoreIntermediateSelections) { logger.debug(`Selections: ignoring intermediate selection change: ${selectionsHash}`); return; } else if (mh.selectionsChanged.ourSelections.length > 0) { // Some intermediate selection must have slipped in after setting the // 'ignoreIntermediateSelections' to false. Which means we didn't count // for it yet, but since we have selections to be ignored then we probably // wanted this one to be ignored as well. logger.debug(`Selections: Ignoring slipped selection: ${selectionsHash}`); return; } } // We may receive changes from other panels when, having selections in them containing the same file // and changing text before the selection in current panel. if (e.textEditor !== mh.vimState.editor) { return; } if (mh.focusChanged) { mh.focusChanged = false; return; } if (mh.currentMode === Mode.EasyMotionMode) { return; } taskQueue.enqueueTask(() => mh.handleSelectionChange(e)); }, true, false ); registerEventListener( context, vscode.window.onDidChangeTextEditorVisibleRanges, async (e: vscode.TextEditorVisibleRangesChangeEvent) => { taskQueue.enqueueTask(async () => { // Scrolling the viewport clears any status bar message, even errors. const mh = await getAndUpdateModeHandler(); if (mh && StatusBar.lastMessageTime) { // TODO: Using the time elapsed works most of the time, but is a bit of a hack const timeElapsed = Date.now() - Number(StatusBar.lastMessageTime); if (timeElapsed > 100) { StatusBar.clear(mh.vimState, true); } } }); } ); const compositionState = new CompositionState(); // Override VSCode commands overrideCommand(context, 'type', async (args) => { taskQueue.enqueueTask(async () => { const mh = await getAndUpdateModeHandler(); if (mh) { if (compositionState.isInComposition) { compositionState.composingText += args.text; if (mh.vimState.currentMode === Mode.Insert) { compositionState.insertedText = true; vscode.commands.executeCommand('default:type', { text: args.text }); } } else { await mh.handleKeyEvent(args.text); } } }); }); overrideCommand(context, 'replacePreviousChar', async (args) => { taskQueue.enqueueTask(async () => { const mh = await getAndUpdateModeHandler(); if (mh) { if (compositionState.isInComposition) { compositionState.composingText = compositionState.composingText.substr( 0, compositionState.composingText.length - args.replaceCharCnt ) + args.text; } if (compositionState.insertedText) { await vscode.commands.executeCommand('default:replacePreviousChar', { text: args.text, replaceCharCnt: args.replaceCharCnt, }); mh.vimState.cursorStopPosition = mh.vimState.editor.selection.start; mh.vimState.cursorStartPosition = mh.vimState.editor.selection.start; } } else { await vscode.commands.executeCommand('default:replacePreviousChar', { text: args.text, replaceCharCnt: args.replaceCharCnt, }); } }); }); overrideCommand(context, 'compositionStart', async () => { taskQueue.enqueueTask(async () => { compositionState.isInComposition = true; }); }); overrideCommand(context, 'compositionEnd', async () => { taskQueue.enqueueTask(async () => { const mh = await getAndUpdateModeHandler(); if (mh) { if (compositionState.insertedText) { mh.selectionsChanged.ignoreIntermediateSelections = true; await vscode.commands.executeCommand('default:replacePreviousChar', { text: '', replaceCharCnt: compositionState.composingText.length, }); mh.vimState.cursorStopPosition = mh.vimState.editor.selection.active; mh.vimState.cursorStartPosition = mh.vimState.editor.selection.active; mh.selectionsChanged.ignoreIntermediateSelections = false; } const text = compositionState.composingText; await mh.handleMultipleKeyEvents(text.split('')); } compositionState.reset(); }); }); // Register extension commands registerCommand(context, 'vim.showQuickpickCmdLine', async () => { const mh = await getAndUpdateModeHandler(); if (mh) { const cmd = await vscode.window.showInputBox({ prompt: 'Vim command line', value: '', ignoreFocusOut: false, valueSelection: [0, 0], }); if (cmd) { await new ExCommandLine(cmd, mh.vimState.currentMode).run(mh.vimState); } mh.updateView(); } }); registerCommand(context, 'vim.remap', async (args: ICodeKeybinding) => { taskQueue.enqueueTask(async () => { const mh = await getAndUpdateModeHandler(); if (mh === undefined) { return; } if (!args) { throw new Error( "'args' is undefined. For this remap to work it needs to have 'args' with an '\"after\": string[]' and/or a '\"commands\": { command: string; args: any[] }[]'" ); } if (args.after) { for (const key of args.after) { await mh.handleKeyEvent(Notation.NormalizeKey(key, configuration.leader)); } } if (args.commands) { for (const command of args.commands) { // Check if this is a vim command by looking for : if (command.command.startsWith(':')) { await new ExCommandLine( command.command.slice(1, command.command.length), mh.vimState.currentMode ).run(mh.vimState); mh.updateView(); } else { vscode.commands.executeCommand(command.command, command.args); } } } }); }); registerCommand(context, 'toggleVim', async () => { configuration.disableExtension = !configuration.disableExtension; toggleExtension(configuration.disableExtension, compositionState); }); for (const boundKey of configuration.boundKeyCombinations) { const command = ['', ''].includes(boundKey.key) ? async () => { const mh = await getAndUpdateModeHandler(); if (mh && !(await forceStopRecursiveRemap(mh))) { await mh.handleKeyEvent(`${boundKey.key}`); } } : async () => { const mh = await getAndUpdateModeHandler(); if (mh) { await mh.handleKeyEvent(`${boundKey.key}`); } }; registerCommand(context, boundKey.command, async () => { taskQueue.enqueueTask(command); }); } { // Initialize mode handler for current active Text Editor at startup. const modeHandler = await getAndUpdateModeHandler(); if (modeHandler) { if (!configuration.startInInsertMode) { const vimState = modeHandler.vimState; // Make sure no cursors start on the EOL character (which is invalid in normal mode) // This can happen if we quit last session in insert mode at the end of the line vimState.cursors = vimState.cursors.map((cursor) => { const eolColumn = vimState.document.lineAt(cursor.stop).text.length; if (cursor.stop.character >= eolColumn) { const character = Math.max(eolColumn - 1, 0); return cursor.withNewStop(cursor.stop.with({ character })); } else { return cursor; } }); } // This is called last because getAndUpdateModeHandler() will change cursor modeHandler.updateView({ drawSelection: true, revealRange: false }); } } // Disable automatic keyboard navigation in lists, so it doesn't interfere // with our list navigation keybindings await VSCodeContext.set('listAutomaticKeyboardNavigation', false); await toggleExtension(configuration.disableExtension, compositionState); logger.debug('Finish.'); } /** * Toggles the VSCodeVim extension between Enabled mode and Disabled mode. This * function is activated by calling the 'toggleVim' command from the Command Palette. * * @param isDisabled if true, sets VSCodeVim to Disabled mode; else sets to enabled mode */ async function toggleExtension(isDisabled: boolean, compositionState: CompositionState) { await VSCodeContext.set('vim.active', !isDisabled); const mh = await getAndUpdateModeHandler(); if (mh) { if (isDisabled) { await mh.handleKeyEvent(SpecialKeys.ExtensionDisable); compositionState.reset(); ModeHandlerMap.clear(); } else { await mh.handleKeyEvent(SpecialKeys.ExtensionEnable); } } } function overrideCommand( context: vscode.ExtensionContext, command: string, callback: (...args: any[]) => any ) { const disposable = vscode.commands.registerCommand(command, async (args) => { if (configuration.disableExtension) { return vscode.commands.executeCommand('default:' + command, args); } if (!vscode.window.activeTextEditor) { return; } if ( vscode.window.activeTextEditor.document && vscode.window.activeTextEditor.document.uri.toString() === 'debug:input' ) { return vscode.commands.executeCommand('default:' + command, args); } return callback(args); }); context.subscriptions.push(disposable); } export function registerCommand( context: vscode.ExtensionContext, command: string, callback: (...args: any[]) => any, requiresActiveEditor: boolean = true ) { const disposable = vscode.commands.registerCommand(command, async (args) => { if (requiresActiveEditor && !vscode.window.activeTextEditor) { return; } callback(args); }); context.subscriptions.push(disposable); } export function registerEventListener( context: vscode.ExtensionContext, event: vscode.Event, listener: (e: T) => void, exitOnExtensionDisable = true, exitOnTests = false ) { const disposable = event(async (e) => { if (exitOnExtensionDisable && configuration.disableExtension) { return; } if (exitOnTests && Globals.isTesting) { return; } listener(e); }); context.subscriptions.push(disposable); } /** * @returns true if there was a remap being executed to stop */ async function forceStopRecursiveRemap(mh: ModeHandler): Promise { if (mh.remapState.isCurrentlyPerformingRecursiveRemapping) { mh.remapState.forceStopRecursiveRemapping = true; return true; } return false; } function handleContentChangedFromDisk(document: vscode.TextDocument): void { ModeHandlerMap.getAll() .filter((modeHandler) => modeHandler.vimState.documentUri === document.uri) .forEach((modeHandler) => { modeHandler.vimState.historyTracker = new HistoryTracker(modeHandler.vimState); }); }