Skip to content

Commit 6fb39d9

Browse files
committed
feat(language-server): Support client-side file watching via onDidChangeWatchedFiles
This implements `onDidChangedWatchedFiles` in the language server, which allows the client to communicate changes to files rather than having the server create system file/directory watchers. This option is enabled in the extension via the `angular.server.useClientSideFileWatcher` setting. When enabled, the extension registers a FileSystemWatcher for .ts, .html, and package.json files and forwards events to the server. The server completely disables its internal native file watchers (via a new 'ServerHost' implementation that stubs watchFile/watchDirectory). This is significantly more performant and reliable than native watching for several reasons: - Deduplication: VS Code already watches the workspace. Piggybacking on these events prevents the server from duplicating thousands of file watchers. - OS Limits: Since the server opens zero watcher handles, it is impossible to hit OS limits (ENOSPC), no matter how large the repo is. - Optimization: VS Code's watcher uses highly optimized native implementations (like Parcel Watcher in Rust/C++) which handle recursive directory watching far better than Node.js's 'fs.watch'. - Debouncing: The client aggregates extremely frequent file events (e.g., during 'git checkout'), reducing the flood of processing requests to the server. This option was tested in one very large internal project and observed ~10-50x improvement of initialization times. fixes #66543
1 parent 85122cb commit 6fb39d9

File tree

7 files changed

+138
-32
lines changed

7 files changed

+138
-32
lines changed

vscode-ng-language-service/client/src/client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ export class AngularLanguageClient implements vscode.Disposable {
5151
},
5252
});
5353

54+
const config = vscode.workspace.getConfiguration();
55+
const useClientSideWatching = config.get('angular.server.useClientSideFileWatcher');
56+
const fileEvents = [
57+
// Notify the server about file changes to tsconfig.json contained in the workspace
58+
vscode.workspace.createFileSystemWatcher('**/tsconfig.json'),
59+
];
60+
if (useClientSideWatching) {
61+
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.ts'));
62+
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.html'));
63+
// While we don't need general JSON watching, TypeScript relies on package.json for module resolution, type acquisition, and auto-imports.
64+
// If we don't watch it, npm install changes or dependency updates might be missed by the Language Service
65+
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/package.json'));
66+
}
67+
5468
this.outputChannel = vscode.window.createOutputChannel(this.name);
5569
// Options to control the language client
5670
this.clientOptions = {
@@ -62,10 +76,7 @@ export class AngularLanguageClient implements vscode.Disposable {
6276
{scheme: 'file', language: 'typescript'},
6377
],
6478
synchronize: {
65-
fileEvents: [
66-
// Notify the server about file changes to tsconfig.json contained in the workspace
67-
vscode.workspace.createFileSystemWatcher('**/tsconfig.json'),
68-
],
79+
fileEvents,
6980
},
7081
// Don't let our output console pop open
7182
revealOutputChannelOn: lsp.RevealOutputChannelOn.Never,
@@ -439,6 +450,12 @@ export class AngularLanguageClient implements vscode.Disposable {
439450
const tsProbeLocations = [...getProbeLocations(this.context.extensionPath)];
440451
args.push('--tsProbeLocations', tsProbeLocations.join(','));
441452

453+
const supportClientSide = config.get('angular.server.useClientSideFileWatcher');
454+
455+
if (supportClientSide) {
456+
args.push('--useClientSideFileWatcher');
457+
}
458+
442459
return args;
443460
}
444461

vscode-ng-language-service/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@
153153
"type": "string",
154154
"default": "",
155155
"markdownDescription": "A comma-separated list of error codes in templates whose diagnostics should be ignored."
156+
},
157+
"angular.server.useClientSideFileWatcher": {
158+
"type": "boolean",
159+
"default": true,
160+
"markdownDescription": "When enabled, the Angular Language Service will delegate file watching to VS Code instead of creating its own internal file watchers. This can significantly improve performance (greater than 10x faster initialization) and reduce resource usage in large repositories."
156161
}
157162
}
158163
},

vscode-ng-language-service/server/src/cmdline_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ interface CommandLineOptions {
7070
disableLetSyntax: boolean;
7171
angularCoreVersion?: string;
7272
suppressAngularDiagnosticCodes?: string;
73+
useClientSideFileWatcher?: boolean;
7374
}
7475

7576
export function parseCommandLine(argv: string[]): CommandLineOptions {
@@ -95,6 +96,7 @@ export function parseCommandLine(argv: string[]): CommandLineOptions {
9596
disableLetSyntax: hasArgument(argv, '--disableLetSyntax'),
9697
angularCoreVersion: findArgument(argv, '--angularCoreVersion'),
9798
suppressAngularDiagnosticCodes: findArgument(argv, '--suppressAngularDiagnosticCodes'),
99+
useClientSideFileWatcher: hasArgument(argv, '--useClientSideFileWatcher'),
98100
};
99101
}
100102

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*!
2+
* @license
3+
* Copyright Google LLC 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.dev/license
7+
*/
8+
9+
import * as lsp from 'vscode-languageserver/node';
10+
import {uriToFilePath} from '../utils';
11+
import {ServerHost} from '../server_host';
12+
import * as ts from 'typescript/lib/tsserverlibrary';
13+
14+
export function onDidChangeWatchedFiles(
15+
params: lsp.DidChangeWatchedFilesParams,
16+
logger: ts.server.Logger,
17+
host: ServerHost,
18+
) {
19+
for (const change of params.changes) {
20+
const filePath = uriToFilePath(change.uri);
21+
logger.info(`Received file change event for ${filePath} type ${change.type}`);
22+
host.notifyFileChange(filePath, change.type);
23+
}
24+
}

vscode-ng-language-service/server/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function main() {
3535
const isG3 = ts.resolvedPath.includes('/google3/');
3636

3737
// ServerHost provides native OS functionality
38-
const host = new ServerHost(isG3);
38+
const host = new ServerHost(isG3, options.useClientSideFileWatcher ?? false);
3939

4040
// Establish a new server session that encapsulates lsp connection.
4141
const session = new Session({

vscode-ng-language-service/server/src/server_host.ts

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

99
import * as ts from 'typescript/lib/tsserverlibrary';
10+
import * as path from 'path';
11+
import * as lsp from 'vscode-languageserver/node';
1012

1113
const NOOP_WATCHER: ts.FileWatcher = {
1214
close() {},
@@ -22,8 +24,16 @@ export class ServerHost implements ts.server.ServerHost {
2224
readonly args: string[];
2325
readonly newLine: string;
2426
readonly useCaseSensitiveFileNames: boolean;
25-
26-
constructor(readonly isG3: boolean) {
27+
private readonly fileWatchers = new Map<string, Set<ts.FileWatcherCallback>>();
28+
private readonly directoryWatchers = new Map<
29+
string,
30+
Set<{callback: ts.DirectoryWatcherCallback; recursive: boolean}>
31+
>();
32+
33+
constructor(
34+
readonly isG3: boolean,
35+
private readonly useClientSideFileWatcher: boolean,
36+
) {
2737
this.args = ts.sys.args;
2838
this.newLine = ts.sys.newLine;
2939
this.useCaseSensitiveFileNames = ts.sys.useCaseSensitiveFileNames;
@@ -59,7 +69,24 @@ export class ServerHost implements ts.server.ServerHost {
5969
pollingInterval?: number,
6070
options?: ts.WatchOptions,
6171
): ts.FileWatcher {
62-
return ts.sys.watchFile!(path, callback, pollingInterval, options);
72+
if (!this.useClientSideFileWatcher) {
73+
return ts.sys.watchFile!(path, callback, pollingInterval, options);
74+
}
75+
76+
const callbacks = this.fileWatchers.get(path) ?? new Set();
77+
callbacks.add(callback);
78+
this.fileWatchers.set(path, callbacks);
79+
return {
80+
close: () => {
81+
const callbacks = this.fileWatchers.get(path);
82+
if (callbacks) {
83+
callbacks.delete(callback);
84+
if (callbacks.size === 0) {
85+
this.fileWatchers.delete(path);
86+
}
87+
}
88+
},
89+
};
6390
}
6491

6592
watchDirectory(
@@ -71,7 +98,56 @@ export class ServerHost implements ts.server.ServerHost {
7198
if (this.isG3 && path.startsWith('/google/src')) {
7299
return NOOP_WATCHER;
73100
}
74-
return ts.sys.watchDirectory!(path, callback, recursive, options);
101+
if (!this.useClientSideFileWatcher) {
102+
return ts.sys.watchDirectory!(path, callback, recursive, options);
103+
}
104+
const callbacks = this.directoryWatchers.get(path) ?? new Set();
105+
const watcher = {callback, recursive: !!recursive};
106+
callbacks.add(watcher);
107+
this.directoryWatchers.set(path, callbacks);
108+
return {
109+
close: () => {
110+
const callbacks = this.directoryWatchers.get(path);
111+
if (callbacks) {
112+
callbacks.delete(watcher);
113+
if (callbacks.size === 0) {
114+
this.directoryWatchers.delete(path);
115+
}
116+
}
117+
},
118+
};
119+
}
120+
121+
notifyFileChange(fileName: string, type: lsp.FileChangeType): void {
122+
if (!this.useClientSideFileWatcher) {
123+
return;
124+
}
125+
126+
const callbacks = this.fileWatchers.get(fileName);
127+
if (callbacks) {
128+
callbacks.forEach((callback) =>
129+
callback(
130+
fileName,
131+
type === lsp.FileChangeType.Deleted
132+
? ts.FileWatcherEventKind.Deleted
133+
: ts.FileWatcherEventKind.Changed,
134+
),
135+
);
136+
}
137+
138+
for (const [dirPath, watchers] of this.directoryWatchers) {
139+
if (fileName.startsWith(dirPath)) {
140+
// If it's a direct child or recursive watch
141+
const relative = path.relative(dirPath, fileName);
142+
const isDirectChild = !relative.includes(path.sep);
143+
144+
for (const watcher of watchers) {
145+
if (watcher.recursive || isDirectChild) {
146+
watcher.callback(fileName);
147+
}
148+
}
149+
}
150+
}
75151
}
76152

77153
resolvePath(path: string): string {

vscode-ng-language-service/server/src/session.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,11 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {
10-
ApplyRefactoringResult,
11-
isNgLanguageService,
12-
NgLanguageService,
13-
PluginConfig,
14-
} from '@angular/language-service/api';
9+
import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api';
1510
import * as ts from 'typescript/lib/tsserverlibrary';
1611
import {promisify} from 'util';
17-
import {getLanguageService as getHTMLLanguageService} from 'vscode-html-languageservice';
18-
import {getSCSSLanguageService} from 'vscode-css-languageservice';
19-
import {TextDocument} from 'vscode-languageserver-textdocument';
2012
import * as lsp from 'vscode-languageserver/node';
2113

22-
import {ServerOptions} from '../../common/initialize';
2314
import {
2415
ProjectLanguageService,
2516
ProjectLoadingFinish,
@@ -28,30 +19,19 @@ import {
2819
} from '../../common/notifications';
2920
import {
3021
GetComponentsWithTemplateFile,
31-
GetTcbParams,
3222
GetTcbRequest,
33-
GetTcbResponse,
3423
GetTemplateLocationForComponent,
35-
GetTemplateLocationForComponentParams,
3624
IsInAngularProject,
37-
IsInAngularProjectParams,
3825
} from '../../common/requests';
3926

4027
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
41-
import {getHTMLVirtualContent, getSCSSVirtualContent, isInlineStyleNode} from './embedded_support';
4228
import {ServerHost} from './server_host';
43-
import {documentationToMarkdown} from './text_render';
4429
import {
4530
filePathToUri,
46-
getMappedDefinitionInfo,
4731
isConfiguredProject,
4832
isDebugMode,
49-
lspPositionToTsPosition,
5033
lspRangeToTsPositions,
5134
MruTracker,
52-
tsDisplayPartsToText,
53-
tsFileTextChangesToLspWorkspaceEdit,
54-
tsTextSpanToLspRange,
5535
uriToFilePath,
5636
} from './utils';
5737
import {onCodeAction, onCodeActionResolve} from './handlers/code_actions';
@@ -65,6 +45,7 @@ import {onRenameRequest, onPrepareRename} from './handlers/rename';
6545
import {onSignatureHelp} from './handlers/signature';
6646
import {onGetTcb} from './handlers/tcb';
6747
import {onGetTemplateLocationForComponent, isInAngularProject} from './handlers/template_info';
48+
import {onDidChangeWatchedFiles} from './handlers/did_change_watched_files';
6849

6950
export interface SessionOptions {
7051
host: ServerHost;
@@ -87,8 +68,6 @@ enum LanguageId {
8768
HTML = 'html',
8869
}
8970

90-
// Empty definition range for files without `scriptInfo`
91-
const EMPTY_RANGE = lsp.Range.create(0, 0, 0, 0);
9271
const setImmediateP = promisify(setImmediate);
9372

9473
const alwaysSuppressDiagnostics: number[] = [
@@ -104,6 +83,7 @@ export class Session {
10483
readonly connection: lsp.Connection;
10584
readonly projectService: ts.server.ProjectService;
10685
readonly logger: ts.server.Logger;
86+
private readonly host: ServerHost;
10787
private readonly logToConsole: boolean;
10888
private readonly openFiles = new MruTracker();
10989
readonly includeAutomaticOptionalChainCompletions: boolean;
@@ -128,6 +108,7 @@ export class Session {
128108
this.includeCompletionsWithSnippetText = options.includeCompletionsWithSnippetText;
129109
this.includeCompletionsForModuleExports = options.includeCompletionsForModuleExports;
130110
this.logger = options.logger;
111+
this.host = options.host;
131112
this.logToConsole = options.logToConsole;
132113
this.defaultPreferences = {
133114
...this.defaultPreferences,
@@ -243,6 +224,7 @@ export class Session {
243224

244225
private addProtocolHandlers(conn: lsp.Connection) {
245226
conn.onInitialize((p) => onInitialize(this, p));
227+
conn.onDidChangeWatchedFiles((p) => onDidChangeWatchedFiles(p, this.logger, this.host));
246228
conn.onDidOpenTextDocument((p) => this.onDidOpenTextDocument(p));
247229
conn.onDidCloseTextDocument((p) => this.onDidCloseTextDocument(p));
248230
conn.onDidChangeTextDocument((p) => this.onDidChangeTextDocument(p));

0 commit comments

Comments
 (0)