Skip to content

Commit 18291b8

Browse files
ayazhafizKeen Yee Liau
authored andcommitted
feat: Add Command to view template typecheck block
This patch adds a command to retrieve and display the typecheck block for a template under the user's active selections (if any), and highlights the span of the node(s) in the typecheck block that correspond to the template node under the user's active selection (if any). The typecheck block is made available via a dedicated text document provider that queries fresh typecheck block content whenever the `getTemplateTcb` command is invoked. See also angular/angular#39974, which provides the language service implementations needed for this feature.
1 parent cd3ed23 commit 18291b8

File tree

9 files changed

+239
-10
lines changed

9 files changed

+239
-10
lines changed

client/src/client.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@ import * as lsp from 'vscode-languageclient/node';
1313

1414
import {ProjectLoadingFinish, ProjectLoadingStart, SuggestIvyLanguageService, SuggestIvyLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../common/notifications';
1515
import {NgccProgress, NgccProgressToken, NgccProgressType} from '../common/progress';
16+
import {GetTcbRequest} from '../common/requests';
1617

1718
import {ProgressReporter} from './progress-reporter';
1819

20+
interface GetTcbResponse {
21+
uri: vscode.Uri;
22+
content: string;
23+
selections: vscode.Range[];
24+
}
25+
1926
export class AngularLanguageClient implements vscode.Disposable {
2027
private client: lsp.LanguageClient|null = null;
2128
private readonly disposables: vscode.Disposable[] = [];
@@ -93,6 +100,33 @@ export class AngularLanguageClient implements vscode.Disposable {
93100
this.client = null;
94101
}
95102

103+
/**
104+
* Requests a template typecheck block at the current cursor location in the
105+
* specified editor.
106+
*/
107+
async getTcbUnderCursor(textEditor: vscode.TextEditor): Promise<GetTcbResponse|undefined> {
108+
if (this.client === null) {
109+
return undefined;
110+
}
111+
const c2pConverter = this.client.code2ProtocolConverter;
112+
// Craft a request by converting vscode params to LSP. The corresponding
113+
// response is in LSP.
114+
const response = await this.client.sendRequest(GetTcbRequest, {
115+
textDocument: c2pConverter.asTextDocumentIdentifier(textEditor.document),
116+
position: c2pConverter.asPosition(textEditor.selection.active),
117+
});
118+
if (response === null) {
119+
return undefined;
120+
}
121+
const p2cConverter = this.client.protocol2CodeConverter;
122+
// Convert the response from LSP back to vscode.
123+
return {
124+
uri: p2cConverter.asUri(response.uri),
125+
content: response.content,
126+
selections: p2cConverter.asRanges(response.selections),
127+
};
128+
}
129+
96130
get initializeResult(): lsp.InitializeResult|undefined {
97131
return this.client?.initializeResult;
98132
}

client/src/commands.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@
99
import * as vscode from 'vscode';
1010
import {ServerOptions} from '../common/initialize';
1111
import {AngularLanguageClient} from './client';
12+
import {ANGULAR_SCHEME, TcbContentProvider} from './providers';
1213

1314
/**
1415
* Represent a vscode command with an ID and an impl function `execute`.
1516
*/
16-
interface Command {
17-
id: string;
18-
execute(): Promise<unknown>;
19-
}
17+
type Command = {
18+
id: string,
19+
isTextEditorCommand: false,
20+
execute(): Promise<unknown>,
21+
}|{
22+
id: string,
23+
isTextEditorCommand: true,
24+
execute(textEditor: vscode.TextEditor): Promise<unknown>,
25+
};
2026

2127
/**
2228
* Restart the language server by killing the process then spanwing a new one.
@@ -26,6 +32,7 @@ interface Command {
2632
function restartNgServer(client: AngularLanguageClient): Command {
2733
return {
2834
id: 'angular.restartNgServer',
35+
isTextEditorCommand: false,
2936
async execute() {
3037
await client.stop();
3138
await client.start();
@@ -39,6 +46,7 @@ function restartNgServer(client: AngularLanguageClient): Command {
3946
function openLogFile(client: AngularLanguageClient): Command {
4047
return {
4148
id: 'angular.openLogFile',
49+
isTextEditorCommand: false,
4250
async execute() {
4351
const serverOptions: ServerOptions|undefined = client.initializeResult?.serverOptions;
4452
if (!serverOptions?.logFile) {
@@ -64,6 +72,50 @@ function openLogFile(client: AngularLanguageClient): Command {
6472
};
6573
}
6674

75+
/**
76+
* Command getTemplateTcb displays a typecheck block for the template a user has
77+
* an active selection over, if any.
78+
* @param ngClient LSP client for the active session
79+
* @param context extension context to which disposables are pushed
80+
*/
81+
function getTemplateTcb(
82+
ngClient: AngularLanguageClient, context: vscode.ExtensionContext): Command {
83+
const TCB_HIGHLIGHT_DECORATION = vscode.window.createTextEditorDecorationType({
84+
// See https://code.visualstudio.com/api/references/theme-color#editor-colors
85+
backgroundColor: new vscode.ThemeColor('editor.selectionHighlightBackground'),
86+
});
87+
88+
const tcbProvider = new TcbContentProvider();
89+
const disposable = vscode.workspace.registerTextDocumentContentProvider(
90+
ANGULAR_SCHEME,
91+
tcbProvider,
92+
);
93+
context.subscriptions.push(disposable);
94+
95+
return {
96+
id: 'angular.getTemplateTcb',
97+
isTextEditorCommand: true,
98+
async execute(textEditor: vscode.TextEditor) {
99+
tcbProvider.clear();
100+
const response = await ngClient.getTcbUnderCursor(textEditor);
101+
if (response === undefined) {
102+
return undefined;
103+
}
104+
// Change the scheme of the URI from `file` to `ng` so that the document
105+
// content is requested from our own `TcbContentProvider`.
106+
const tcbUri = response.uri.with({
107+
scheme: ANGULAR_SCHEME,
108+
});
109+
tcbProvider.update(tcbUri, response.content);
110+
const editor = await vscode.window.showTextDocument(tcbUri, {
111+
viewColumn: vscode.ViewColumn.Beside,
112+
preserveFocus: true, // cursor remains in the active editor
113+
});
114+
editor.setDecorations(TCB_HIGHLIGHT_DECORATION, response.selections);
115+
}
116+
};
117+
}
118+
67119
/**
68120
* Register all supported vscode commands for the Angular extension.
69121
* @param client language client
@@ -74,10 +126,13 @@ export function registerCommands(
74126
const commands: Command[] = [
75127
restartNgServer(client),
76128
openLogFile(client),
129+
getTemplateTcb(client, context),
77130
];
78131

79132
for (const command of commands) {
80-
const disposable = vscode.commands.registerCommand(command.id, command.execute);
133+
const disposable = command.isTextEditorCommand ?
134+
vscode.commands.registerTextEditorCommand(command.id, command.execute) :
135+
vscode.commands.registerCommand(command.id, command.execute);
81136
context.subscriptions.push(disposable);
82137
}
83138
}

client/src/providers.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as vscode from 'vscode';
10+
11+
export const ANGULAR_SCHEME = 'ng';
12+
13+
/**
14+
* Allocate a provider of documents corresponding to the `ng` URI scheme,
15+
* which we will use to provide a virtual document with the TCB contents.
16+
*
17+
* We use a virtual document provider rather than opening an untitled file to
18+
* ensure the buffer remains readonly (https://github.com/microsoft/vscode/issues/4873).
19+
*/
20+
export class TcbContentProvider implements vscode.TextDocumentContentProvider {
21+
/**
22+
* Event emitter used to notify VSCode of a change to the TCB virtual document,
23+
* prompting it to re-evaluate the document content. This is needed to bust
24+
* VSCode's document cache if someone requests a TCB that was previously opened.
25+
* https://code.visualstudio.com/api/extension-guides/virtual-documents#update-virtual-documents
26+
*/
27+
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
28+
/**
29+
* Name of the typecheck file.
30+
*/
31+
private tcbFile: vscode.Uri|null = null;
32+
/**
33+
* Content of the entire typecheck file.
34+
*/
35+
private tcbContent: string|null = null;
36+
37+
/**
38+
* This callback is invoked only when user explicitly requests to view or
39+
* update typecheck file. We do not automatically update the typecheck document
40+
* when the source file changes.
41+
*/
42+
readonly onDidChange = this.onDidChangeEmitter.event;
43+
44+
provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken):
45+
vscode.ProviderResult<string> {
46+
if (uri.toString() !== this.tcbFile?.toString()) {
47+
return null;
48+
}
49+
return this.tcbContent;
50+
}
51+
52+
update(uri: vscode.Uri, content: string) {
53+
this.tcbFile = uri;
54+
this.tcbContent = content;
55+
this.onDidChangeEmitter.fire(uri);
56+
}
57+
58+
clear() {
59+
this.tcbFile = null;
60+
this.tcbContent = null;
61+
}
62+
}

common/requests.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as lsp from 'vscode-languageserver-protocol';
10+
11+
export interface GetTcbParams {
12+
textDocument: lsp.TextDocumentIdentifier;
13+
position: lsp.Position;
14+
}
15+
16+
export const GetTcbRequest =
17+
new lsp.RequestType<GetTcbParams, GetTcbResponse|null, /* error */ void>('angular/getTcb');
18+
19+
export interface GetTcbResponse {
20+
uri: lsp.DocumentUri;
21+
content: string;
22+
selections: lsp.Range[]
23+
}

integration/lsp/ivy_spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {URI} from 'vscode-uri';
1313

1414
import {ProjectLanguageService, ProjectLanguageServiceParams, SuggestStrictMode, SuggestStrictModeParams} from '../../common/notifications';
1515
import {NgccProgress, NgccProgressToken, NgccProgressType} from '../../common/progress';
16+
import {GetTcbRequest} from '../../common/requests';
1617

1718
import {APP_COMPONENT, createConnection, createTracer, FOO_COMPONENT, FOO_TEMPLATE, initializeServer, openTextDocument, TSCONFIG} from './test_utils';
1819

@@ -312,6 +313,20 @@ describe('Angular Ivy language server', () => {
312313
});
313314
});
314315
});
316+
317+
describe('getTcb', () => {
318+
it('should handle getTcb request', async () => {
319+
openTextDocument(client, FOO_TEMPLATE);
320+
await waitForNgcc(client);
321+
const response = await client.sendRequest(GetTcbRequest, {
322+
textDocument: {
323+
uri: `file://${FOO_TEMPLATE}`,
324+
},
325+
position: {line: 0, character: 3},
326+
});
327+
expect(response).toBeDefined();
328+
});
329+
});
315330
});
316331

317332
function onNgccProgress(client: MessageConnection): Promise<string> {

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,22 @@
2727
"command": "angular.openLogFile",
2828
"title": "Open Angular Server log",
2929
"category": "Angular"
30+
},
31+
{
32+
"command": "angular.getTemplateTcb",
33+
"title": "View Template Typecheck Block",
34+
"category": "Angular"
3035
}
3136
],
37+
"menus": {
38+
"editor/context": [
39+
{
40+
"when": "resourceLangId == html || resourceLangId == typescript",
41+
"command": "angular.getTemplateTcb",
42+
"group": "angular"
43+
}
44+
]
45+
},
3246
"configuration": {
3347
"title": "Angular Language Service",
3448
"properties": {
@@ -164,4 +178,4 @@
164178
"type": "git",
165179
"url": "https://github.com/angular/vscode-ng-language-service"
166180
}
167-
}
181+
}

server/src/session.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {NgLanguageService} from '@angular/language-service';
910
import * as ts from 'typescript/lib/tsserverlibrary';
1011
import * as lsp from 'vscode-languageserver/node';
1112

1213
import {ServerOptions} from '../common/initialize';
1314
import {ProjectLanguageService, ProjectLoadingFinish, ProjectLoadingStart, SuggestIvyLanguageService, SuggestStrictMode} from '../common/notifications';
1415
import {NgccProgressToken, NgccProgressType} from '../common/progress';
16+
import {GetTcbParams, GetTcbRequest, GetTcbResponse} from '../common/requests';
1517

1618
import {readNgCompletionData, tsCompletionEntryToLspCompletionItem} from './completion';
1719
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
@@ -132,6 +134,30 @@ export class Session {
132134
conn.onHover(p => this.onHover(p));
133135
conn.onCompletion(p => this.onCompletion(p));
134136
conn.onCompletionResolve(p => this.onCompletionResolve(p));
137+
conn.onRequest(GetTcbRequest, p => this.onGetTcb(p));
138+
}
139+
140+
private onGetTcb(params: GetTcbParams): GetTcbResponse|undefined {
141+
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
142+
if (lsInfo === undefined) {
143+
return undefined;
144+
}
145+
const {languageService, scriptInfo} = lsInfo;
146+
const offset = lspPositionToTsPosition(scriptInfo, params.position);
147+
const response = languageService.getTcb(scriptInfo.fileName, offset);
148+
if (response === undefined) {
149+
return undefined;
150+
}
151+
const {fileName: tcfName} = response;
152+
const tcfScriptInfo = this.projectService.getScriptInfo(tcfName);
153+
if (!tcfScriptInfo) {
154+
return undefined;
155+
}
156+
return {
157+
uri: filePathToUri(tcfName),
158+
content: response.content,
159+
selections: response.selections.map((span => tsTextSpanToLspRange(tcfScriptInfo, span))),
160+
};
135161
}
136162

137163
private async runNgcc(configFilePath: string) {
@@ -663,7 +689,7 @@ export class Session {
663689
}
664690

665691
private getLSAndScriptInfo(textDocumentOrFileName: lsp.TextDocumentIdentifier|string):
666-
{languageService: ts.LanguageService, scriptInfo: ts.server.ScriptInfo}|undefined {
692+
{languageService: NgLanguageService, scriptInfo: ts.server.ScriptInfo}|undefined {
667693
const filePath = lsp.TextDocumentIdentifier.is(textDocumentOrFileName) ?
668694
uriToFilePath(textDocumentOrFileName.uri) :
669695
textDocumentOrFileName;
@@ -683,7 +709,7 @@ export class Session {
683709
return undefined;
684710
}
685711
return {
686-
languageService: project.getLanguageService(),
712+
languageService: project.getLanguageService() as NgLanguageService,
687713
scriptInfo,
688714
};
689715
}

server/src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function uriToFilePath(uri: string): string {
3333
* Converts the specified `filePath` to a proper URI.
3434
* @param filePath
3535
*/
36-
export function filePathToUri(filePath: string): string {
36+
export function filePathToUri(filePath: string): lsp.DocumentUri {
3737
return URI.file(filePath).toString();
3838
}
3939

server/src/version_provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as fs from 'fs';
1010

1111
const MIN_TS_VERSION = '4.1';
12-
const MIN_NG_VERSION = '11.1';
12+
const MIN_NG_VERSION = '11.2';
1313

1414
/**
1515
* Represents a valid node module that has been successfully resolved.

0 commit comments

Comments
 (0)