Skip to content

Commit 934216f

Browse files
authored
Support using client watch in tsserver using events (#54662)
1 parent 4c96543 commit 934216f

File tree

12 files changed

+1372
-28
lines changed

12 files changed

+1372
-28
lines changed

src/server/editorServices.ts

+137-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Diagnostic,
2828
directorySeparator,
2929
DirectoryStructureHost,
30+
DirectoryWatcherCallback,
3031
DocumentPosition,
3132
DocumentPositionMapper,
3233
DocumentRegistry,
@@ -37,6 +38,7 @@ import {
3738
FileExtensionInfo,
3839
fileExtensionIs,
3940
FileWatcher,
41+
FileWatcherCallback,
4042
FileWatcherEventKind,
4143
find,
4244
flatMap,
@@ -127,6 +129,7 @@ import {
127129
version,
128130
WatchDirectoryFlags,
129131
WatchFactory,
132+
WatchFactoryHost,
130133
WatchLogLevel,
131134
WatchOptions,
132135
WatchType,
@@ -193,6 +196,9 @@ export const ConfigFileDiagEvent = "configFileDiag";
193196
export const ProjectLanguageServiceStateEvent = "projectLanguageServiceState";
194197
export const ProjectInfoTelemetryEvent = "projectInfo";
195198
export const OpenFileInfoTelemetryEvent = "openFileInfo";
199+
export const CreateFileWatcherEvent: protocol.CreateFileWatcherEventName = "createFileWatcher";
200+
export const CreateDirectoryWatcherEvent: protocol.CreateDirectoryWatcherEventName = "createDirectoryWatcher";
201+
export const CloseFileWatcherEvent: protocol.CloseFileWatcherEventName = "closeFileWatcher";
196202
const ensureProjectForOpenFileSchedule = "*ensureProjectForOpenFiles*";
197203

198204
export interface ProjectsUpdatedInBackgroundEvent {
@@ -320,6 +326,21 @@ export interface OpenFileInfo {
320326
readonly checkJs: boolean;
321327
}
322328

329+
export interface CreateFileWatcherEvent {
330+
readonly eventName: protocol.CreateFileWatcherEventName;
331+
readonly data: protocol.CreateFileWatcherEventBody;
332+
}
333+
334+
export interface CreateDirectoryWatcherEvent {
335+
readonly eventName: protocol.CreateDirectoryWatcherEventName;
336+
readonly data: protocol.CreateDirectoryWatcherEventBody;
337+
}
338+
339+
export interface CloseFileWatcherEvent {
340+
readonly eventName: protocol.CloseFileWatcherEventName;
341+
readonly data: protocol.CloseFileWatcherEventBody;
342+
}
343+
323344
export type ProjectServiceEvent =
324345
| LargeFileReferencedEvent
325346
| ProjectsUpdatedInBackgroundEvent
@@ -328,7 +349,10 @@ export type ProjectServiceEvent =
328349
| ConfigFileDiagEvent
329350
| ProjectLanguageServiceStateEvent
330351
| ProjectInfoTelemetryEvent
331-
| OpenFileInfoTelemetryEvent;
352+
| OpenFileInfoTelemetryEvent
353+
| CreateFileWatcherEvent
354+
| CreateDirectoryWatcherEvent
355+
| CloseFileWatcherEvent;
332356

333357
export type ProjectServiceEventHandler = (event: ProjectServiceEvent) => void;
334358

@@ -583,6 +607,7 @@ export interface ProjectServiceOptions {
583607
useInferredProjectPerProjectRoot: boolean;
584608
typingsInstaller?: ITypingsInstaller;
585609
eventHandler?: ProjectServiceEventHandler;
610+
canUseWatchEvents?: boolean;
586611
suppressDiagnosticEvents?: boolean;
587612
throttleWaitMilliseconds?: number;
588613
globalPlugins?: readonly string[];
@@ -857,6 +882,109 @@ function createProjectNameFactoryWithCounter(nameFactory: (counter: number) => s
857882
return () => nameFactory(nextId++);
858883
}
859884

885+
interface HostWatcherMap<T> {
886+
idToCallbacks: Map<number, Set<T>>;
887+
pathToId: Map<Path, number>;
888+
}
889+
890+
function getHostWatcherMap<T>(): HostWatcherMap<T> {
891+
return { idToCallbacks: new Map(), pathToId: new Map() };
892+
}
893+
894+
function createWatchFactoryHostUsingWatchEvents(service: ProjectService, canUseWatchEvents: boolean | undefined): WatchFactoryHost | undefined {
895+
if (!canUseWatchEvents || !service.eventHandler || !service.session) return undefined;
896+
const watchedFiles = getHostWatcherMap<FileWatcherCallback>();
897+
const watchedDirectories = getHostWatcherMap<DirectoryWatcherCallback>();
898+
const watchedDirectoriesRecursive = getHostWatcherMap<DirectoryWatcherCallback>();
899+
let ids = 1;
900+
service.session.addProtocolHandler(protocol.CommandTypes.WatchChange, req => {
901+
onWatchChange((req as protocol.WatchChangeRequest).arguments);
902+
return { responseRequired: false };
903+
});
904+
return {
905+
watchFile,
906+
watchDirectory,
907+
getCurrentDirectory: () => service.host.getCurrentDirectory(),
908+
useCaseSensitiveFileNames: service.host.useCaseSensitiveFileNames,
909+
};
910+
function watchFile(path: string, callback: FileWatcherCallback): FileWatcher {
911+
return getOrCreateFileWatcher(
912+
watchedFiles,
913+
path,
914+
callback,
915+
id => ({ eventName: CreateFileWatcherEvent, data: { id, path } }),
916+
);
917+
}
918+
function watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean): FileWatcher {
919+
return getOrCreateFileWatcher(
920+
recursive ? watchedDirectoriesRecursive : watchedDirectories,
921+
path,
922+
callback,
923+
id => ({ eventName: CreateDirectoryWatcherEvent, data: { id, path, recursive: !!recursive } }),
924+
);
925+
}
926+
function getOrCreateFileWatcher<T>(
927+
{ pathToId, idToCallbacks }: HostWatcherMap<T>,
928+
path: string,
929+
callback: T,
930+
event: (id: number) => CreateFileWatcherEvent | CreateDirectoryWatcherEvent,
931+
) {
932+
const key = service.toPath(path);
933+
let id = pathToId.get(key);
934+
if (!id) pathToId.set(key, id = ids++);
935+
let callbacks = idToCallbacks.get(id);
936+
if (!callbacks) {
937+
idToCallbacks.set(id, callbacks = new Set());
938+
// Add watcher
939+
service.eventHandler!(event(id));
940+
}
941+
callbacks.add(callback);
942+
return {
943+
close() {
944+
const callbacks = idToCallbacks.get(id!);
945+
if (!callbacks?.delete(callback)) return;
946+
if (callbacks.size) return;
947+
idToCallbacks.delete(id!);
948+
pathToId.delete(key);
949+
service.eventHandler!({ eventName: CloseFileWatcherEvent, data: { id: id! } });
950+
},
951+
};
952+
}
953+
function onWatchChange({ id, path, eventType }: protocol.WatchChangeRequestArgs) {
954+
// console.log(`typescript-vscode-watcher:: Invoke:: ${id}:: ${path}:: ${eventType}`);
955+
onFileWatcherCallback(id, path, eventType);
956+
onDirectoryWatcherCallback(watchedDirectories, id, path, eventType);
957+
onDirectoryWatcherCallback(watchedDirectoriesRecursive, id, path, eventType);
958+
}
959+
960+
function onFileWatcherCallback(
961+
id: number,
962+
eventPath: string,
963+
eventType: "create" | "delete" | "update",
964+
) {
965+
watchedFiles.idToCallbacks.get(id)?.forEach(callback => {
966+
const eventKind = eventType === "create" ?
967+
FileWatcherEventKind.Created :
968+
eventType === "delete" ?
969+
FileWatcherEventKind.Deleted :
970+
FileWatcherEventKind.Changed;
971+
callback(eventPath, eventKind);
972+
});
973+
}
974+
975+
function onDirectoryWatcherCallback(
976+
{ idToCallbacks }: HostWatcherMap<DirectoryWatcherCallback>,
977+
id: number,
978+
eventPath: string,
979+
eventType: "create" | "delete" | "update",
980+
) {
981+
if (eventType === "update") return;
982+
idToCallbacks.get(id)?.forEach(callback => {
983+
callback(eventPath);
984+
});
985+
}
986+
}
987+
860988
export class ProjectService {
861989
/** @internal */
862990
readonly typingsCache: TypingsCache;
@@ -961,7 +1089,8 @@ export class ProjectService {
9611089
public readonly typingsInstaller: ITypingsInstaller;
9621090
private readonly globalCacheLocationDirectoryPath: Path | undefined;
9631091
public readonly throttleWaitMilliseconds?: number;
964-
private readonly eventHandler?: ProjectServiceEventHandler;
1092+
/** @internal */
1093+
readonly eventHandler?: ProjectServiceEventHandler;
9651094
private readonly suppressDiagnosticEvents?: boolean;
9661095

9671096
public readonly globalPlugins: readonly string[];
@@ -1065,7 +1194,12 @@ export class ProjectService {
10651194
watchFile: returnNoopFileWatcher,
10661195
watchDirectory: returnNoopFileWatcher,
10671196
} :
1068-
getWatchFactory(this.host, watchLogLevel, log, getDetailWatchInfo);
1197+
getWatchFactory(
1198+
createWatchFactoryHostUsingWatchEvents(this, opts.canUseWatchEvents) || this.host,
1199+
watchLogLevel,
1200+
log,
1201+
getDetailWatchInfo,
1202+
);
10691203
opts.incrementalVerifier?.(this);
10701204
}
10711205

src/server/protocol.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const enum CommandTypes {
173173
ProvideCallHierarchyIncomingCalls = "provideCallHierarchyIncomingCalls",
174174
ProvideCallHierarchyOutgoingCalls = "provideCallHierarchyOutgoingCalls",
175175
ProvideInlayHints = "provideInlayHints",
176+
WatchChange = "watchChange",
176177
}
177178

178179
/**
@@ -1956,6 +1957,17 @@ export interface CloseRequest extends FileRequest {
19561957
command: CommandTypes.Close;
19571958
}
19581959

1960+
export interface WatchChangeRequest extends Request {
1961+
command: CommandTypes.WatchChange;
1962+
arguments: WatchChangeRequestArgs;
1963+
}
1964+
1965+
export interface WatchChangeRequestArgs {
1966+
id: number;
1967+
path: string;
1968+
eventType: "create" | "delete" | "update";
1969+
}
1970+
19591971
/**
19601972
* Request to obtain the list of files that should be regenerated if target file is recompiled.
19611973
* NOTE: this us query-only operation and does not generate any output on disk.
@@ -3018,6 +3030,39 @@ export interface LargeFileReferencedEventBody {
30183030
maxFileSize: number;
30193031
}
30203032

3033+
export type CreateFileWatcherEventName = "createFileWatcher";
3034+
export interface CreateFileWatcherEvent extends Event {
3035+
readonly event: CreateFileWatcherEventName;
3036+
readonly body: CreateFileWatcherEventBody;
3037+
}
3038+
3039+
export interface CreateFileWatcherEventBody {
3040+
readonly id: number;
3041+
readonly path: string;
3042+
}
3043+
3044+
export type CreateDirectoryWatcherEventName = "createDirectoryWatcher";
3045+
export interface CreateDirectoryWatcherEvent extends Event {
3046+
readonly event: CreateDirectoryWatcherEventName;
3047+
readonly body: CreateDirectoryWatcherEventBody;
3048+
}
3049+
3050+
export interface CreateDirectoryWatcherEventBody {
3051+
readonly id: number;
3052+
readonly path: string;
3053+
readonly recursive: boolean;
3054+
}
3055+
3056+
export type CloseFileWatcherEventName = "closeFileWatcher";
3057+
export interface CloseFileWatcherEvent extends Event {
3058+
readonly event: CloseFileWatcherEventName;
3059+
readonly body: CloseFileWatcherEventBody;
3060+
}
3061+
3062+
export interface CloseFileWatcherEventBody {
3063+
readonly id: number;
3064+
}
3065+
30213066
/** @internal */
30223067
export type AnyEvent =
30233068
| RequestCompletedEvent
@@ -3029,7 +3074,10 @@ export type AnyEvent =
30293074
| ProjectLoadingStartEvent
30303075
| ProjectLoadingFinishEvent
30313076
| SurveyReadyEvent
3032-
| LargeFileReferencedEvent;
3077+
| LargeFileReferencedEvent
3078+
| CreateFileWatcherEvent
3079+
| CreateDirectoryWatcherEvent
3080+
| CloseFileWatcherEvent;
30333081

30343082
/**
30353083
* Arguments for reload request.

src/server/session.ts

+22-19
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,14 @@ import {
139139
WithMetadata,
140140
} from "./_namespaces/ts";
141141
import {
142+
CloseFileWatcherEvent,
142143
ConfigFileDiagEvent,
143144
ConfiguredProject,
144145
convertFormatOptions,
145146
convertScriptKindName,
146147
convertUserPreferences,
148+
CreateDirectoryWatcherEvent,
149+
CreateFileWatcherEvent,
147150
EmitResult,
148151
emptyArray,
149152
Errors,
@@ -949,6 +952,7 @@ export interface SessionOptions {
949952
* If falsy, all events are suppressed.
950953
*/
951954
canUseEvents: boolean;
955+
canUseWatchEvents?: boolean;
952956
eventHandler?: ProjectServiceEventHandler;
953957
/** Has no effect if eventHandler is also specified. */
954958
suppressDiagnosticEvents?: boolean;
@@ -1026,6 +1030,7 @@ export class Session<TMessage = string> implements EventSender {
10261030
typesMapLocation: opts.typesMapLocation,
10271031
serverMode: opts.serverMode,
10281032
session: this,
1033+
canUseWatchEvents: opts.canUseWatchEvents,
10291034
incrementalVerifier: opts.incrementalVerifier,
10301035
};
10311036
this.projectService = new ProjectService(settings);
@@ -1080,39 +1085,37 @@ export class Session<TMessage = string> implements EventSender {
10801085
private defaultEventHandler(event: ProjectServiceEvent) {
10811086
switch (event.eventName) {
10821087
case ProjectsUpdatedInBackgroundEvent:
1083-
const { openFiles } = event.data;
1084-
this.projectsUpdatedInBackgroundEvent(openFiles);
1088+
this.projectsUpdatedInBackgroundEvent(event.data.openFiles);
10851089
break;
10861090
case ProjectLoadingStartEvent:
1087-
const { project, reason } = event.data;
1088-
this.event<protocol.ProjectLoadingStartEventBody>(
1089-
{ projectName: project.getProjectName(), reason },
1090-
ProjectLoadingStartEvent,
1091-
);
1091+
this.event<protocol.ProjectLoadingStartEventBody>({
1092+
projectName: event.data.project.getProjectName(),
1093+
reason: event.data.reason,
1094+
}, event.eventName);
10921095
break;
10931096
case ProjectLoadingFinishEvent:
1094-
const { project: finishProject } = event.data;
1095-
this.event<protocol.ProjectLoadingFinishEventBody>({ projectName: finishProject.getProjectName() }, ProjectLoadingFinishEvent);
1097+
this.event<protocol.ProjectLoadingFinishEventBody>({
1098+
projectName: event.data.project.getProjectName(),
1099+
}, event.eventName);
10961100
break;
10971101
case LargeFileReferencedEvent:
1098-
const { file, fileSize, maxFileSize } = event.data;
1099-
this.event<protocol.LargeFileReferencedEventBody>({ file, fileSize, maxFileSize }, LargeFileReferencedEvent);
1102+
case CreateFileWatcherEvent:
1103+
case CreateDirectoryWatcherEvent:
1104+
case CloseFileWatcherEvent:
1105+
this.event(event.data, event.eventName);
11001106
break;
11011107
case ConfigFileDiagEvent:
1102-
const { triggerFile, configFileName: configFile, diagnostics } = event.data;
1103-
const bakedDiags = map(diagnostics, diagnostic => formatDiagnosticToProtocol(diagnostic, /*includeFileName*/ true));
11041108
this.event<protocol.ConfigFileDiagnosticEventBody>({
1105-
triggerFile,
1106-
configFile,
1107-
diagnostics: bakedDiags,
1108-
}, ConfigFileDiagEvent);
1109+
triggerFile: event.data.triggerFile,
1110+
configFile: event.data.configFileName,
1111+
diagnostics: map(event.data.diagnostics, diagnostic => formatDiagnosticToProtocol(diagnostic, /*includeFileName*/ true)),
1112+
}, event.eventName);
11091113
break;
11101114
case ProjectLanguageServiceStateEvent: {
1111-
const eventName: protocol.ProjectLanguageServiceStateEventName = ProjectLanguageServiceStateEvent;
11121115
this.event<protocol.ProjectLanguageServiceStateEventBody>({
11131116
projectName: event.data.project.getProjectName(),
11141117
languageServiceEnabled: event.data.languageServiceEnabled,
1115-
}, eventName);
1118+
}, event.eventName);
11161119
break;
11171120
}
11181121
case ProjectInfoTelemetryEvent: {

src/testRunner/tests.ts

+1
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ import "./unittests/tsserver/events/largeFileReferenced";
153153
import "./unittests/tsserver/events/projectLanguageServiceState";
154154
import "./unittests/tsserver/events/projectLoading";
155155
import "./unittests/tsserver/events/projectUpdatedInBackground";
156+
import "./unittests/tsserver/events/watchEvents";
156157
import "./unittests/tsserver/exportMapCache";
157158
import "./unittests/tsserver/extends";
158159
import "./unittests/tsserver/externalProjects";

0 commit comments

Comments
 (0)