Skip to content

Commit 6950674

Browse files
committed
feat: generate the import declaration for the completion item code actions
In the completion item, the `additionalTextEdits` can only be included the changes about the current file, the other changes should be inserted by the vscode command. For example, when the user selects a component in an HTML file, the extension inserts the selector in the HTML file and auto-generates the import declaration in the TS file.
1 parent 13d9776 commit 6950674

File tree

6 files changed

+167
-2
lines changed

6 files changed

+167
-2
lines changed

client/src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ export class AngularLanguageClient implements vscode.Disposable {
176176
};
177177
}
178178

179+
async applyWorkspaceEdits(workspaceEdits: lsp.WorkspaceEdit[]) {
180+
for (const edit of workspaceEdits) {
181+
const workspaceEdit = this.client?.protocol2CodeConverter.asWorkspaceEdit(edit);
182+
if (workspaceEdit === undefined) {
183+
continue;
184+
}
185+
await vscode.workspace.applyEdit(workspaceEdit);
186+
}
187+
}
188+
179189
private async isInAngularProject(doc: vscode.TextDocument): Promise<boolean> {
180190
if (this.client === null) {
181191
return false;

client/src/commands.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as vscode from 'vscode';
10+
import * as lsp from 'vscode-languageclient/node';
1011

1112
import {OpenJsDocLinkCommand_Args, OpenJsDocLinkCommandId, ServerOptions} from '../../common/initialize';
1213

@@ -191,6 +192,16 @@ function openJsDocLinkCommand(): Command<OpenJsDocLinkCommand_Args> {
191192
};
192193
}
193194

195+
function applyCodeActionCommand(ngClient: AngularLanguageClient): Command {
196+
return {
197+
id: 'angular.applyCompletionCodeAction',
198+
isTextEditorCommand: false,
199+
async execute(args: lsp.WorkspaceEdit[]) {
200+
await ngClient.applyWorkspaceEdits(args);
201+
},
202+
};
203+
}
204+
194205
/**
195206
* Register all supported vscode commands for the Angular extension.
196207
* @param client language client
@@ -205,6 +216,7 @@ export function registerCommands(
205216
goToComponentWithTemplateFile(client),
206217
goToTemplateForComponent(client),
207218
openJsDocLinkCommand(),
219+
applyCodeActionCommand(client),
208220
];
209221

210222
for (const command of commands) {

integration/lsp/ivy_spec.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {URI} from 'vscode-uri';
1414

1515
import {ProjectLanguageService, ProjectLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../../common/notifications';
1616
import {GetComponentsWithTemplateFile, GetTcbRequest, GetTemplateLocationForComponent, IsInAngularProject} from '../../common/requests';
17-
import {APP_COMPONENT, APP_COMPONENT_URI, FOO_COMPONENT, FOO_COMPONENT_URI, FOO_TEMPLATE, FOO_TEMPLATE_URI, IS_BAZEL, PROJECT_PATH, TSCONFIG} from '../test_constants';
17+
import {APP_COMPONENT, APP_COMPONENT_MODULE_URI, APP_COMPONENT_URI, BAR_COMPONENT, BAR_COMPONENT_URI, FOO_COMPONENT, FOO_COMPONENT_URI, FOO_TEMPLATE, FOO_TEMPLATE_URI, IS_BAZEL, PROJECT_PATH, TSCONFIG} from '../test_constants';
1818

1919
import {convertPathToFileUrl, createConnection, createTracer, initializeServer, openTextDocument} from './test_utils';
2020

@@ -580,6 +580,58 @@ export class AppComponent {
580580
});
581581
expect(componentResponse).toBe(true);
582582
})
583+
584+
describe('auto-import component', () => {
585+
it('should generate import in the different file', async () => {
586+
openTextDocument(client, FOO_TEMPLATE, `<bar-`);
587+
const response = await client.sendRequest(lsp.CompletionRequest.type, {
588+
textDocument: {
589+
uri: FOO_TEMPLATE_URI,
590+
},
591+
position: {line: 0, character: 5},
592+
}) as lsp.CompletionItem[];
593+
const libPostResponse = response.find(res => res.label === 'bar-component')!;
594+
const detail = await client.sendRequest(lsp.CompletionResolveRequest.type, libPostResponse);
595+
expect(detail.command?.command).toEqual('angular.applyCompletionCodeAction');
596+
expect(detail.command?.arguments?.[0])
597+
.toEqual(([{
598+
'changes': {
599+
[APP_COMPONENT_MODULE_URI]: [
600+
{
601+
'newText': '\nimport { BarComponent } from "./bar.component";',
602+
'range':
603+
{'start': {'line': 5, 'character': 45}, 'end': {'line': 5, 'character': 45}}
604+
},
605+
{
606+
'newText':
607+
'{\n declarations: [\n AppComponent,\n FooComponent,\n ],\n bootstrap: [AppComponent],\n imports: [\n CommonModule,\n PostModule,\n BarComponent\n ]\n}',
608+
'range':
609+
{'start': {'line': 7, 'character': 10}, 'end': {'line': 17, 'character': 1}}
610+
}
611+
]
612+
}
613+
}]
614+
615+
));
616+
});
617+
618+
it('should generate import in the current file', async () => {
619+
openTextDocument(client, BAR_COMPONENT);
620+
const response = await client.sendRequest(lsp.CompletionRequest.type, {
621+
textDocument: {
622+
uri: BAR_COMPONENT_URI,
623+
},
624+
position: {line: 13, character: 16},
625+
}) as lsp.CompletionItem[];
626+
const libPostResponse = response.find(res => res.label === 'baz-component')!;
627+
const detail = await client.sendRequest(lsp.CompletionResolveRequest.type, libPostResponse);
628+
expect(detail.additionalTextEdits).toEqual([{
629+
'newText':
630+
'{\n selector: \'bar-component\',\n template: `<`,\n standalone: true,\n imports: [BazComponent]\n}',
631+
'range': {'start': {'line': 11, 'character': 11}, 'end': {'line': 15, 'character': 1}}
632+
}]);
633+
});
634+
});
583635
});
584636

585637
describe('auto-apply optional chaining', () => {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
selector: 'baz-component',
5+
template: `<h1>Hello {{name}}</h1>`,
6+
standalone: true
7+
})
8+
export class BazComponent {
9+
name = 'Angular';
10+
}
11+
12+
@Component({
13+
selector: 'bar-component',
14+
template: `<`,
15+
standalone: true
16+
})
17+
export class BarComponent {
18+
name = 'Angular';
19+
}

integration/test_constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const SERVER_PATH = IS_BAZEL ? join(PACKAGE_ROOT, 'server', 'index.js') :
99
export const PROJECT_PATH = join(PACKAGE_ROOT, 'integration', 'project');
1010
export const APP_COMPONENT = join(PROJECT_PATH, 'app', 'app.component.ts');
1111
export const APP_COMPONENT_URI = convertPathToFileUrl(APP_COMPONENT);
12+
export const BAR_COMPONENT = join(PROJECT_PATH, 'app', 'bar.component.ts');
13+
export const BAR_COMPONENT_URI = convertPathToFileUrl(BAR_COMPONENT);
14+
export const APP_COMPONENT_MODULE = join(PROJECT_PATH, 'app', 'app.module.ts');
15+
export const APP_COMPONENT_MODULE_URI = convertPathToFileUrl(APP_COMPONENT_MODULE);
1216
export const FOO_TEMPLATE = join(PROJECT_PATH, 'app', 'foo.component.html');
1317
export const FOO_TEMPLATE_URI = convertPathToFileUrl(FOO_TEMPLATE);
1418
export const FOO_COMPONENT = join(PROJECT_PATH, 'app', 'foo.component.ts');

server/src/session.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,7 +1190,9 @@ export class Session {
11901190
return item;
11911191
}
11921192

1193-
const {kind, kindModifiers, displayParts, documentation, tags} = details;
1193+
const {kind, kindModifiers, displayParts, documentation, tags, codeActions} = details;
1194+
const codeActionsDetail = generateCommandAndTextEditsFromCodeActions(
1195+
codeActions ?? [], filePath, (path: string) => this.projectService.getScriptInfo(path));
11941196
let desc = kindModifiers ? kindModifiers + ' ' : '';
11951197
if (displayParts && displayParts.length > 0) {
11961198
// displayParts does not contain info about kindModifiers
@@ -1206,6 +1208,8 @@ export class Session {
12061208
documentation, tags, (fileName) => this.getLSAndScriptInfo(fileName)?.scriptInfo)
12071209
.join('\n'),
12081210
};
1211+
item.additionalTextEdits = codeActionsDetail.additionalTextEdits;
1212+
item.command = codeActionsDetail.command;
12091213
return item;
12101214
}
12111215

@@ -1340,3 +1344,67 @@ function getCodeFixesAll(
13401344
}
13411345
return lspCodeActions;
13421346
}
1347+
1348+
/**
1349+
* In the completion item, the `additionalTextEdits` can only be included the changes about the
1350+
* current file, the other changes should be inserted by the vscode command.
1351+
*
1352+
* For example, when the user selects a component in an HTML file, the extension inserts the
1353+
* selector in the HTML file and auto-generates the import declaration in the TS file.
1354+
*
1355+
* The code is copied from
1356+
* [here](https://github.com/microsoft/vscode/blob/4608b378a8101ff273fa5db36516da6022f66bbf/extensions/typescript-language-features/src/languageFeatures/completions.ts#L304)
1357+
*/
1358+
function generateCommandAndTextEditsFromCodeActions(
1359+
codeActions: ts.CodeAction[], currentFilePath: string,
1360+
getScriptInfo: (path: string) => ts.server.ScriptInfo |
1361+
undefined): {command?: lsp.Command; additionalTextEdits?: lsp.TextEdit[]} {
1362+
if (codeActions.length === 0) {
1363+
return {};
1364+
}
1365+
1366+
// Try to extract out the additionalTextEdits for the current file.
1367+
// Also check if we still have to apply other workspace edits and commands
1368+
// using a vscode command
1369+
const additionalTextEdits: lsp.TextEdit[] = [];
1370+
const commandTextEditors: lsp.WorkspaceEdit[] = [];
1371+
1372+
for (const tsAction of codeActions) {
1373+
const currentFileChanges =
1374+
tsAction.changes.filter(change => change.fileName === currentFilePath);
1375+
const otherWorkspaceFileChanges =
1376+
tsAction.changes.filter(change => change.fileName !== currentFilePath);
1377+
1378+
if (currentFileChanges.length > 0) {
1379+
// Apply all edits in the current file using `additionalTextEdits`
1380+
const additionalWorkspaceEdit =
1381+
tsFileTextChangesToLspWorkspaceEdit(currentFileChanges, getScriptInfo).changes;
1382+
if (additionalWorkspaceEdit !== undefined) {
1383+
for (const edit of Object.values(additionalWorkspaceEdit)) {
1384+
additionalTextEdits.push(...edit);
1385+
}
1386+
}
1387+
}
1388+
1389+
if (otherWorkspaceFileChanges.length > 0) {
1390+
commandTextEditors.push(
1391+
tsFileTextChangesToLspWorkspaceEdit(otherWorkspaceFileChanges, getScriptInfo),
1392+
);
1393+
}
1394+
}
1395+
1396+
let command: lsp.Command|undefined = undefined;
1397+
if (commandTextEditors.length > 0) {
1398+
// Create command that applies all edits not in the current file.
1399+
command = {
1400+
title: '',
1401+
command: 'angular.applyCompletionCodeAction',
1402+
arguments: [commandTextEditors],
1403+
};
1404+
}
1405+
1406+
return {
1407+
command,
1408+
additionalTextEdits: additionalTextEdits.length ? additionalTextEdits : undefined
1409+
};
1410+
}

0 commit comments

Comments
 (0)