mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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
This commit is contained in:
parent
85122cb12d
commit
6fb39d9b62
7 changed files with 137 additions and 31 deletions
|
|
@ -51,6 +51,20 @@ export class AngularLanguageClient implements vscode.Disposable {
|
|||
},
|
||||
});
|
||||
|
||||
const config = vscode.workspace.getConfiguration();
|
||||
const useClientSideWatching = config.get('angular.server.useClientSideFileWatcher');
|
||||
const fileEvents = [
|
||||
// Notify the server about file changes to tsconfig.json contained in the workspace
|
||||
vscode.workspace.createFileSystemWatcher('**/tsconfig.json'),
|
||||
];
|
||||
if (useClientSideWatching) {
|
||||
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.ts'));
|
||||
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.html'));
|
||||
// While we don't need general JSON watching, TypeScript relies on package.json for module resolution, type acquisition, and auto-imports.
|
||||
// If we don't watch it, npm install changes or dependency updates might be missed by the Language Service
|
||||
fileEvents.push(vscode.workspace.createFileSystemWatcher('**/package.json'));
|
||||
}
|
||||
|
||||
this.outputChannel = vscode.window.createOutputChannel(this.name);
|
||||
// Options to control the language client
|
||||
this.clientOptions = {
|
||||
|
|
@ -62,10 +76,7 @@ export class AngularLanguageClient implements vscode.Disposable {
|
|||
{scheme: 'file', language: 'typescript'},
|
||||
],
|
||||
synchronize: {
|
||||
fileEvents: [
|
||||
// Notify the server about file changes to tsconfig.json contained in the workspace
|
||||
vscode.workspace.createFileSystemWatcher('**/tsconfig.json'),
|
||||
],
|
||||
fileEvents,
|
||||
},
|
||||
// Don't let our output console pop open
|
||||
revealOutputChannelOn: lsp.RevealOutputChannelOn.Never,
|
||||
|
|
@ -439,6 +450,12 @@ export class AngularLanguageClient implements vscode.Disposable {
|
|||
const tsProbeLocations = [...getProbeLocations(this.context.extensionPath)];
|
||||
args.push('--tsProbeLocations', tsProbeLocations.join(','));
|
||||
|
||||
const supportClientSide = config.get('angular.server.useClientSideFileWatcher');
|
||||
|
||||
if (supportClientSide) {
|
||||
args.push('--useClientSideFileWatcher');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@
|
|||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "A comma-separated list of error codes in templates whose diagnostics should be ignored."
|
||||
},
|
||||
"angular.server.useClientSideFileWatcher": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ interface CommandLineOptions {
|
|||
disableLetSyntax: boolean;
|
||||
angularCoreVersion?: string;
|
||||
suppressAngularDiagnosticCodes?: string;
|
||||
useClientSideFileWatcher?: boolean;
|
||||
}
|
||||
|
||||
export function parseCommandLine(argv: string[]): CommandLineOptions {
|
||||
|
|
@ -95,6 +96,7 @@ export function parseCommandLine(argv: string[]): CommandLineOptions {
|
|||
disableLetSyntax: hasArgument(argv, '--disableLetSyntax'),
|
||||
angularCoreVersion: findArgument(argv, '--angularCoreVersion'),
|
||||
suppressAngularDiagnosticCodes: findArgument(argv, '--suppressAngularDiagnosticCodes'),
|
||||
useClientSideFileWatcher: hasArgument(argv, '--useClientSideFileWatcher'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*!
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import * as lsp from 'vscode-languageserver/node';
|
||||
import {uriToFilePath} from '../utils';
|
||||
import {ServerHost} from '../server_host';
|
||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||
|
||||
export function onDidChangeWatchedFiles(
|
||||
params: lsp.DidChangeWatchedFilesParams,
|
||||
logger: ts.server.Logger,
|
||||
host: ServerHost,
|
||||
) {
|
||||
for (const change of params.changes) {
|
||||
const filePath = uriToFilePath(change.uri);
|
||||
logger.info(`Received file change event for ${filePath} type ${change.type}`);
|
||||
host.notifyFileChange(filePath, change.type);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ function main() {
|
|||
const isG3 = ts.resolvedPath.includes('/google3/');
|
||||
|
||||
// ServerHost provides native OS functionality
|
||||
const host = new ServerHost(isG3);
|
||||
const host = new ServerHost(isG3, options.useClientSideFileWatcher ?? false);
|
||||
|
||||
// Establish a new server session that encapsulates lsp connection.
|
||||
const session = new Session({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
*/
|
||||
|
||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||
import * as path from 'path';
|
||||
import * as lsp from 'vscode-languageserver/node';
|
||||
|
||||
const NOOP_WATCHER: ts.FileWatcher = {
|
||||
close() {},
|
||||
|
|
@ -22,8 +24,16 @@ export class ServerHost implements ts.server.ServerHost {
|
|||
readonly args: string[];
|
||||
readonly newLine: string;
|
||||
readonly useCaseSensitiveFileNames: boolean;
|
||||
private readonly fileWatchers = new Map<string, Set<ts.FileWatcherCallback>>();
|
||||
private readonly directoryWatchers = new Map<
|
||||
string,
|
||||
Set<{callback: ts.DirectoryWatcherCallback; recursive: boolean}>
|
||||
>();
|
||||
|
||||
constructor(readonly isG3: boolean) {
|
||||
constructor(
|
||||
readonly isG3: boolean,
|
||||
private readonly useClientSideFileWatcher: boolean,
|
||||
) {
|
||||
this.args = ts.sys.args;
|
||||
this.newLine = ts.sys.newLine;
|
||||
this.useCaseSensitiveFileNames = ts.sys.useCaseSensitiveFileNames;
|
||||
|
|
@ -59,7 +69,24 @@ export class ServerHost implements ts.server.ServerHost {
|
|||
pollingInterval?: number,
|
||||
options?: ts.WatchOptions,
|
||||
): ts.FileWatcher {
|
||||
return ts.sys.watchFile!(path, callback, pollingInterval, options);
|
||||
if (!this.useClientSideFileWatcher) {
|
||||
return ts.sys.watchFile!(path, callback, pollingInterval, options);
|
||||
}
|
||||
|
||||
const callbacks = this.fileWatchers.get(path) ?? new Set();
|
||||
callbacks.add(callback);
|
||||
this.fileWatchers.set(path, callbacks);
|
||||
return {
|
||||
close: () => {
|
||||
const callbacks = this.fileWatchers.get(path);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
this.fileWatchers.delete(path);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
watchDirectory(
|
||||
|
|
@ -71,7 +98,56 @@ export class ServerHost implements ts.server.ServerHost {
|
|||
if (this.isG3 && path.startsWith('/google/src')) {
|
||||
return NOOP_WATCHER;
|
||||
}
|
||||
return ts.sys.watchDirectory!(path, callback, recursive, options);
|
||||
if (!this.useClientSideFileWatcher) {
|
||||
return ts.sys.watchDirectory!(path, callback, recursive, options);
|
||||
}
|
||||
const callbacks = this.directoryWatchers.get(path) ?? new Set();
|
||||
const watcher = {callback, recursive: !!recursive};
|
||||
callbacks.add(watcher);
|
||||
this.directoryWatchers.set(path, callbacks);
|
||||
return {
|
||||
close: () => {
|
||||
const callbacks = this.directoryWatchers.get(path);
|
||||
if (callbacks) {
|
||||
callbacks.delete(watcher);
|
||||
if (callbacks.size === 0) {
|
||||
this.directoryWatchers.delete(path);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
notifyFileChange(fileName: string, type: lsp.FileChangeType): void {
|
||||
if (!this.useClientSideFileWatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this.fileWatchers.get(fileName);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((callback) =>
|
||||
callback(
|
||||
fileName,
|
||||
type === lsp.FileChangeType.Deleted
|
||||
? ts.FileWatcherEventKind.Deleted
|
||||
: ts.FileWatcherEventKind.Changed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const [dirPath, watchers] of this.directoryWatchers) {
|
||||
if (fileName.startsWith(dirPath)) {
|
||||
// If it's a direct child or recursive watch
|
||||
const relative = path.relative(dirPath, fileName);
|
||||
const isDirectChild = !relative.includes(path.sep);
|
||||
|
||||
for (const watcher of watchers) {
|
||||
if (watcher.recursive || isDirectChild) {
|
||||
watcher.callback(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolvePath(path: string): string {
|
||||
|
|
|
|||
|
|
@ -6,20 +6,11 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {
|
||||
ApplyRefactoringResult,
|
||||
isNgLanguageService,
|
||||
NgLanguageService,
|
||||
PluginConfig,
|
||||
} from '@angular/language-service/api';
|
||||
import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api';
|
||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||
import {promisify} from 'util';
|
||||
import {getLanguageService as getHTMLLanguageService} from 'vscode-html-languageservice';
|
||||
import {getSCSSLanguageService} from 'vscode-css-languageservice';
|
||||
import {TextDocument} from 'vscode-languageserver-textdocument';
|
||||
import * as lsp from 'vscode-languageserver/node';
|
||||
|
||||
import {ServerOptions} from '../../common/initialize';
|
||||
import {
|
||||
ProjectLanguageService,
|
||||
ProjectLoadingFinish,
|
||||
|
|
@ -28,30 +19,19 @@ import {
|
|||
} from '../../common/notifications';
|
||||
import {
|
||||
GetComponentsWithTemplateFile,
|
||||
GetTcbParams,
|
||||
GetTcbRequest,
|
||||
GetTcbResponse,
|
||||
GetTemplateLocationForComponent,
|
||||
GetTemplateLocationForComponentParams,
|
||||
IsInAngularProject,
|
||||
IsInAngularProjectParams,
|
||||
} from '../../common/requests';
|
||||
|
||||
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
|
||||
import {getHTMLVirtualContent, getSCSSVirtualContent, isInlineStyleNode} from './embedded_support';
|
||||
import {ServerHost} from './server_host';
|
||||
import {documentationToMarkdown} from './text_render';
|
||||
import {
|
||||
filePathToUri,
|
||||
getMappedDefinitionInfo,
|
||||
isConfiguredProject,
|
||||
isDebugMode,
|
||||
lspPositionToTsPosition,
|
||||
lspRangeToTsPositions,
|
||||
MruTracker,
|
||||
tsDisplayPartsToText,
|
||||
tsFileTextChangesToLspWorkspaceEdit,
|
||||
tsTextSpanToLspRange,
|
||||
uriToFilePath,
|
||||
} from './utils';
|
||||
import {onCodeAction, onCodeActionResolve} from './handlers/code_actions';
|
||||
|
|
@ -65,6 +45,7 @@ import {onRenameRequest, onPrepareRename} from './handlers/rename';
|
|||
import {onSignatureHelp} from './handlers/signature';
|
||||
import {onGetTcb} from './handlers/tcb';
|
||||
import {onGetTemplateLocationForComponent, isInAngularProject} from './handlers/template_info';
|
||||
import {onDidChangeWatchedFiles} from './handlers/did_change_watched_files';
|
||||
|
||||
export interface SessionOptions {
|
||||
host: ServerHost;
|
||||
|
|
@ -87,8 +68,6 @@ enum LanguageId {
|
|||
HTML = 'html',
|
||||
}
|
||||
|
||||
// Empty definition range for files without `scriptInfo`
|
||||
const EMPTY_RANGE = lsp.Range.create(0, 0, 0, 0);
|
||||
const setImmediateP = promisify(setImmediate);
|
||||
|
||||
const alwaysSuppressDiagnostics: number[] = [
|
||||
|
|
@ -104,6 +83,7 @@ export class Session {
|
|||
readonly connection: lsp.Connection;
|
||||
readonly projectService: ts.server.ProjectService;
|
||||
readonly logger: ts.server.Logger;
|
||||
private readonly host: ServerHost;
|
||||
private readonly logToConsole: boolean;
|
||||
private readonly openFiles = new MruTracker();
|
||||
readonly includeAutomaticOptionalChainCompletions: boolean;
|
||||
|
|
@ -128,6 +108,7 @@ export class Session {
|
|||
this.includeCompletionsWithSnippetText = options.includeCompletionsWithSnippetText;
|
||||
this.includeCompletionsForModuleExports = options.includeCompletionsForModuleExports;
|
||||
this.logger = options.logger;
|
||||
this.host = options.host;
|
||||
this.logToConsole = options.logToConsole;
|
||||
this.defaultPreferences = {
|
||||
...this.defaultPreferences,
|
||||
|
|
@ -243,6 +224,7 @@ export class Session {
|
|||
|
||||
private addProtocolHandlers(conn: lsp.Connection) {
|
||||
conn.onInitialize((p) => onInitialize(this, p));
|
||||
conn.onDidChangeWatchedFiles((p) => onDidChangeWatchedFiles(p, this.logger, this.host));
|
||||
conn.onDidOpenTextDocument((p) => this.onDidOpenTextDocument(p));
|
||||
conn.onDidCloseTextDocument((p) => this.onDidCloseTextDocument(p));
|
||||
conn.onDidChangeTextDocument((p) => this.onDidChangeTextDocument(p));
|
||||
|
|
|
|||
Loading…
Reference in a new issue