mirror of
https://github.com/VSCodeVim/Vim.git
synced 2024-11-09 13:34:29 +03:00
50bc4fe1e3
At some point this class had a purpose, but at this point I think it's just serving to maybe add some latency and indeterminism to the mix.
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
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 { Register } from './src/register/register';
|
|
import { SpecialKeys } from './src/util/specialKeys';
|
|
import { exCommandParser } from './src/vimscript/exCommandParser';
|
|
|
|
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<ModeHandler | undefined> {
|
|
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.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) {
|
|
ExCommandLine.parser = exCommandParser;
|
|
|
|
Logger.init();
|
|
|
|
// before we do anything else, we need to load the configuration
|
|
await loadConfiguration();
|
|
|
|
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 () => {
|
|
Logger.info('Configuration changed');
|
|
await loadConfiguration();
|
|
},
|
|
false
|
|
);
|
|
|
|
registerEventListener(context, vscode.workspace.onDidChangeTextDocument, async (event) => {
|
|
if (event.document.uri.scheme === 'output') {
|
|
// Without this, we'll get an infinite logging loop
|
|
return;
|
|
}
|
|
if (event.contentChanges.length === 0) {
|
|
// This happens when the document is saved
|
|
return;
|
|
}
|
|
|
|
Logger.debug(
|
|
`${event.contentChanges.length} change(s) to ${event.document.fileName} because ${event.reason}`
|
|
);
|
|
for (const x of event.contentChanges) {
|
|
Logger.trace(`\t-${x.rangeLength}, +'${x.text}'`);
|
|
}
|
|
|
|
if (event.contentChanges.length === 1) {
|
|
const change = event.contentChanges[0];
|
|
|
|
const anyLinesDeleted = change.range.start.line !== change.range.end.line;
|
|
|
|
if (anyLinesDeleted && change.text === '') {
|
|
globalState.jumpTracker.handleTextDeleted(event.document, change.range);
|
|
} else if (!anyLinesDeleted && change.text.includes('\n')) {
|
|
globalState.jumpTracker.handleTextAdded(event.document, change.range, change.text);
|
|
} else {
|
|
// TODO: What to do here?
|
|
}
|
|
} else {
|
|
// TODO: In this case, we should probably loop over the content changes...
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
const mh = ModeHandlerMap.get(event.document.uri);
|
|
if (mh) {
|
|
contentChangeHandler(mh);
|
|
}
|
|
});
|
|
|
|
registerEventListener(
|
|
context,
|
|
vscode.workspace.onDidCloseTextDocument,
|
|
async (closedDocument) => {
|
|
Logger.info(`${closedDocument.fileName} closed`);
|
|
|
|
// Delete modehandler once all tabs of this document have been closed
|
|
for (const [uri, modeHandler] of ModeHandlerMap.entries()) {
|
|
let shouldDelete = false;
|
|
if (modeHandler == null) {
|
|
shouldDelete = true;
|
|
} else {
|
|
const document = modeHandler.vimState.document;
|
|
if (!vscode.workspace.textDocuments.includes(document)) {
|
|
shouldDelete = true;
|
|
if (closedDocument === document) {
|
|
lastClosedModeHandler = modeHandler;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldDelete) {
|
|
ModeHandlerMap.delete(uri);
|
|
}
|
|
}
|
|
},
|
|
false
|
|
);
|
|
|
|
// window events
|
|
registerEventListener(
|
|
context,
|
|
vscode.window.onDidChangeActiveTextEditor,
|
|
async (activeTextEditor: vscode.TextEditor | undefined) => {
|
|
if (activeTextEditor) {
|
|
Logger.info(`Active editor: ${activeTextEditor.document.uri}`);
|
|
} else {
|
|
Logger.debug(`No active editor`);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
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 (e.textEditor.document.uri.scheme === 'output') {
|
|
// Without this, we can an infinite logging loop
|
|
return;
|
|
}
|
|
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.trace(
|
|
`Ignoring selection: ${selectionsHash}. ${mh.selectionsChanged.ourSelections.length} left`
|
|
);
|
|
return;
|
|
} else if (mh.selectionsChanged.ignoreIntermediateSelections) {
|
|
Logger.trace(`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.warn(`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;
|
|
}
|
|
|
|
await mh.handleSelectionChange(e);
|
|
},
|
|
true,
|
|
false
|
|
);
|
|
|
|
registerEventListener(
|
|
context,
|
|
vscode.window.onDidChangeTextEditorVisibleRanges,
|
|
async (e: vscode.TextEditorVisibleRangesChangeEvent) => {
|
|
if (e.textEditor !== vscode.window.activeTextEditor) {
|
|
return;
|
|
}
|
|
// 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) => {
|
|
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) => {
|
|
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 () => {
|
|
compositionState.isInComposition = true;
|
|
});
|
|
|
|
overrideCommand(context, 'compositionEnd', 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) => {
|
|
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 = ['<Esc>', '<C-c>'].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, 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<T>(
|
|
context: vscode.ExtensionContext,
|
|
event: vscode.Event<T>,
|
|
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<boolean> {
|
|
if (mh.remapState.isCurrentlyPerformingRecursiveRemapping) {
|
|
mh.remapState.forceStopRecursiveRemapping = true;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|