Skip to content

Commit 68f0d60

Browse files
ivanwonderatscott
authored andcommitted
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 4ab3481 commit 68f0d60

File tree

12 files changed

+180
-17
lines changed

12 files changed

+180
-17
lines changed

.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Input hashes for repository rule npm_translate_lock(name = "npm", pnpm_lock = "//:pnpm-lock.yaml").
33
# This file should be checked into version control along with the pnpm-lock.yaml file.
44
.npmrc=974837034
5-
pnpm-lock.yaml=730915817
6-
yarn.lock=1032276408
7-
package.json=-257701941
5+
pnpm-lock.yaml=1771343819
6+
yarn.lock=1590538245
7+
package.json=1973544585
88
pnpm-workspace.yaml=1711114604

.circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
type: enum
1010
enum: ['package.json', 'builds-repo']
1111
docker:
12-
- image: cimg/node:18.13.0
12+
- image: cimg/node:18.19.1
1313
environment:
1414
# TODO: Remove when pnpm is exclusively used.
1515
ASPECT_RULES_JS_FROZEN_PNPM_LOCK: '1'

client/src/client.ts

+10
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

+12
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

+51-1
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,56 @@ 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': 'imports: [\n CommonModule,\n PostModule,\n BarComponent\n]',
607+
'range':
608+
{'start': {'line': 8, 'character': 2}, 'end': {'line': 11, 'character': 3}}
609+
}
610+
]
611+
}
612+
}]
613+
614+
));
615+
});
616+
617+
it('should generate import in the current file', async () => {
618+
openTextDocument(client, BAR_COMPONENT);
619+
const response = await client.sendRequest(lsp.CompletionRequest.type, {
620+
textDocument: {
621+
uri: BAR_COMPONENT_URI,
622+
},
623+
position: {line: 13, character: 16},
624+
}) as lsp.CompletionItem[];
625+
const libPostResponse = response.find(res => res.label === 'baz-component')!;
626+
const detail = await client.sendRequest(lsp.CompletionResolveRequest.type, libPostResponse);
627+
expect(detail.additionalTextEdits).toEqual([{
628+
'newText': ',\n imports: [BazComponent]',
629+
'range': {'start': {'line': 14, 'character': 20}, 'end': {'line': 14, 'character': 20}}
630+
}]);
631+
});
632+
});
583633
});
584634

585635
describe('auto-apply optional chaining', () => {
+19
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

+4
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');

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@
238238
"test:legacy-syntaxes": "yarn compile:syntaxes-test && yarn build:syntaxes && jasmine dist/syntaxes/test/driver.js"
239239
},
240240
"dependencies": {
241-
"@angular/language-service": "18.1.0-next.0",
241+
"@angular/language-service": "18.1.0-next.2",
242242
"typescript": "5.4.5",
243243
"vscode-html-languageservice": "^4.2.5",
244244
"vscode-jsonrpc": "6.0.0",

pnpm-lock.yaml

+5-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"ngserver": "./bin/ngserver"
1616
},
1717
"dependencies": {
18-
"@angular/language-service": "18.1.0-next.1",
18+
"@angular/language-service": "18.1.0-next.2",
1919
"vscode-html-languageservice": "^4.2.5",
2020
"vscode-jsonrpc": "6.0.0",
2121
"vscode-languageserver": "7.0.0",

server/src/session.ts

+69-1
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+
}

yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,10 @@
158158
uuid "^8.3.2"
159159
yargs "^17.0.0"
160160

161-
"@angular/[email protected].0":
162-
version "18.1.0-next.0"
163-
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.1.0-next.0.tgz#ad62863fd2172d494c2c464dad35a8b1ca47e8b3"
164-
integrity sha512-9kMpU+P9KY0YK56GlR6csFq/8GCZUPcTkTGwbMoOFLJCBa/y/tho9Ikl7epupl1GjaYZraKqNUxH+5z4P0DzCg==
161+
"@angular/[email protected].2":
162+
version "18.1.0-next.2"
163+
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.1.0-next.2.tgz#f8e31a175ea3df6535f50e1bacf5038e83b5d6d4"
164+
integrity sha512-d1c/rOmbmVxigzuENEdSKjEx+/tqSbuoQJ5iHUmof/rRQGub4fzFI2I3d2sVOJ4eP38/jifVMWGrX0MdrBbJAw==
165165

166166
"@assemblyscript/loader@^0.10.1":
167167
version "0.10.1"

0 commit comments

Comments
 (0)