Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support using client watch in tsserver using events #54662

Merged
merged 1 commit into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 137 additions & 3 deletions src/server/editorServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
Diagnostic,
directorySeparator,
DirectoryStructureHost,
DirectoryWatcherCallback,
DocumentPosition,
DocumentPositionMapper,
DocumentRegistry,
Expand All @@ -37,6 +38,7 @@ import {
FileExtensionInfo,
fileExtensionIs,
FileWatcher,
FileWatcherCallback,
FileWatcherEventKind,
find,
flatMap,
Expand Down Expand Up @@ -127,6 +129,7 @@ import {
version,
WatchDirectoryFlags,
WatchFactory,
WatchFactoryHost,
WatchLogLevel,
WatchOptions,
WatchType,
Expand Down Expand Up @@ -193,6 +196,9 @@ export const ConfigFileDiagEvent = "configFileDiag";
export const ProjectLanguageServiceStateEvent = "projectLanguageServiceState";
export const ProjectInfoTelemetryEvent = "projectInfo";
export const OpenFileInfoTelemetryEvent = "openFileInfo";
export const CreateFileWatcherEvent: protocol.CreateFileWatcherEventName = "createFileWatcher";
export const CreateDirectoryWatcherEvent: protocol.CreateDirectoryWatcherEventName = "createDirectoryWatcher";
export const CloseFileWatcherEvent: protocol.CloseFileWatcherEventName = "closeFileWatcher";
const ensureProjectForOpenFileSchedule = "*ensureProjectForOpenFiles*";

export interface ProjectsUpdatedInBackgroundEvent {
Expand Down Expand Up @@ -320,6 +326,21 @@ export interface OpenFileInfo {
readonly checkJs: boolean;
}

export interface CreateFileWatcherEvent {
readonly eventName: protocol.CreateFileWatcherEventName;
readonly data: protocol.CreateFileWatcherEventBody;
}

export interface CreateDirectoryWatcherEvent {
readonly eventName: protocol.CreateDirectoryWatcherEventName;
readonly data: protocol.CreateDirectoryWatcherEventBody;
}

export interface CloseFileWatcherEvent {
readonly eventName: protocol.CloseFileWatcherEventName;
readonly data: protocol.CloseFileWatcherEventBody;
}

export type ProjectServiceEvent =
| LargeFileReferencedEvent
| ProjectsUpdatedInBackgroundEvent
Expand All @@ -328,7 +349,10 @@ export type ProjectServiceEvent =
| ConfigFileDiagEvent
| ProjectLanguageServiceStateEvent
| ProjectInfoTelemetryEvent
| OpenFileInfoTelemetryEvent;
| OpenFileInfoTelemetryEvent
| CreateFileWatcherEvent
| CreateDirectoryWatcherEvent
| CloseFileWatcherEvent;

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

Expand Down Expand Up @@ -583,6 +607,7 @@ export interface ProjectServiceOptions {
useInferredProjectPerProjectRoot: boolean;
typingsInstaller?: ITypingsInstaller;
eventHandler?: ProjectServiceEventHandler;
canUseWatchEvents?: boolean;
suppressDiagnosticEvents?: boolean;
throttleWaitMilliseconds?: number;
globalPlugins?: readonly string[];
Expand Down Expand Up @@ -857,6 +882,109 @@ function createProjectNameFactoryWithCounter(nameFactory: (counter: number) => s
return () => nameFactory(nextId++);
}

interface HostWatcherMap<T> {
idToCallbacks: Map<number, Set<T>>;
pathToId: Map<Path, number>;
}

function getHostWatcherMap<T>(): HostWatcherMap<T> {
return { idToCallbacks: new Map(), pathToId: new Map() };
}

function createWatchFactoryHostUsingWatchEvents(service: ProjectService, canUseWatchEvents: boolean | undefined): WatchFactoryHost | undefined {
if (!canUseWatchEvents || !service.eventHandler || !service.session) return undefined;
const watchedFiles = getHostWatcherMap<FileWatcherCallback>();
const watchedDirectories = getHostWatcherMap<DirectoryWatcherCallback>();
const watchedDirectoriesRecursive = getHostWatcherMap<DirectoryWatcherCallback>();
let ids = 1;
service.session.addProtocolHandler(protocol.CommandTypes.WatchChange, req => {
onWatchChange((req as protocol.WatchChangeRequest).arguments);
return { responseRequired: false };
});
return {
watchFile,
watchDirectory,
getCurrentDirectory: () => service.host.getCurrentDirectory(),
useCaseSensitiveFileNames: service.host.useCaseSensitiveFileNames,
};
function watchFile(path: string, callback: FileWatcherCallback): FileWatcher {
return getOrCreateFileWatcher(
watchedFiles,
path,
callback,
id => ({ eventName: CreateFileWatcherEvent, data: { id, path } }),
);
}
function watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean): FileWatcher {
return getOrCreateFileWatcher(
recursive ? watchedDirectoriesRecursive : watchedDirectories,
path,
callback,
id => ({ eventName: CreateDirectoryWatcherEvent, data: { id, path, recursive: !!recursive } }),
);
}
function getOrCreateFileWatcher<T>(
{ pathToId, idToCallbacks }: HostWatcherMap<T>,
path: string,
callback: T,
event: (id: number) => CreateFileWatcherEvent | CreateDirectoryWatcherEvent,
) {
const key = service.toPath(path);
let id = pathToId.get(key);
if (!id) pathToId.set(key, id = ids++);
let callbacks = idToCallbacks.get(id);
if (!callbacks) {
idToCallbacks.set(id, callbacks = new Set());
// Add watcher
service.eventHandler!(event(id));
}
callbacks.add(callback);
return {
close() {
const callbacks = idToCallbacks.get(id!);
if (!callbacks?.delete(callback)) return;
if (callbacks.size) return;
idToCallbacks.delete(id!);
pathToId.delete(key);
service.eventHandler!({ eventName: CloseFileWatcherEvent, data: { id: id! } });
},
};
}
function onWatchChange({ id, path, eventType }: protocol.WatchChangeRequestArgs) {
// console.log(`typescript-vscode-watcher:: Invoke:: ${id}:: ${path}:: ${eventType}`);
onFileWatcherCallback(id, path, eventType);
onDirectoryWatcherCallback(watchedDirectories, id, path, eventType);
onDirectoryWatcherCallback(watchedDirectoriesRecursive, id, path, eventType);
}

function onFileWatcherCallback(
id: number,
eventPath: string,
eventType: "create" | "delete" | "update",
) {
watchedFiles.idToCallbacks.get(id)?.forEach(callback => {
const eventKind = eventType === "create" ?
FileWatcherEventKind.Created :
eventType === "delete" ?
FileWatcherEventKind.Deleted :
FileWatcherEventKind.Changed;
callback(eventPath, eventKind);
});
}

function onDirectoryWatcherCallback(
{ idToCallbacks }: HostWatcherMap<DirectoryWatcherCallback>,
id: number,
eventPath: string,
eventType: "create" | "delete" | "update",
) {
if (eventType === "update") return;
idToCallbacks.get(id)?.forEach(callback => {
callback(eventPath);
});
}
}

export class ProjectService {
/** @internal */
readonly typingsCache: TypingsCache;
Expand Down Expand Up @@ -961,7 +1089,8 @@ export class ProjectService {
public readonly typingsInstaller: ITypingsInstaller;
private readonly globalCacheLocationDirectoryPath: Path | undefined;
public readonly throttleWaitMilliseconds?: number;
private readonly eventHandler?: ProjectServiceEventHandler;
/** @internal */
readonly eventHandler?: ProjectServiceEventHandler;
private readonly suppressDiagnosticEvents?: boolean;

public readonly globalPlugins: readonly string[];
Expand Down Expand Up @@ -1065,7 +1194,12 @@ export class ProjectService {
watchFile: returnNoopFileWatcher,
watchDirectory: returnNoopFileWatcher,
} :
getWatchFactory(this.host, watchLogLevel, log, getDetailWatchInfo);
getWatchFactory(
createWatchFactoryHostUsingWatchEvents(this, opts.canUseWatchEvents) || this.host,
watchLogLevel,
log,
getDetailWatchInfo,
);
opts.incrementalVerifier?.(this);
}

Expand Down
50 changes: 49 additions & 1 deletion src/server/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export const enum CommandTypes {
ProvideCallHierarchyIncomingCalls = "provideCallHierarchyIncomingCalls",
ProvideCallHierarchyOutgoingCalls = "provideCallHierarchyOutgoingCalls",
ProvideInlayHints = "provideInlayHints",
WatchChange = "watchChange",
}

/**
Expand Down Expand Up @@ -1956,6 +1957,17 @@ export interface CloseRequest extends FileRequest {
command: CommandTypes.Close;
}

export interface WatchChangeRequest extends Request {
command: CommandTypes.WatchChange;
arguments: WatchChangeRequestArgs;
}

export interface WatchChangeRequestArgs {
id: number;
path: string;
eventType: "create" | "delete" | "update";
}

/**
* Request to obtain the list of files that should be regenerated if target file is recompiled.
* NOTE: this us query-only operation and does not generate any output on disk.
Expand Down Expand Up @@ -3018,6 +3030,39 @@ export interface LargeFileReferencedEventBody {
maxFileSize: number;
}

export type CreateFileWatcherEventName = "createFileWatcher";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just writing this down before I forget (I haven't read the full PR yet), but one thing we may want to consider is preemptively modeling this after LSP, specifically, moving the glob calculation to the server and sending that as part of the request instead. Right now, that's done over on the VS Code side (per https://github.com/sheetalkamat/vscode/pull/2/files), but in the LSP, there is only one file watching API and it accepts globs (https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWatchedFiles).

This would mean that we would eventually have to pull the code back out of VS Code into tsserver anyway if we were to start using LSP.

Copy link
Member

@jakebailey jakebailey Jun 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also of note is that LSP doesn't have watch IDs or anything; when changing watches, you have to send the entire list of desired watches. That feels expensive but I'm not sure what all we watch (I have yet to view a trace of the current PR set to see).

(maybe this isn't true but I think it is; my memory is foggy)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are sending directory names to watch along with whether to watch recursively or not . I feel its better for API users to convert to whichever pattern they need it.

Ids are added because its easier and cheaper to figure out which Watcher to invoke instead of having to traffic around the complete data about paths and recursive etc info

export interface CreateFileWatcherEvent extends Event {
readonly event: CreateFileWatcherEventName;
readonly body: CreateFileWatcherEventBody;
}

export interface CreateFileWatcherEventBody {
readonly id: number;
readonly path: string;
}

export type CreateDirectoryWatcherEventName = "createDirectoryWatcher";
export interface CreateDirectoryWatcherEvent extends Event {
readonly event: CreateDirectoryWatcherEventName;
readonly body: CreateDirectoryWatcherEventBody;
}

export interface CreateDirectoryWatcherEventBody {
readonly id: number;
readonly path: string;
readonly recursive: boolean;
}

export type CloseFileWatcherEventName = "closeFileWatcher";
export interface CloseFileWatcherEvent extends Event {
readonly event: CloseFileWatcherEventName;
readonly body: CloseFileWatcherEventBody;
}

export interface CloseFileWatcherEventBody {
readonly id: number;
}

/** @internal */
export type AnyEvent =
| RequestCompletedEvent
Expand All @@ -3029,7 +3074,10 @@ export type AnyEvent =
| ProjectLoadingStartEvent
| ProjectLoadingFinishEvent
| SurveyReadyEvent
| LargeFileReferencedEvent;
| LargeFileReferencedEvent
| CreateFileWatcherEvent
| CreateDirectoryWatcherEvent
| CloseFileWatcherEvent;

/**
* Arguments for reload request.
Expand Down
41 changes: 22 additions & 19 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,14 @@ import {
WithMetadata,
} from "./_namespaces/ts";
import {
CloseFileWatcherEvent,
ConfigFileDiagEvent,
ConfiguredProject,
convertFormatOptions,
convertScriptKindName,
convertUserPreferences,
CreateDirectoryWatcherEvent,
CreateFileWatcherEvent,
EmitResult,
emptyArray,
Errors,
Expand Down Expand Up @@ -949,6 +952,7 @@ export interface SessionOptions {
* If falsy, all events are suppressed.
*/
canUseEvents: boolean;
canUseWatchEvents?: boolean;
eventHandler?: ProjectServiceEventHandler;
/** Has no effect if eventHandler is also specified. */
suppressDiagnosticEvents?: boolean;
Expand Down Expand Up @@ -1026,6 +1030,7 @@ export class Session<TMessage = string> implements EventSender {
typesMapLocation: opts.typesMapLocation,
serverMode: opts.serverMode,
session: this,
canUseWatchEvents: opts.canUseWatchEvents,
incrementalVerifier: opts.incrementalVerifier,
};
this.projectService = new ProjectService(settings);
Expand Down Expand Up @@ -1080,39 +1085,37 @@ export class Session<TMessage = string> implements EventSender {
private defaultEventHandler(event: ProjectServiceEvent) {
switch (event.eventName) {
case ProjectsUpdatedInBackgroundEvent:
const { openFiles } = event.data;
this.projectsUpdatedInBackgroundEvent(openFiles);
this.projectsUpdatedInBackgroundEvent(event.data.openFiles);
break;
case ProjectLoadingStartEvent:
const { project, reason } = event.data;
this.event<protocol.ProjectLoadingStartEventBody>(
{ projectName: project.getProjectName(), reason },
ProjectLoadingStartEvent,
);
this.event<protocol.ProjectLoadingStartEventBody>({
projectName: event.data.project.getProjectName(),
reason: event.data.reason,
}, event.eventName);
break;
case ProjectLoadingFinishEvent:
const { project: finishProject } = event.data;
this.event<protocol.ProjectLoadingFinishEventBody>({ projectName: finishProject.getProjectName() }, ProjectLoadingFinishEvent);
this.event<protocol.ProjectLoadingFinishEventBody>({
projectName: event.data.project.getProjectName(),
}, event.eventName);
break;
case LargeFileReferencedEvent:
const { file, fileSize, maxFileSize } = event.data;
this.event<protocol.LargeFileReferencedEventBody>({ file, fileSize, maxFileSize }, LargeFileReferencedEvent);
case CreateFileWatcherEvent:
case CreateDirectoryWatcherEvent:
case CloseFileWatcherEvent:
this.event(event.data, event.eventName);
break;
case ConfigFileDiagEvent:
const { triggerFile, configFileName: configFile, diagnostics } = event.data;
const bakedDiags = map(diagnostics, diagnostic => formatDiagnosticToProtocol(diagnostic, /*includeFileName*/ true));
this.event<protocol.ConfigFileDiagnosticEventBody>({
triggerFile,
configFile,
diagnostics: bakedDiags,
}, ConfigFileDiagEvent);
triggerFile: event.data.triggerFile,
configFile: event.data.configFileName,
diagnostics: map(event.data.diagnostics, diagnostic => formatDiagnosticToProtocol(diagnostic, /*includeFileName*/ true)),
}, event.eventName);
break;
case ProjectLanguageServiceStateEvent: {
const eventName: protocol.ProjectLanguageServiceStateEventName = ProjectLanguageServiceStateEvent;
this.event<protocol.ProjectLanguageServiceStateEventBody>({
projectName: event.data.project.getProjectName(),
languageServiceEnabled: event.data.languageServiceEnabled,
}, eventName);
}, event.eventName);
break;
}
case ProjectInfoTelemetryEvent: {
Expand Down
1 change: 1 addition & 0 deletions src/testRunner/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import "./unittests/tsserver/events/largeFileReferenced";
import "./unittests/tsserver/events/projectLanguageServiceState";
import "./unittests/tsserver/events/projectLoading";
import "./unittests/tsserver/events/projectUpdatedInBackground";
import "./unittests/tsserver/events/watchEvents";
import "./unittests/tsserver/exportMapCache";
import "./unittests/tsserver/extends";
import "./unittests/tsserver/externalProjects";
Expand Down
Loading
Loading