mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-23 19:59:37 +00:00
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:
@@ -45,6 +45,11 @@ interface CompletionContext {
|
|||||||
readonly useFuzzyWordRangeLogic: boolean,
|
readonly useFuzzyWordRangeLogic: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolvedCompletionItem = {
|
||||||
|
readonly edits?: readonly vscode.TextEdit[];
|
||||||
|
readonly commands: readonly vscode.Command[];
|
||||||
|
};
|
||||||
|
|
||||||
class MyCompletionItem extends vscode.CompletionItem {
|
class MyCompletionItem extends vscode.CompletionItem {
|
||||||
|
|
||||||
public readonly useCodeSnippet: boolean;
|
public readonly useCodeSnippet: boolean;
|
||||||
@@ -135,6 +140,192 @@ class MyCompletionItem extends vscode.CompletionItem {
|
|||||||
this.resolveRange();
|
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) {
|
private getRangeFromReplacementSpan(tsEntry: Proto.CompletionEntry, completionContext: CompletionContext, position: vscode.Position) {
|
||||||
if (!tsEntry.replacementSpan) {
|
if (!tsEntry.replacementSpan) {
|
||||||
return;
|
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 {
|
class ApplyCompletionCodeActionCommand implements Command {
|
||||||
public static readonly ID = '_typescript.applyCompletionCodeAction';
|
public static readonly ID = '_typescript.applyCompletionCodeAction';
|
||||||
public readonly id = ApplyCompletionCodeActionCommand.ID;
|
public readonly id = ApplyCompletionCodeActionCommand.ID;
|
||||||
@@ -434,6 +658,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||||||
commandManager.register(new ApplyCompletionCodeActionCommand(this.client));
|
commandManager.register(new ApplyCompletionCodeActionCommand(this.client));
|
||||||
commandManager.register(new CompositeCommand());
|
commandManager.register(new CompositeCommand());
|
||||||
commandManager.register(new CompletionAcceptedCommand(onCompletionAccepted, this.telemetryReporter));
|
commandManager.register(new CompletionAcceptedCommand(onCompletionAccepted, this.telemetryReporter));
|
||||||
|
commandManager.register(new ApplyCompletionCommand(this.client));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async provideCompletionItems(
|
public async provideCompletionItems(
|
||||||
@@ -535,7 +760,13 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||||||
const items: MyCompletionItem[] = [];
|
const items: MyCompletionItem[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!shouldExcludeCompletionEntry(entry, completionConfiguration)) {
|
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;
|
includesPackageJsonImport = !!entry.isPackageJsonImport;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,121 +828,10 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||||||
item: MyCompletionItem,
|
item: MyCompletionItem,
|
||||||
token: vscode.CancellationToken
|
token: vscode.CancellationToken
|
||||||
): Promise<MyCompletionItem | undefined> {
|
): Promise<MyCompletionItem | undefined> {
|
||||||
const filepath = this.client.toOpenedFilePath(item.document);
|
await item.resolveCompletionItem(this.client, token);
|
||||||
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;
|
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
private isInValidCommitCharacterContext(
|
||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
position: vscode.Position
|
position: vscode.Position
|
||||||
@@ -768,51 +888,6 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider<
|
|||||||
|
|
||||||
return true;
|
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(
|
function shouldExcludeCompletionEntry(
|
||||||
|
|||||||
Reference in New Issue
Block a user