Make sure we always apply TS auto imports, even if VS Code applies the completion before it has been resolved
Fixes #109439 This introduces a new `ApplyCompletionCommand` that is included on all JS/TS completions, which applies additional parts of the completion (such as auto imports). This is needed since VS Code will not always wait until `resolveCompletionItem` completes before appling the completion. This causes auto imports to sometimes not work when typing quickly
This commit is contained in:
parent
60bb22ddd3
commit
d99c218e9b
|
@ -45,6 +45,11 @@ interface CompletionContext {
|
|||
readonly useFuzzyWordRangeLogic: boolean,
|
||||
}
|
||||
|
||||
type ResolvedCompletionItem = {
|
||||
readonly edits?: readonly vscode.TextEdit[];
|
||||
readonly commands: readonly vscode.Command[];
|
||||
};
|
||||
|
||||
class MyCompletionItem extends vscode.CompletionItem {
|
||||
|
||||
public readonly useCodeSnippet: boolean;
|
||||
|
@ -135,6 +140,192 @@ class MyCompletionItem extends vscode.CompletionItem {
|
|||
this.resolveRange();
|
||||
}
|
||||
|
||||
private _resolvedPromise?: {
|
||||
readonly requestToken: vscode.CancellationTokenSource;
|
||||
readonly promise: Promise<ResolvedCompletionItem | undefined>;
|
||||
waiting: number;
|
||||
};
|
||||
|
||||
public async resolveCompletionItem(
|
||||
client: ITypeScriptServiceClient,
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<ResolvedCompletionItem | undefined> {
|
||||
token.onCancellationRequested(() => {
|
||||
if (this._resolvedPromise && --this._resolvedPromise.waiting <= 0) {
|
||||
// Give a little extra time for another caller to come in
|
||||
setTimeout(() => {
|
||||
if (this._resolvedPromise && this._resolvedPromise.waiting <= 0) {
|
||||
this._resolvedPromise.requestToken.cancel();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
if (this._resolvedPromise) {
|
||||
++this._resolvedPromise.waiting;
|
||||
return this._resolvedPromise.promise;
|
||||
}
|
||||
|
||||
const requestToken = new vscode.CancellationTokenSource();
|
||||
|
||||
const promise = (async (): Promise<ResolvedCompletionItem | undefined> => {
|
||||
const filepath = client.toOpenedFilePath(this.document);
|
||||
if (!filepath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const args: Proto.CompletionDetailsRequestArgs = {
|
||||
...typeConverters.Position.toFileLocationRequestArgs(filepath, this.position),
|
||||
entryNames: [
|
||||
this.tsEntry.source ? { name: this.tsEntry.name, source: this.tsEntry.source } : this.tsEntry.name
|
||||
]
|
||||
};
|
||||
const response = await client.interruptGetErr(() => client.execute('completionEntryDetails', args, requestToken.token));
|
||||
if (response.type !== 'response' || !response.body || !response.body.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const detail = response.body[0];
|
||||
|
||||
if (!this.detail && detail.displayParts.length) {
|
||||
this.detail = Previewer.plain(detail.displayParts);
|
||||
}
|
||||
this.documentation = this.getDocumentation(detail, this);
|
||||
|
||||
const codeAction = this.getCodeActions(detail, filepath);
|
||||
const commands: vscode.Command[] = [{
|
||||
command: CompletionAcceptedCommand.ID,
|
||||
title: '',
|
||||
arguments: [this]
|
||||
}];
|
||||
if (codeAction.command) {
|
||||
commands.push(codeAction.command);
|
||||
}
|
||||
const additionalTextEdits = codeAction.additionalTextEdits;
|
||||
|
||||
if (this.useCodeSnippet) {
|
||||
const shouldCompleteFunction = await this.isValidFunctionCompletionContext(client, filepath, this.position, this.document, token);
|
||||
if (shouldCompleteFunction) {
|
||||
const { snippet, parameterCount } = snippetForFunctionCall(this, detail.displayParts);
|
||||
this.insertText = snippet;
|
||||
if (parameterCount > 0) {
|
||||
//Fix for https://github.com/microsoft/vscode/issues/104059
|
||||
//Don't show parameter hints if "editor.parameterHints.enabled": false
|
||||
if (vscode.workspace.getConfiguration('editor.parameterHints').get('enabled')) {
|
||||
commands.push({ title: 'triggerParameterHints', command: 'editor.action.triggerParameterHints' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { commands, edits: additionalTextEdits };
|
||||
})();
|
||||
|
||||
this._resolvedPromise = {
|
||||
promise,
|
||||
requestToken,
|
||||
waiting: 1,
|
||||
};
|
||||
|
||||
return this._resolvedPromise.promise;
|
||||
}
|
||||
|
||||
private getDocumentation(
|
||||
detail: Proto.CompletionEntryDetails,
|
||||
item: MyCompletionItem
|
||||
): vscode.MarkdownString | undefined {
|
||||
const documentation = new vscode.MarkdownString();
|
||||
if (detail.source) {
|
||||
const importPath = `'${Previewer.plain(detail.source)}'`;
|
||||
const autoImportLabel = localize('autoImportLabel', 'Auto import from {0}', importPath);
|
||||
item.detail = `${autoImportLabel}\n${item.detail}`;
|
||||
}
|
||||
Previewer.addMarkdownDocumentation(documentation, detail.documentation, detail.tags);
|
||||
|
||||
return documentation.value.length ? documentation : undefined;
|
||||
}
|
||||
|
||||
private async isValidFunctionCompletionContext(
|
||||
client: ITypeScriptServiceClient,
|
||||
filepath: string,
|
||||
position: vscode.Position,
|
||||
document: vscode.TextDocument,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<boolean> {
|
||||
// Workaround for https://github.com/microsoft/TypeScript/issues/12677
|
||||
// Don't complete function calls inside of destructive assignments or imports
|
||||
try {
|
||||
const args: Proto.FileLocationRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position);
|
||||
const response = await client.execute('quickinfo', args, token);
|
||||
if (response.type === 'response' && response.body) {
|
||||
switch (response.body.kind) {
|
||||
case 'var':
|
||||
case 'let':
|
||||
case 'const':
|
||||
case 'alias':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Noop
|
||||
}
|
||||
|
||||
// Don't complete function call if there is already something that looks like a function call
|
||||
// https://github.com/microsoft/vscode/issues/18131
|
||||
const after = document.lineAt(position.line).text.slice(position.character);
|
||||
return after.match(/^[a-z_$0-9]*\s*\(/gi) === null;
|
||||
}
|
||||
|
||||
private getCodeActions(
|
||||
detail: Proto.CompletionEntryDetails,
|
||||
filepath: string
|
||||
): { command?: vscode.Command, additionalTextEdits?: vscode.TextEdit[] } {
|
||||
if (!detail.codeActions || !detail.codeActions.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Try to extract out the additionalTextEdits for the current file.
|
||||
// Also check if we still have to apply other workspace edits and commands
|
||||
// using a vscode command
|
||||
const additionalTextEdits: vscode.TextEdit[] = [];
|
||||
let hasRemainingCommandsOrEdits = false;
|
||||
for (const tsAction of detail.codeActions) {
|
||||
if (tsAction.commands) {
|
||||
hasRemainingCommandsOrEdits = true;
|
||||
}
|
||||
|
||||
// Apply all edits in the current file using `additionalTextEdits`
|
||||
if (tsAction.changes) {
|
||||
for (const change of tsAction.changes) {
|
||||
if (change.fileName === filepath) {
|
||||
additionalTextEdits.push(...change.textChanges.map(typeConverters.TextEdit.fromCodeEdit));
|
||||
} else {
|
||||
hasRemainingCommandsOrEdits = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let command: vscode.Command | undefined = undefined;
|
||||
if (hasRemainingCommandsOrEdits) {
|
||||
// Create command that applies all edits not in the current file.
|
||||
command = {
|
||||
title: '',
|
||||
command: ApplyCompletionCodeActionCommand.ID,
|
||||
arguments: [filepath, detail.codeActions.map((x): Proto.CodeAction => ({
|
||||
commands: x.commands,
|
||||
description: x.description,
|
||||
changes: x.changes.filter(x => x.fileName !== filepath)
|
||||
}))]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
additionalTextEdits: additionalTextEdits.length ? additionalTextEdits : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private getRangeFromReplacementSpan(tsEntry: Proto.CompletionEntry, completionContext: CompletionContext, position: vscode.Position) {
|
||||
if (!tsEntry.replacementSpan) {
|
||||
return;
|
||||
|
@ -358,6 +549,39 @@ class CompletionAcceptedCommand implements Command {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command fired when an completion item needs to be applied
|
||||
*/
|
||||
class ApplyCompletionCommand implements Command {
|
||||
public static readonly ID = '_typescript.applyCompletionCommand';
|
||||
public readonly id = ApplyCompletionCommand.ID;
|
||||
|
||||
public constructor(
|
||||
private readonly client: ITypeScriptServiceClient,
|
||||
) { }
|
||||
|
||||
public async execute(item: MyCompletionItem) {
|
||||
const resolved = await item.resolveCompletionItem(this.client, nulToken);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { edits, commands } = resolved;
|
||||
|
||||
if (edits) {
|
||||
const workspaceEdit = new vscode.WorkspaceEdit();
|
||||
for (const edit of edits) {
|
||||
workspaceEdit.replace(item.document.uri, edit.range, edit.newText);
|
||||
}
|
||||
await vscode.workspace.applyEdit(workspaceEdit);
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
await vscode.commands.executeCommand(command.command, ...(command.arguments ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ApplyCompletionCodeActionCommand implements Command {
|
||||
public static readonly ID = '_typescript.applyCompletionCodeAction';
|
||||
public readonly id = ApplyCompletionCodeActionCommand.ID;
|
||||
|
@ -434,6 +658,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||
commandManager.register(new ApplyCompletionCodeActionCommand(this.client));
|
||||
commandManager.register(new CompositeCommand());
|
||||
commandManager.register(new CompletionAcceptedCommand(onCompletionAccepted, this.telemetryReporter));
|
||||
commandManager.register(new ApplyCompletionCommand(this.client));
|
||||
}
|
||||
|
||||
public async provideCompletionItems(
|
||||
|
@ -535,7 +760,13 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||
const items: MyCompletionItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!shouldExcludeCompletionEntry(entry, completionConfiguration)) {
|
||||
items.push(new MyCompletionItem(position, document, entry, completionContext, metadata));
|
||||
const item = new MyCompletionItem(position, document, entry, completionContext, metadata);
|
||||
item.command = {
|
||||
command: ApplyCompletionCommand.ID,
|
||||
title: '',
|
||||
arguments: [item]
|
||||
};
|
||||
items.push(item);
|
||||
includesPackageJsonImport = !!entry.isPackageJsonImport;
|
||||
}
|
||||
}
|
||||
|
@ -597,121 +828,10 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||
item: MyCompletionItem,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<MyCompletionItem | undefined> {
|
||||
const filepath = this.client.toOpenedFilePath(item.document);
|
||||
if (!filepath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const args: Proto.CompletionDetailsRequestArgs = {
|
||||
...typeConverters.Position.toFileLocationRequestArgs(filepath, item.position),
|
||||
entryNames: [
|
||||
item.tsEntry.source ? { name: item.tsEntry.name, source: item.tsEntry.source } : item.tsEntry.name
|
||||
]
|
||||
};
|
||||
|
||||
const response = await this.client.interruptGetErr(() => this.client.execute('completionEntryDetails', args, token));
|
||||
if (response.type !== 'response' || !response.body || !response.body.length) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const detail = response.body[0];
|
||||
|
||||
if (!item.detail && detail.displayParts.length) {
|
||||
item.detail = Previewer.plain(detail.displayParts);
|
||||
}
|
||||
item.documentation = this.getDocumentation(detail, item);
|
||||
|
||||
const codeAction = this.getCodeActions(detail, filepath);
|
||||
const commands: vscode.Command[] = [{
|
||||
command: CompletionAcceptedCommand.ID,
|
||||
title: '',
|
||||
arguments: [item]
|
||||
}];
|
||||
if (codeAction.command) {
|
||||
commands.push(codeAction.command);
|
||||
}
|
||||
item.additionalTextEdits = codeAction.additionalTextEdits;
|
||||
|
||||
if (item.useCodeSnippet) {
|
||||
const shouldCompleteFunction = await this.isValidFunctionCompletionContext(filepath, item.position, item.document, token);
|
||||
if (shouldCompleteFunction) {
|
||||
const { snippet, parameterCount } = snippetForFunctionCall(item, detail.displayParts);
|
||||
item.insertText = snippet;
|
||||
if (parameterCount > 0) {
|
||||
//Fix for https://github.com/microsoft/vscode/issues/104059
|
||||
//Don't show parameter hints if "editor.parameterHints.enabled": false
|
||||
if (vscode.workspace.getConfiguration('editor.parameterHints').get('enabled')) {
|
||||
commands.push({ title: 'triggerParameterHints', command: 'editor.action.triggerParameterHints' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.length) {
|
||||
if (commands.length === 1) {
|
||||
item.command = commands[0];
|
||||
} else {
|
||||
item.command = {
|
||||
command: CompositeCommand.ID,
|
||||
title: '',
|
||||
arguments: commands
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await item.resolveCompletionItem(this.client, token);
|
||||
return item;
|
||||
}
|
||||
|
||||
private getCodeActions(
|
||||
detail: Proto.CompletionEntryDetails,
|
||||
filepath: string
|
||||
): { command?: vscode.Command, additionalTextEdits?: vscode.TextEdit[] } {
|
||||
if (!detail.codeActions || !detail.codeActions.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Try to extract out the additionalTextEdits for the current file.
|
||||
// Also check if we still have to apply other workspace edits and commands
|
||||
// using a vscode command
|
||||
const additionalTextEdits: vscode.TextEdit[] = [];
|
||||
let hasRemainingCommandsOrEdits = false;
|
||||
for (const tsAction of detail.codeActions) {
|
||||
if (tsAction.commands) {
|
||||
hasRemainingCommandsOrEdits = true;
|
||||
}
|
||||
|
||||
// Apply all edits in the current file using `additionalTextEdits`
|
||||
if (tsAction.changes) {
|
||||
for (const change of tsAction.changes) {
|
||||
if (change.fileName === filepath) {
|
||||
additionalTextEdits.push(...change.textChanges.map(typeConverters.TextEdit.fromCodeEdit));
|
||||
} else {
|
||||
hasRemainingCommandsOrEdits = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let command: vscode.Command | undefined = undefined;
|
||||
if (hasRemainingCommandsOrEdits) {
|
||||
// Create command that applies all edits not in the current file.
|
||||
command = {
|
||||
title: '',
|
||||
command: ApplyCompletionCodeActionCommand.ID,
|
||||
arguments: [filepath, detail.codeActions.map((x): Proto.CodeAction => ({
|
||||
commands: x.commands,
|
||||
description: x.description,
|
||||
changes: x.changes.filter(x => x.fileName !== filepath)
|
||||
}))]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
additionalTextEdits: additionalTextEdits.length ? additionalTextEdits : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private isInValidCommitCharacterContext(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
|
@ -768,51 +888,6 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getDocumentation(
|
||||
detail: Proto.CompletionEntryDetails,
|
||||
item: MyCompletionItem
|
||||
): vscode.MarkdownString | undefined {
|
||||
const documentation = new vscode.MarkdownString();
|
||||
if (detail.source) {
|
||||
const importPath = `'${Previewer.plain(detail.source)}'`;
|
||||
const autoImportLabel = localize('autoImportLabel', 'Auto import from {0}', importPath);
|
||||
item.detail = `${autoImportLabel}\n${item.detail}`;
|
||||
}
|
||||
Previewer.addMarkdownDocumentation(documentation, detail.documentation, detail.tags);
|
||||
|
||||
return documentation.value.length ? documentation : undefined;
|
||||
}
|
||||
|
||||
private async isValidFunctionCompletionContext(
|
||||
filepath: string,
|
||||
position: vscode.Position,
|
||||
document: vscode.TextDocument,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<boolean> {
|
||||
// Workaround for https://github.com/microsoft/TypeScript/issues/12677
|
||||
// Don't complete function calls inside of destructive assignments or imports
|
||||
try {
|
||||
const args: Proto.FileLocationRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position);
|
||||
const response = await this.client.execute('quickinfo', args, token);
|
||||
if (response.type === 'response' && response.body) {
|
||||
switch (response.body.kind) {
|
||||
case 'var':
|
||||
case 'let':
|
||||
case 'const':
|
||||
case 'alias':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Noop
|
||||
}
|
||||
|
||||
// Don't complete function call if there is already something that looks like a function call
|
||||
// https://github.com/microsoft/vscode/issues/18131
|
||||
const after = document.lineAt(position.line).text.slice(position.character);
|
||||
return after.match(/^[a-z_$0-9]*\s*\(/gi) === null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldExcludeCompletionEntry(
|
||||
|
|
Loading…
Reference in a new issue