diff --git a/packages/common/src/ide/types/FileSystem.types.ts b/packages/common/src/ide/types/FileSystem.types.ts new file mode 100644 index 0000000000..15818b3633 --- /dev/null +++ b/packages/common/src/ide/types/FileSystem.types.ts @@ -0,0 +1,13 @@ +import { Disposable } from "./ide.types"; + +export type PathChangeListener = () => void; + +export interface FileSystem { + /** + * Recursively watch a directory for changes. + * @param path The path of the directory to watch + * @param onDidChange A function to call on changes + * @returns A disposable to cancel the watcher + */ + watchDir(path: string, onDidChange: PathChangeListener): Disposable; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a989652ad3..6f087a3b22 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -26,6 +26,7 @@ export * from "./ide/types/Events"; export * from "./ide/types/QuickPickOptions"; export * from "./ide/types/events.types"; export * from "./ide/types/Paths"; +export * from "./ide/types/FileSystem.types"; export * from "./types/RangeExpansionBehavior"; export * from "./types/InputBoxOptions"; export * from "./types/Position"; diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index e1265749bb..ef501b18be 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -1,4 +1,10 @@ -import { Command, CommandServerApi, Hats, IDE } from "@cursorless/common"; +import { + Command, + CommandServerApi, + FileSystem, + Hats, + IDE, +} from "@cursorless/common"; import { StoredTargetMap, TestCaseRecorder, TreeSitter } from "."; import { CursorlessEngine } from "./api/CursorlessEngineApi"; import { ScopeProvider } from "./api/ScopeProvider"; @@ -22,6 +28,7 @@ export function createCursorlessEngine( ide: IDE, hats: Hats, commandServerApi: CommandServerApi | null, + fileSystem: FileSystem, ): CursorlessEngine { injectIde(ide); diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 625bdcf4b0..3b60a24294 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -37,6 +37,7 @@ import { ScopeVisualizerCommandApi, VisualizationType, } from "./ScopeVisualizerCommandApi"; +import { VscodeFileSystem } from "./ide/vscode/VscodeFileSystem"; /** * Extension entrypoint called by VSCode on Cursorless startup. @@ -51,7 +52,7 @@ export async function activate( ): Promise { const parseTreeApi = await getParseTreeApi(); - const { vscodeIDE, hats } = await createVscodeIde(context); + const { vscodeIDE, hats, fileSystem } = await createVscodeIde(context); const normalizedIde = vscodeIDE.runMode === "production" @@ -83,6 +84,7 @@ export async function activate( normalizedIde ?? vscodeIDE, hats, commandServerApi, + fileSystem, ); const statusBarItem = StatusBarItem.create("cursorless.showQuickPick"); @@ -129,7 +131,7 @@ async function createVscodeIde(context: vscode.ExtensionContext) { ); await hats.init(); - return { vscodeIDE, hats }; + return { vscodeIDE, hats, fileSystem: new VscodeFileSystem() }; } function createTreeSitter(parseTreeApi: ParseTreeApi): TreeSitter { diff --git a/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts new file mode 100644 index 0000000000..49da4f0017 --- /dev/null +++ b/packages/cursorless-vscode/src/ide/vscode/VscodeFileSystem.ts @@ -0,0 +1,52 @@ +import { + Disposable, + FileSystem, + PathChangeListener, + walkFiles, +} from "@cursorless/common"; +import { stat } from "fs/promises"; +import { max } from "lodash"; + +export class VscodeFileSystem implements FileSystem { + watchDir(path: string, onDidChange: PathChangeListener): Disposable { + // Just poll for now; we can take advantage of VSCode's sophisticated + // watcher later. Note that we would need to do a version check, as VSCode + // file watcher is only available in more recent versions of VSCode. + return new PollingFileSystemWatcher(path, onDidChange); + } +} + +const CHECK_INTERVAL_MS = 1000; + +class PollingFileSystemWatcher implements Disposable { + private maxMtimeMs: number = -1; + private timer: NodeJS.Timer; + + constructor( + private readonly path: string, + private readonly onDidChange: PathChangeListener, + ) { + this.checkForChanges = this.checkForChanges.bind(this); + this.timer = setInterval(this.checkForChanges, CHECK_INTERVAL_MS); + } + + private async checkForChanges() { + const paths = await walkFiles(this.path); + + const maxMtime = + max( + (await Promise.all(paths.map((file) => stat(file)))).map( + (stat) => stat.mtimeMs, + ), + ) ?? 0; + + if (maxMtime > this.maxMtimeMs) { + this.maxMtimeMs = maxMtime; + this.onDidChange(); + } + } + + dispose() { + clearInterval(this.timer); + } +}