diff --git a/.vscode/settings.json b/.vscode/settings.json index 251f8c0617a0b..cb577d02703ea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -172,4 +172,5 @@ "css.format.spaceAroundSelectorSeparator": true, "inlineChat.mode": "live", "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsserver.experimental.useVsCodeWatcher": true } diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 34dad264553fd..fb5383a2978b7 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -9,6 +9,7 @@ "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "enabledApiProposals": [ "workspaceTrust", + "createFileSystemWatcher", "multiDocumentHighlightProvider", "mappedEditsProvider", "codeActionAI", @@ -1168,6 +1169,14 @@ "experimental" ] }, + "typescript.tsserver.experimental.useVsCodeWatcher": { + "type": "boolean", + "description": "%configuration.tsserver.useVsCodeWatcher%", + "default": false, + "tags": [ + "experimental" + ] + }, "typescript.tsserver.watchOptions": { "type": "object", "description": "%configuration.tsserver.watchOptions%", diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 796b524a48c3f..e60451eaeb4a9 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -164,6 +164,7 @@ "typescript.suggest.enabled": "Enabled/disable autocomplete suggestions.", "configuration.surveys.enabled": "Enabled/disable occasional surveys that help us improve VS Code's JavaScript and TypeScript support.", "configuration.suggest.completeJSDocs": "Enable/disable suggestion to complete JSDoc comments.", + "configuration.tsserver.useVsCodeWatcher": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", "configuration.tsserver.watchOptions": "Configure which watching strategies should be used to keep track of files and directories.", "configuration.tsserver.watchOptions.watchFile": "Strategy for how individual files are watched.", "configuration.tsserver.watchOptions.watchFile.fixedChunkSizePolling": "Polls files in chunks at regular interval.", diff --git a/extensions/typescript-language-features/src/configuration/configuration.ts b/extensions/typescript-language-features/src/configuration/configuration.ts index 5fce20d1d1f29..47620bd0743b3 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.ts @@ -117,6 +117,7 @@ export interface TypeScriptServiceConfiguration { readonly enableProjectDiagnostics: boolean; readonly maxTsServerMemory: number; readonly enablePromptUseWorkspaceTsdk: boolean; + readonly useVsCodeWatcher: boolean; readonly watchOptions: Proto.WatchOptions | undefined; readonly includePackageJsonAutoImports: 'auto' | 'on' | 'off' | undefined; readonly enableTsServerTracing: boolean; @@ -154,6 +155,7 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu enableProjectDiagnostics: this.readEnableProjectDiagnostics(configuration), maxTsServerMemory: this.readMaxTsServerMemory(configuration), enablePromptUseWorkspaceTsdk: this.readEnablePromptUseWorkspaceTsdk(configuration), + useVsCodeWatcher: this.readUseVsCodeWatcher(configuration), watchOptions: this.readWatchOptions(configuration), includePackageJsonAutoImports: this.readIncludePackageJsonAutoImports(configuration), enableTsServerTracing: this.readEnableTsServerTracing(configuration), @@ -222,7 +224,11 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu return configuration.get('typescript.tsserver.experimental.enableProjectDiagnostics', false); } - protected readWatchOptions(configuration: vscode.WorkspaceConfiguration): Proto.WatchOptions | undefined { + private readUseVsCodeWatcher(configuration: vscode.WorkspaceConfiguration): boolean { + return configuration.get('typescript.tsserver.experimental.useVsCodeWatcher', false); + } + + private readWatchOptions(configuration: vscode.WorkspaceConfiguration): Proto.WatchOptions | undefined { const watchOptions = configuration.get('typescript.tsserver.watchOptions'); // Returned value may be a proxy. Clone it into a normal object return { ...(watchOptions ?? {}) }; diff --git a/extensions/typescript-language-features/src/tsServer/api.ts b/extensions/typescript-language-features/src/tsServer/api.ts index 92e4503ec85a4..4a35ada0f24b4 100644 --- a/extensions/typescript-language-features/src/tsServer/api.ts +++ b/extensions/typescript-language-features/src/tsServer/api.ts @@ -35,6 +35,7 @@ export class API { public static readonly v500 = API.fromSimpleString('5.0.0'); public static readonly v510 = API.fromSimpleString('5.1.0'); public static readonly v520 = API.fromSimpleString('5.2.0'); + public static readonly v544 = API.fromSimpleString('5.4.4'); public static readonly v540 = API.fromSimpleString('5.4.0'); public static fromVersionString(versionString: string): API { diff --git a/extensions/typescript-language-features/src/tsServer/protocol/protocol.const.ts b/extensions/typescript-language-features/src/tsServer/protocol/protocol.const.ts index deb3357e6ae30..4f02ed29427e0 100644 --- a/extensions/typescript-language-features/src/tsServer/protocol/protocol.const.ts +++ b/extensions/typescript-language-features/src/tsServer/protocol/protocol.const.ts @@ -88,6 +88,9 @@ export enum EventName { surveyReady = 'surveyReady', projectLoadingStart = 'projectLoadingStart', projectLoadingFinish = 'projectLoadingFinish', + createFileWatcher = 'createFileWatcher', + createDirectoryWatcher = 'createDirectoryWatcher', + closeFileWatcher = 'closeFileWatcher', } export enum OrganizeImportsMode { diff --git a/extensions/typescript-language-features/src/tsServer/spawner.ts b/extensions/typescript-language-features/src/tsServer/spawner.ts index 52dcf5baa1939..b88251a70336e 100644 --- a/extensions/typescript-language-features/src/tsServer/spawner.ts +++ b/extensions/typescript-language-features/src/tsServer/spawner.ts @@ -271,6 +271,10 @@ export class TypeScriptServerSpawner { args.push('--noGetErrOnBackgroundUpdate'); + if (apiVersion.gte(API.v544) && configuration.useVsCodeWatcher) { + args.push('--canUseWatchEvents'); + } + args.push('--validateDefaultNpmLocation'); if (isWebAndHasSharedArrayBuffers()) { diff --git a/extensions/typescript-language-features/src/typescriptService.ts b/extensions/typescript-language-features/src/typescriptService.ts index 5823430c416fa..147324911187b 100644 --- a/extensions/typescript-language-features/src/typescriptService.ts +++ b/extensions/typescript-language-features/src/typescriptService.ts @@ -86,6 +86,7 @@ interface NoResponseTsServerRequests { 'compilerOptionsForInferredProjects': [Proto.SetCompilerOptionsForInferredProjectsArgs, null]; 'reloadProjects': [null, null]; 'configurePlugin': [Proto.ConfigurePluginRequest, Proto.ConfigurePluginResponse]; + 'watchChange': [Proto.Request, null]; } interface AsyncTsServerRequests { diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index 812a9c457bf97..78a77f23c9f0c 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -21,7 +21,7 @@ import { TypeScriptVersionManager } from './tsServer/versionManager'; import { ITypeScriptVersionProvider, TypeScriptVersion } from './tsServer/versionProvider'; import { ClientCapabilities, ClientCapability, ExecConfig, ITypeScriptServiceClient, ServerResponse, TypeScriptRequests } from './typescriptService'; import { ServiceConfigurationProvider, SyntaxServerConfiguration, TsServerLogLevel, TypeScriptServiceConfiguration, areServiceConfigurationsEqual } from './configuration/configuration'; -import { Disposable } from './utils/dispose'; +import { Disposable, DisposableStore, disposeAll } from './utils/dispose'; import * as fileSchemes from './configuration/fileSchemes'; import { Logger } from './logging/logger'; import { isWeb, isWebAndHasSharedArrayBuffers } from './utils/platform'; @@ -97,6 +97,12 @@ export const emptyAuthority = 'ts-nul-authority'; export const inMemoryResourcePrefix = '^'; +interface WatchEvent { + updated?: Set; + created?: Set; + deleted?: Set; +} + export default class TypeScriptServiceClient extends Disposable implements ITypeScriptServiceClient { @@ -128,6 +134,10 @@ export default class TypeScriptServiceClient extends Disposable implements IType private readonly versionProvider: ITypeScriptVersionProvider; private readonly processFactory: TsServerProcessFactory; + private readonly watches = new Map(); + private readonly watchEvents = new Map(); + private watchChangeTimeout: NodeJS.Timeout | undefined; + constructor( private readonly context: vscode.ExtensionContext, onCaseInsenitiveFileSystem: boolean, @@ -298,6 +308,8 @@ export default class TypeScriptServiceClient extends Disposable implements IType } this.loadingIndicator.reset(); + + this.resetWatchers(); } public restartTsServer(fromUserAction = false): void { @@ -401,6 +413,8 @@ export default class TypeScriptServiceClient extends Disposable implements IType this.info(`Using Node installation from ${nodePath} to run TS Server`); } + this.resetWatchers(); + const apiVersion = version.apiVersion || API.defaultVersion; const mytoken = ++this.token; const handle = this.typescriptServerSpawner.spawn(version, this.capabilities, this.configuration, this.pluginManager, this.cancellerFactory, { @@ -493,6 +507,11 @@ export default class TypeScriptServiceClient extends Disposable implements IType return this.serverState; } + private resetWatchers() { + clearTimeout(this.watchChangeTimeout); + disposeAll(Array.from(this.watches.values())); + } + public async showVersionPicker(): Promise { this._versionManager.promptUserForVersion(); } @@ -594,6 +613,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType } private serviceExited(restart: boolean): void { + this.resetWatchers(); this.loadingIndicator.reset(); const previousState = this.serverState; @@ -973,6 +993,120 @@ export default class TypeScriptServiceClient extends Disposable implements IType case EventName.projectLoadingFinish: this.loadingIndicator.finishedLoadingProject((event as Proto.ProjectLoadingFinishEvent).body.projectName); break; + + case EventName.createDirectoryWatcher: + this.createFileSystemWatcher( + (event.body as Proto.CreateDirectoryWatcherEventBody).id, + new vscode.RelativePattern( + vscode.Uri.file((event.body as Proto.CreateDirectoryWatcherEventBody).path), + (event.body as Proto.CreateDirectoryWatcherEventBody).recursive ? '**' : '*' + ), + (event.body as Proto.CreateDirectoryWatcherEventBody).ignoreUpdate + ); + break; + + case EventName.createFileWatcher: + this.createFileSystemWatcher( + (event.body as Proto.CreateFileWatcherEventBody).id, + new vscode.RelativePattern( + vscode.Uri.file((event.body as Proto.CreateFileWatcherEventBody).path), + '*' + ) + ); + break; + + case EventName.closeFileWatcher: + this.closeFileSystemWatcher(event.body.id); + break; + } + } + + private scheduleExecuteWatchChangeRequest() { + if (!this.watchChangeTimeout) { + this.watchChangeTimeout = setTimeout(() => { + this.watchChangeTimeout = undefined; + const allEvents = Array.from(this.watchEvents, ([id, event]) => ({ + id, + updated: event.updated && Array.from(event.updated), + created: event.created && Array.from(event.created), + deleted: event.deleted && Array.from(event.deleted) + })); + this.watchEvents.clear(); + this.executeWithoutWaitingForResponse('watchChange', allEvents); + }, 100); /* aggregate events over 100ms to reduce client<->server IPC overhead */ + } + } + + private addWatchEvent(id: number, eventType: keyof WatchEvent, path: string) { + let event = this.watchEvents.get(id); + const removeEvent = (typeOfEventToRemove: keyof WatchEvent) => { + if (event?.[typeOfEventToRemove]?.delete(path) && event[typeOfEventToRemove].size === 0) { + event[typeOfEventToRemove] = undefined; + } + }; + const aggregateEvent = () => { + if (!event) { + this.watchEvents.set(id, event = {}); + } + (event[eventType] ??= new Set()).add(path); + }; + switch (eventType) { + case 'created': + removeEvent('deleted'); + removeEvent('updated'); + aggregateEvent(); + break; + case 'deleted': + removeEvent('created'); + removeEvent('updated'); + aggregateEvent(); + break; + case 'updated': + if (event?.created?.has(path)) { + return; + } + removeEvent('deleted'); + aggregateEvent(); + break; + } + this.scheduleExecuteWatchChangeRequest(); + } + + private createFileSystemWatcher( + id: number, + pattern: vscode.RelativePattern, + ignoreChangeEvents?: boolean, + ) { + const disposable = new DisposableStore(); + const watcher = disposable.add(vscode.workspace.createFileSystemWatcher(pattern, { excludes: [] /* TODO:: need to fill in excludes list */, ignoreChangeEvents })); + disposable.add(watcher.onDidChange(changeFile => + this.addWatchEvent(id, 'updated', changeFile.fsPath) + )); + disposable.add(watcher.onDidCreate(createFile => + this.addWatchEvent(id, 'created', createFile.fsPath) + )); + disposable.add(watcher.onDidDelete(deletedFile => + this.addWatchEvent(id, 'deleted', deletedFile.fsPath) + )); + disposable.add({ + dispose: () => { + this.watchEvents.delete(id); + this.watches.delete(id); + } + }); + + if (this.watches.has(id)) { + this.closeFileSystemWatcher(id); + } + this.watches.set(id, disposable); + } + + private closeFileSystemWatcher( + id: number, + ) { + const existing = this.watches.get(id); + if (existing) { + existing.dispose(); } } diff --git a/extensions/typescript-language-features/src/utils/dispose.ts b/extensions/typescript-language-features/src/utils/dispose.ts index 7b6627204d5ed..a3730ee4540a6 100644 --- a/extensions/typescript-language-features/src/utils/dispose.ts +++ b/extensions/typescript-language-features/src/utils/dispose.ts @@ -6,10 +6,10 @@ import * as vscode from 'vscode'; export function disposeAll(disposables: vscode.Disposable[]) { - while (disposables.length) { - const item = disposables.pop(); - item?.dispose(); + for (const disposable of disposables) { + disposable.dispose(); } + disposables.length = 0; } export interface IDisposable { @@ -42,3 +42,12 @@ export abstract class Disposable { return this._isDisposed; } } + +export class DisposableStore extends Disposable { + + public add(disposable: T): T { + this._register(disposable); + + return disposable; + } +} diff --git a/extensions/typescript-language-features/tsconfig.json b/extensions/typescript-language-features/tsconfig.json index d40c03a591db0..1da85cd17cd4b 100644 --- a/extensions/typescript-language-features/tsconfig.json +++ b/extensions/typescript-language-features/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.createFileSystemWatcher.d.ts", "../../src/vscode-dts/vscode.proposed.codeActionAI.d.ts", "../../src/vscode-dts/vscode.proposed.codeActionRanges.d.ts", "../../src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts",