From 72100ee5c1121458fc9ff695b9aff23812c38e33 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 10 Mar 2026 17:51:59 +0100 Subject: [PATCH] perf: flush watched files --- packages/extension/src/watcher.ts | 163 ++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 52 deletions(-) diff --git a/packages/extension/src/watcher.ts b/packages/extension/src/watcher.ts index 28896c6..1a81785 100644 --- a/packages/extension/src/watcher.ts +++ b/packages/extension/src/watcher.ts @@ -7,9 +7,12 @@ import * as vscode from 'vscode' import { getConfig } from './config' import { log } from './log' +const DEBOUNCE_DELAY = 300 + export class ExtensionWatcher extends vscode.Disposable { private watcherByFolder = new Map() private apisByFolder = new WeakMap() + private debounceTimers = new Map>() constructor( private readonly testTree: TestTree, @@ -25,6 +28,8 @@ export class ExtensionWatcher extends vscode.Disposable { this.watcherByFolder.forEach((x) => x.dispose()) this.watcherByFolder.clear() this.apisByFolder = new WeakMap() + this.debounceTimers.forEach((timer) => clearTimeout(timer)) + this.debounceTimers.clear() } watchTestFilesInWorkspace(api: VitestProcessAPI) { @@ -44,82 +49,136 @@ export class ExtensionWatcher extends vscode.Disposable { const watcher = vscode.workspace.createFileSystemWatcher(pattern) this.watcherByFolder.set(folder, watcher) - watcher.onDidDelete(async (uri) => { + const deleteQueue = new Map() + const changeQueue = new Map() + const createQueue = new Map() + + watcher.onDidDelete((uri) => { const path = normalize(uri.fsPath) if (this.isCommondIgnore(path)) { return } - - log.verbose?.('[VSCODE] Item deleted:', this.relative(api, uri)) - - this.transformSchemaProvider.emitChange(uri) - - // We don't know if it is a file or a folder - this.testTree.removeFile(path) - this.testTree.removeFolder(path) + deleteQueue.set(path, uri) + this.scheduleFlush(`delete:${folder.name}`, () => { + const batch = new Map(deleteQueue) + deleteQueue.clear() + log.verbose?.(`[VSCODE] Flushing ${batch.size} deleted items`) + for (const [path, uri] of batch) { + this.transformSchemaProvider.emitChange(uri) + // We don't know if it is a file or a folder + this.testTree.removeFile(path) + this.testTree.removeFolder(path) + } + }) }) - watcher.onDidChange(async (uri) => { + watcher.onDidChange((uri) => { const path = normalize(uri.fsPath) - const type = await this.getFsType(api, path, uri) - if (type !== 'file') { + if (this.isCommondIgnore(path)) { return } + changeQueue.set(path, uri) + this.scheduleFlush(`change:${folder.name}`, async () => { + const batch = new Map(changeQueue) + changeQueue.clear() + log.verbose?.(`[VSCODE] Flushing ${batch.size} changed items`) + const apis = this.apisByFolder.get(folder) || [] + for (const [path, uri] of batch) { + const type = await this.getFsType(api, path, uri) + if (type !== 'file') { + continue + } + this.transformSchemaProvider.emitChange(uri) + + apis.forEach((api) => { + api.onFileChanged(path) + const fileItems = this.testTree.getFileTestItems(path) + + // Ignore changed to never opened files + if (fileItems.every((item) => item.children.size === 0 && !item.error)) { + return + } + + if (api.getPersistentProcessMeta() || api.isSpawningPersistentProcess) { + return + } - this.transformSchemaProvider.emitChange(uri) - log.verbose?.('[VSCODE] File changed:', this.relative(api, uri)) - const apis = this.apisByFolder.get(folder) || [] - apis.forEach((api) => api.onFileChanged(path)) - apis.forEach((api) => { - if (api.getPersistentProcessMeta() || api.isSpawningPersistentProcess) { - return + const metadata = api.getPotentialTestFileMetadata(path) + metadata.forEach((meta) => { + api.collectTests(meta.project, path) + }) + }) } - const metadata = api.getPotentialTestFileMetadata(path) - metadata.forEach((meta) => { - api.collectTests(meta.project, path) - }) }) }) - watcher.onDidCreate(async (uri) => { + watcher.onDidCreate((uri) => { const path = normalize(uri.fsPath) - const type = await this.getFsType(api, path, uri) - - if (!type) { + if (this.isCommondIgnore(path)) { return } + createQueue.set(path, uri) + this.scheduleFlush(`create:${folder.name}`, async () => { + const batch = new Map(createQueue) + createQueue.clear() + log.verbose?.(`[VSCODE] Flushing ${batch.size} created items`) + + const apis = this.apisByFolder.get(folder) || [] + const roots = apis.flatMap((api) => + // TODO: resolve should be done on the worker side + api.config.projects.map((p) => normalize(resolve(api.config.cwd, p.dir || p.root))), + ) + const openedFiles = vscode.workspace.textDocuments.map((d) => normalize(d.uri.fsPath)) + + const allFiles: string[] = [] + for (const [path, uri] of batch) { + const type = await this.getFsType(api, path, uri) + if (!type) { + continue + } + if (type === 'file') { + allFiles.push(path) + } else { + allFiles.push(...(await this.readFilesRecursively(uri, roots))) + } + } - log.verbose?.('[VSCODE]', 'New', type, 'created:', this.relative(api, uri)) - - const apis = this.apisByFolder.get(folder) || [] - const roots = apis.flatMap((api) => - // TODO: resolve should be done on the worker side - api.config.projects.map((p) => normalize(resolve(api.config.cwd, p.dir || p.root))), - ) - const files = type === 'file' ? [path] : await this.readFilesRecursively(uri, roots) - const openedFiles = vscode.workspace.textDocuments.map((d) => normalize(d.uri.fsPath)) - - files.forEach((file) => { - apis.forEach((api) => { - const metadata = api.getPotentialTestFileMetadata(file) - metadata.forEach((meta) => { - this.testTree.getOrCreateFileTestItem(api, meta, file) - - // If file is open and not a continuous run, - // Collect its tests immidetly, otherwise ignore - if ( - openedFiles.includes(file) && - !api.getPersistentProcessMeta() && - !api.isSpawningPersistentProcess - ) { - api.collectTests(meta.project, file) - } + allFiles.forEach((file) => { + apis.forEach((api) => { + const metadata = api.getPotentialTestFileMetadata(file) + metadata.forEach((meta) => { + this.testTree.getOrCreateFileTestItem(api, meta, file) + + // If file is open and not a continuous run, + // Collect its tests immidetly, otherwise ignore + if ( + openedFiles.includes(file) && + !api.getPersistentProcessMeta() && + !api.isSpawningPersistentProcess + ) { + api.collectTests(meta.project, file) + } + }) }) }) }) }) } + private scheduleFlush(key: string, flush: () => void) { + const existing = this.debounceTimers.get(key) + if (existing) { + clearTimeout(existing) + } + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key) + flush() + }, DEBOUNCE_DELAY), + ) + } + private relative(api: VitestProcessAPI, uri: vscode.Uri) { return relative(api.workspaceFolder.uri.fsPath, uri.fsPath) } -- 2.51.2