diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 46e5034398..519d608e0a 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -2,7 +2,7 @@ # Input hashes for repository rule npm_translate_lock(name = "npm", pnpm_lock = "//:pnpm-lock.yaml"). # This file should be checked into version control along with the pnpm-lock.yaml file. .npmrc=974837034 -pnpm-lock.yaml=730915817 -yarn.lock=1032276408 -package.json=-257701941 +pnpm-lock.yaml=1771343819 +yarn.lock=1590538245 +package.json=1973544585 pnpm-workspace.yaml=1711114604 diff --git a/.circleci/config.yml b/.circleci/config.yml index 122ee5dfa8..2151129b80 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ jobs: type: enum enum: ['package.json', 'builds-repo'] docker: - - image: cimg/node:18.13.0 + - image: cimg/node:18.19.1 environment: # TODO: Remove when pnpm is exclusively used. ASPECT_RULES_JS_FROZEN_PNPM_LOCK: '1' diff --git a/client/src/client.ts b/client/src/client.ts index b4c58b6906..49b583fe1e 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -176,6 +176,16 @@ export class AngularLanguageClient implements vscode.Disposable { }; } + async applyWorkspaceEdits(workspaceEdits: lsp.WorkspaceEdit[]) { + for (const edit of workspaceEdits) { + const workspaceEdit = this.client?.protocol2CodeConverter.asWorkspaceEdit(edit); + if (workspaceEdit === undefined) { + continue; + } + await vscode.workspace.applyEdit(workspaceEdit); + } + } + private async isInAngularProject(doc: vscode.TextDocument): Promise { if (this.client === null) { return false; diff --git a/client/src/commands.ts b/client/src/commands.ts index 3a6f38df24..d3cc17f1ba 100644 --- a/client/src/commands.ts +++ b/client/src/commands.ts @@ -7,6 +7,7 @@ */ import * as vscode from 'vscode'; +import * as lsp from 'vscode-languageclient/node'; import {OpenJsDocLinkCommand_Args, OpenJsDocLinkCommandId, ServerOptions} from '../../common/initialize'; @@ -191,6 +192,16 @@ function openJsDocLinkCommand(): Command { }; } +function applyCodeActionCommand(ngClient: AngularLanguageClient): Command { + return { + id: 'angular.applyCompletionCodeAction', + isTextEditorCommand: false, + async execute(args: lsp.WorkspaceEdit[]) { + await ngClient.applyWorkspaceEdits(args); + }, + }; +} + /** * Register all supported vscode commands for the Angular extension. * @param client language client @@ -205,6 +216,7 @@ export function registerCommands( goToComponentWithTemplateFile(client), goToTemplateForComponent(client), openJsDocLinkCommand(), + applyCodeActionCommand(client), ]; for (const command of commands) { diff --git a/integration/lsp/ivy_spec.ts b/integration/lsp/ivy_spec.ts index 22305e6135..af0cea7096 100644 --- a/integration/lsp/ivy_spec.ts +++ b/integration/lsp/ivy_spec.ts @@ -14,7 +14,7 @@ import {URI} from 'vscode-uri'; import {ProjectLanguageService, ProjectLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../../common/notifications'; import {GetComponentsWithTemplateFile, GetTcbRequest, GetTemplateLocationForComponent, IsInAngularProject} from '../../common/requests'; -import {APP_COMPONENT, APP_COMPONENT_URI, FOO_COMPONENT, FOO_COMPONENT_URI, FOO_TEMPLATE, FOO_TEMPLATE_URI, IS_BAZEL, PROJECT_PATH, TSCONFIG} from '../test_constants'; +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'; import {convertPathToFileUrl, createConnection, createTracer, initializeServer, openTextDocument} from './test_utils'; @@ -580,6 +580,56 @@ export class AppComponent { }); expect(componentResponse).toBe(true); }) + + describe('auto-import component', () => { + it('should generate import in the different file', async () => { + openTextDocument(client, FOO_TEMPLATE, ` res.label === 'bar-component')!; + const detail = await client.sendRequest(lsp.CompletionResolveRequest.type, libPostResponse); + expect(detail.command?.command).toEqual('angular.applyCompletionCodeAction'); + expect(detail.command?.arguments?.[0]) + .toEqual(([{ + 'changes': { + [APP_COMPONENT_MODULE_URI]: [ + { + 'newText': '\nimport { BarComponent } from "./bar.component";', + 'range': + {'start': {'line': 5, 'character': 45}, 'end': {'line': 5, 'character': 45}} + }, + { + 'newText': 'imports: [\n CommonModule,\n PostModule,\n BarComponent\n]', + 'range': + {'start': {'line': 8, 'character': 2}, 'end': {'line': 11, 'character': 3}} + } + ] + } + }] + + )); + }); + + it('should generate import in the current file', async () => { + openTextDocument(client, BAR_COMPONENT); + const response = await client.sendRequest(lsp.CompletionRequest.type, { + textDocument: { + uri: BAR_COMPONENT_URI, + }, + position: {line: 13, character: 16}, + }) as lsp.CompletionItem[]; + const libPostResponse = response.find(res => res.label === 'baz-component')!; + const detail = await client.sendRequest(lsp.CompletionResolveRequest.type, libPostResponse); + expect(detail.additionalTextEdits).toEqual([{ + 'newText': ',\n imports: [BazComponent]', + 'range': {'start': {'line': 14, 'character': 20}, 'end': {'line': 14, 'character': 20}} + }]); + }); + }); }); describe('auto-apply optional chaining', () => { diff --git a/integration/project/app/bar.component.ts b/integration/project/app/bar.component.ts new file mode 100644 index 0000000000..d782033ad2 --- /dev/null +++ b/integration/project/app/bar.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'baz-component', + template: `

Hello {{name}}

`, + standalone: true +}) +export class BazComponent { + name = 'Angular'; +} + +@Component({ + selector: 'bar-component', + template: `<`, + standalone: true +}) +export class BarComponent { + name = 'Angular'; +} diff --git a/integration/test_constants.ts b/integration/test_constants.ts index 50b82c5b66..891b91f96f 100644 --- a/integration/test_constants.ts +++ b/integration/test_constants.ts @@ -9,6 +9,10 @@ export const SERVER_PATH = IS_BAZEL ? join(PACKAGE_ROOT, 'server', 'index.js') : export const PROJECT_PATH = join(PACKAGE_ROOT, 'integration', 'project'); export const APP_COMPONENT = join(PROJECT_PATH, 'app', 'app.component.ts'); export const APP_COMPONENT_URI = convertPathToFileUrl(APP_COMPONENT); +export const BAR_COMPONENT = join(PROJECT_PATH, 'app', 'bar.component.ts'); +export const BAR_COMPONENT_URI = convertPathToFileUrl(BAR_COMPONENT); +export const APP_COMPONENT_MODULE = join(PROJECT_PATH, 'app', 'app.module.ts'); +export const APP_COMPONENT_MODULE_URI = convertPathToFileUrl(APP_COMPONENT_MODULE); export const FOO_TEMPLATE = join(PROJECT_PATH, 'app', 'foo.component.html'); export const FOO_TEMPLATE_URI = convertPathToFileUrl(FOO_TEMPLATE); export const FOO_COMPONENT = join(PROJECT_PATH, 'app', 'foo.component.ts'); diff --git a/package.json b/package.json index b861ab7637..f84275e324 100644 --- a/package.json +++ b/package.json @@ -238,7 +238,7 @@ "test:legacy-syntaxes": "yarn compile:syntaxes-test && yarn build:syntaxes && jasmine dist/syntaxes/test/driver.js" }, "dependencies": { - "@angular/language-service": "18.1.0-next.0", + "@angular/language-service": "18.1.0-next.2", "typescript": "5.4.5", "vscode-html-languageservice": "^4.2.5", "vscode-jsonrpc": "6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b404a44e5e..3a6a6f50bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@angular/language-service': - specifier: 18.1.0-next.0 - version: 18.1.0-next.0 + specifier: 18.1.0-next.2 + version: 18.1.0-next.2 typescript: specifier: 5.4.5 version: 5.4.5 @@ -313,9 +313,9 @@ packages: zone.js: 0.11.8 dev: true - /@angular/language-service@18.1.0-next.0: - resolution: {integrity: sha512-9kMpU+P9KY0YK56GlR6csFq/8GCZUPcTkTGwbMoOFLJCBa/y/tho9Ikl7epupl1GjaYZraKqNUxH+5z4P0DzCg==} - engines: {node: ^18.13.0 || >=20.9.0} + /@angular/language-service@18.1.0-next.2: + resolution: {integrity: sha512-d1c/rOmbmVxigzuENEdSKjEx+/tqSbuoQJ5iHUmof/rRQGub4fzFI2I3d2sVOJ4eP38/jifVMWGrX0MdrBbJAw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0} dev: false /@assemblyscript/loader@0.10.1: diff --git a/server/package.json b/server/package.json index 44d929a809..5bec19a308 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,7 @@ "ngserver": "./bin/ngserver" }, "dependencies": { - "@angular/language-service": "18.1.0-next.1", + "@angular/language-service": "18.1.0-next.2", "vscode-html-languageservice": "^4.2.5", "vscode-jsonrpc": "6.0.0", "vscode-languageserver": "7.0.0", diff --git a/server/src/session.ts b/server/src/session.ts index 52c560ff7a..a5d7645b47 100644 --- a/server/src/session.ts +++ b/server/src/session.ts @@ -1190,7 +1190,9 @@ export class Session { return item; } - const {kind, kindModifiers, displayParts, documentation, tags} = details; + const {kind, kindModifiers, displayParts, documentation, tags, codeActions} = details; + const codeActionsDetail = generateCommandAndTextEditsFromCodeActions( + codeActions ?? [], filePath, (path: string) => this.projectService.getScriptInfo(path)); let desc = kindModifiers ? kindModifiers + ' ' : ''; if (displayParts && displayParts.length > 0) { // displayParts does not contain info about kindModifiers @@ -1206,6 +1208,8 @@ export class Session { documentation, tags, (fileName) => this.getLSAndScriptInfo(fileName)?.scriptInfo) .join('\n'), }; + item.additionalTextEdits = codeActionsDetail.additionalTextEdits; + item.command = codeActionsDetail.command; return item; } @@ -1340,3 +1344,67 @@ function getCodeFixesAll( } return lspCodeActions; } + +/** + * 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. + * + * The code is copied from + * [here](https://github.com/microsoft/vscode/blob/4608b378a8101ff273fa5db36516da6022f66bbf/extensions/typescript-language-features/src/languageFeatures/completions.ts#L304) + */ +function generateCommandAndTextEditsFromCodeActions( + codeActions: ts.CodeAction[], currentFilePath: string, + getScriptInfo: (path: string) => ts.server.ScriptInfo | + undefined): {command?: lsp.Command; additionalTextEdits?: lsp.TextEdit[]} { + if (codeActions.length === 0) { + 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: lsp.TextEdit[] = []; + const commandTextEditors: lsp.WorkspaceEdit[] = []; + + for (const tsAction of codeActions) { + const currentFileChanges = + tsAction.changes.filter(change => change.fileName === currentFilePath); + const otherWorkspaceFileChanges = + tsAction.changes.filter(change => change.fileName !== currentFilePath); + + if (currentFileChanges.length > 0) { + // Apply all edits in the current file using `additionalTextEdits` + const additionalWorkspaceEdit = + tsFileTextChangesToLspWorkspaceEdit(currentFileChanges, getScriptInfo).changes; + if (additionalWorkspaceEdit !== undefined) { + for (const edit of Object.values(additionalWorkspaceEdit)) { + additionalTextEdits.push(...edit); + } + } + } + + if (otherWorkspaceFileChanges.length > 0) { + commandTextEditors.push( + tsFileTextChangesToLspWorkspaceEdit(otherWorkspaceFileChanges, getScriptInfo), + ); + } + } + + let command: lsp.Command|undefined = undefined; + if (commandTextEditors.length > 0) { + // Create command that applies all edits not in the current file. + command = { + title: '', + command: 'angular.applyCompletionCodeAction', + arguments: [commandTextEditors], + }; + } + + return { + command, + additionalTextEdits: additionalTextEdits.length ? additionalTextEdits : undefined + }; +} diff --git a/yarn.lock b/yarn.lock index 48b2df07fc..cb3acc2284 100644 --- a/yarn.lock +++ b/yarn.lock @@ -158,10 +158,10 @@ uuid "^8.3.2" yargs "^17.0.0" -"@angular/language-service@18.1.0-next.0": - version "18.1.0-next.0" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.1.0-next.0.tgz#ad62863fd2172d494c2c464dad35a8b1ca47e8b3" - integrity sha512-9kMpU+P9KY0YK56GlR6csFq/8GCZUPcTkTGwbMoOFLJCBa/y/tho9Ikl7epupl1GjaYZraKqNUxH+5z4P0DzCg== +"@angular/language-service@18.1.0-next.2": + version "18.1.0-next.2" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.1.0-next.2.tgz#f8e31a175ea3df6535f50e1bacf5038e83b5d6d4" + integrity sha512-d1c/rOmbmVxigzuENEdSKjEx+/tqSbuoQJ5iHUmof/rRQGub4fzFI2I3d2sVOJ4eP38/jifVMWGrX0MdrBbJAw== "@assemblyscript/loader@^0.10.1": version "0.10.1"