From 02e6a3c075801e061faff398c35851ad4b9f77e1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 30 Jul 2026 09:41:21 -0700 Subject: [PATCH 1/3] feat(extension): update graph cache from workspace events --- packages/extension/src/extension/activate.ts | 2 + .../extension/graphView/analysis/execution.ts | 11 +- .../analysis/execution/incremental.ts | 33 ++++ .../graphView/analysis/execution/load.ts | 8 + .../analysis/execution/load/routing.ts | 5 + .../graphView/analysis/execution/progress.ts | 1 + .../graphView/provider/analysis/execution.ts | 14 +- .../graphView/provider/analysis/methods.ts | 18 ++ .../graphView/provider/analysis/request.ts | 16 +- .../graphView/provider/analysis/state.ts | 2 + .../graphView/provider/wiring/publicApi.ts | 3 + .../pipeline/service/pluginFacade.ts | 8 + .../workspaceFiles/cacheUpdates/model.ts | 180 ++++++++++++++++++ .../workspaceFiles/cacheUpdates/paths.ts | 36 ++++ .../workspaceFiles/cacheUpdates/register.ts | 150 +++++++++++++++ packages/extension/tests/__mocks__/vscode.ts | 12 ++ .../analysis/execution/incremental.test.ts | 79 ++++++++ .../graphView/analysis/execution/load.test.ts | 26 +++ .../analysis/methods.factories.test.ts | 10 +- .../provider/analysis/methods.test.ts | 39 ++++ .../provider/analysis/request.test.ts | 32 ++++ .../provider/wiring/publicApi.test.ts | 8 + .../pipeline/service/pluginFacade.test.ts | 14 ++ .../workspaceFiles/cacheUpdates/model.test.ts | 151 +++++++++++++++ .../workspaceFiles/cacheUpdates/paths.test.ts | 24 +++ .../cacheUpdates/register.test.ts | 158 +++++++++++++++ 26 files changed, 1029 insertions(+), 11 deletions(-) create mode 100644 packages/extension/src/extension/graphView/analysis/execution/incremental.ts create mode 100644 packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts create mode 100644 packages/extension/src/extension/workspaceFiles/cacheUpdates/paths.ts create mode 100644 packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts create mode 100644 packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts create mode 100644 packages/extension/tests/extension/workspaceFiles/cacheUpdates/paths.test.ts create mode 100644 packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts diff --git a/packages/extension/src/extension/activate.ts b/packages/extension/src/extension/activate.ts index a11eb1a52e..ba592fbec7 100644 --- a/packages/extension/src/extension/activate.ts +++ b/packages/extension/src/extension/activate.ts @@ -4,6 +4,7 @@ import { registerConfigHandler } from './config/listener'; import { initializeCurrentCodeGraphyConfiguration } from './repoSettings/current'; import { registerCommands } from './commands/register'; import { registerEditorChangeHandler } from './workspaceFiles/editorSync'; +import { registerWorkspaceCacheUpdates } from './workspaceFiles/cacheUpdates/register'; import { createCodeGraphyAgentUriHandler } from './agentBridge/uri'; import type { GraphQueryRequest, GraphQueryResult } from '@codegraphy-dev/core'; import { getCodeGraphyConfiguration } from './repoSettings/current'; @@ -58,6 +59,7 @@ export function activate(context: vscode.ExtensionContext): CodeGraphyAPI { registerConfigHandler(context, provider); registerEditorChangeHandler(context, provider); + registerWorkspaceCacheUpdates(context, provider); registerCommands(context, provider); diagnostics.emit({ area: 'extension.lifecycle', diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index a99ebe8b04..e214e4c05e 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -5,7 +5,7 @@ import { prepareGraphViewAnalysis } from './execution/prepare'; import { runGraphViewAnalysis } from './execution/run'; import type { CodeGraphyIndexFreshness } from '../../repoSettings/freshness'; -export type GraphViewAnalysisMode = 'load' | 'index' | 'refresh'; +export type GraphViewAnalysisMode = 'load' | 'index' | 'incremental' | 'refresh'; export type GraphViewIndexingProgress = { phase: string; current: number; total: number }; interface GraphViewAnalyzerLike { @@ -24,6 +24,14 @@ interface GraphViewAnalyzerLike { requiredAnalysisCacheTiers?: readonly AnalysisCacheTier[]; }, ): Promise; + hasLoadedGraphState?(): boolean; + refreshChangedFiles?( + filePaths: readonly string[], + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: GraphViewIndexingProgress) => void, + ): Promise; analyze( filterPatterns?: string[], disabledPlugins?: Set, @@ -50,6 +58,7 @@ export interface GraphViewAnalysisExecutionState { analyzerInitPromise: Promise | undefined; installedPluginActivationPromise?: Promise; mode: GraphViewAnalysisMode; + changedFilePaths?: readonly string[]; filterPatterns: string[]; disabledPlugins: Set; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/incremental.ts b/packages/extension/src/extension/graphView/analysis/execution/incremental.ts new file mode 100644 index 0000000000..86eded9d19 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/incremental.ts @@ -0,0 +1,33 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionState, + GraphViewIndexingProgress, +} from '../execution'; +import { EMPTY_GRAPH_DATA } from './publish'; + +export async function refreshGraphViewChangedFiles( + signal: AbortSignal, + state: GraphViewAnalysisExecutionState, + forwardProgress: (progress: GraphViewIndexingProgress) => void, +): Promise { + const analyzer = state.analyzer; + if (!analyzer?.refreshChangedFiles) { + return EMPTY_GRAPH_DATA; + } + + if (!analyzer.hasLoadedGraphState?.()) { + await analyzer.loadCachedGraph?.( + state.filterPatterns, + state.disabledPlugins, + signal, + ); + } + + return analyzer.refreshChangedFiles( + state.changedFilePaths ?? [], + state.filterPatterns, + state.disabledPlugins, + signal, + forwardProgress, + ); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index f4e0ef8da4..2eb13e9826 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -11,6 +11,9 @@ import { EMPTY_GRAPH_DATA } from './publish'; import { refreshGraphViewRawData, } from './refresh'; +import { + refreshGraphViewChangedFiles, +} from './incremental'; import { loadCachedGraphViewRawData, } from './load/analyzerData'; @@ -39,6 +42,11 @@ const GRAPH_VIEW_RAW_DATA_LOADERS: Record Promise> = { cached: loadCachedGraphViewRawDataOnly, empty: async () => EMPTY_GRAPH_DATA, + incremental: context => refreshGraphViewChangedFiles( + context.signal, + context.state, + context.forwardProgress, + ), refresh: loadRefreshedGraphViewRawData, }; diff --git a/packages/extension/src/extension/graphView/analysis/execution/load/routing.ts b/packages/extension/src/extension/graphView/analysis/execution/load/routing.ts index d8765a2b8c..57f350a0e7 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load/routing.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load/routing.ts @@ -4,6 +4,7 @@ import type { GraphViewAnalysisExecutionState } from '../../execution'; export type GraphViewRawDataLoadRoute = | 'cached' | 'empty' + | 'incremental' | 'refresh'; export interface GraphViewRawDataLoadDecision { @@ -21,5 +22,9 @@ export function selectGraphViewRawDataLoadDecision( }; } + if (mode === 'incremental') { + return { route: 'incremental' }; + } + return { route: 'refresh' }; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress.ts b/packages/extension/src/extension/graphView/analysis/execution/progress.ts index 7c3fab2835..0417faa159 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/progress.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/progress.ts @@ -11,6 +11,7 @@ export { createGraphViewIndexProgressCoalescer } from './progress/coalescer'; const ANALYSIS_PHASE_BY_MODE: Record = { load: 'Loading Graph', index: 'Indexing Workspace', + incremental: 'Updating Graph Cache', refresh: 'Refreshing Index', }; export function createGraphViewAnalysisProgressForwarder( diff --git a/packages/extension/src/extension/graphView/provider/analysis/execution.ts b/packages/extension/src/extension/graphView/provider/analysis/execution.ts index 6c5b1ce275..808c9d0dda 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/execution.ts @@ -15,9 +15,17 @@ export function createGraphViewProviderDoAnalyzeAndSendData( dependencies: GraphViewProviderAnalysisMethodDependencies, delegates: GraphViewProviderAnalysisDelegateCalls, mode: GraphViewAnalysisMode, -): (signal: AbortSignal, requestId: number) => Promise { - return async (signal: AbortSignal, requestId: number): Promise => { - const state = createGraphViewProviderAnalysisState(source, mode); +): ( + signal: AbortSignal, + requestId: number, + changedFilePaths?: readonly string[], +) => Promise { + return async ( + signal: AbortSignal, + requestId: number, + changedFilePaths?: readonly string[], + ): Promise => { + const state = createGraphViewProviderAnalysisState(source, mode, changedFilePaths); await dependencies.executeAnalysis( signal, diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index d291a27110..3987226a4b 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -69,6 +69,7 @@ export interface GraphViewProviderAnalysisMethodsSource { export interface GraphViewProviderAnalysisMethods { _loadAndSendData(): Promise; _indexAndSendData(): Promise; + _updateChangedFilesAndSendData(filePaths: readonly string[]): Promise; _refreshAndSendData(): Promise; _doLoadAndSendData(signal: AbortSignal, requestId: number): Promise; _markWorkspaceReady(graph: IGraphData, disabledPlugins?: ReadonlySet): void; @@ -135,6 +136,19 @@ export function createGraphViewProviderAnalysisMethods( _doIndexAndSendData, 'index', ); + const _doUpdateChangedFilesAndSendData = createGraphViewProviderDoAnalyzeAndSendData( + source, + dependencies, + delegates, + 'incremental', + ); + const _updateChangedFilesAndSendData = createGraphViewProviderAnalyzeAndSendData( + source, + dependencies, + delegates, + _doUpdateChangedFilesAndSendData, + 'incremental', + ); const _doRefreshAndSendData = createGraphViewProviderDoAnalyzeAndSendData( source, dependencies, @@ -157,6 +171,10 @@ export function createGraphViewProviderAnalysisMethods( await _loadAndSendData(); }, _indexAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_indexAndSendData), + _updateChangedFilesAndSendData: async filePaths => { + await fullIndexAnalysis.waitForFullIndexAnalysis(); + await _updateChangedFilesAndSendData(filePaths); + }, _refreshAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_refreshAndSendData), _doLoadAndSendData, _markWorkspaceReady, diff --git a/packages/extension/src/extension/graphView/provider/analysis/request.ts b/packages/extension/src/extension/graphView/provider/analysis/request.ts index 1b672835c9..634d2381dd 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/request.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/request.ts @@ -14,16 +14,22 @@ export function createGraphViewProviderAnalyzeAndSendData( source: GraphViewProviderAnalysisMethodsSource, dependencies: GraphViewProviderAnalysisMethodDependencies, delegates: Pick, - doAnalyzeAndSendData: (signal: AbortSignal, requestId: number) => Promise, + doAnalyzeAndSendData: ( + signal: AbortSignal, + requestId: number, + changedFilePaths?: readonly string[], + ) => Promise, mode: GraphViewAnalysisMode, -): () => Promise { - return async (): Promise => { - const state = createGraphViewProviderAnalysisState(source, mode); +): (changedFilePaths?: readonly string[]) => Promise { + return async (changedFilePaths?: readonly string[]): Promise => { + const state = createGraphViewProviderAnalysisState(source, mode, changedFilePaths); await dependencies.runAnalysisRequest( state, createGraphViewProviderAnalysisRequestHandlers(source, dependencies, { - executeAnalysis: (signal, requestId) => doAnalyzeAndSendData(signal, requestId), + executeAnalysis: (signal, requestId) => changedFilePaths + ? doAnalyzeAndSendData(signal, requestId, changedFilePaths) + : doAnalyzeAndSendData(signal, requestId), isAbortError: error => delegates.callIsAbortError(error), }), ); diff --git a/packages/extension/src/extension/graphView/provider/analysis/state.ts b/packages/extension/src/extension/graphView/provider/analysis/state.ts index 8981ae6c91..1258a29c8a 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/state.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/state.ts @@ -11,6 +11,7 @@ interface GraphViewProviderWorkspaceReadyState { export function createGraphViewProviderAnalysisState( source: GraphViewProviderAnalysisMethodsSource, mode: GraphViewAnalysisMode, + changedFilePaths?: readonly string[], ): GraphViewProviderAnalysisState { return { get analysisController() { @@ -47,6 +48,7 @@ export function createGraphViewProviderAnalysisState( return source._installedPluginActivationPromise; }, mode, + ...(changedFilePaths ? { changedFilePaths } : {}), get filterPatterns() { return source._filterPatterns; }, diff --git a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts index 13fdac20fa..81a2d33310 100644 --- a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts +++ b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts @@ -29,6 +29,7 @@ interface GraphViewProviderPublicMethodsOwner { export interface GraphViewProviderPublicMethods { refresh: () => Promise; + updateWorkspaceFiles: (filePaths: readonly string[]) => Promise; refreshIndex: () => Promise; hydrateGraphScope: () => Promise; hydratePluginGraphScope: (pluginIds: readonly string[]) => Promise; @@ -83,6 +84,8 @@ export function assignGraphViewProviderPublicMethods( target: GraphViewProviderPublicMethodsTarget, ): void { target.refresh = () => target._methodContainers.refresh.refresh(); + target.updateWorkspaceFiles = filePaths => + target._methodContainers.analysis._updateChangedFilesAndSendData(filePaths); target.refreshIndex = () => target._methodContainers.refresh.refreshIndex(); target.hydrateGraphScope = () => target._methodContainers.refresh.hydrateGraphScope(); target.hydratePluginGraphScope = pluginIds => diff --git a/packages/extension/src/extension/pipeline/service/pluginFacade.ts b/packages/extension/src/extension/pipeline/service/pluginFacade.ts index fa08db7e07..8fb404d977 100644 --- a/packages/extension/src/extension/pipeline/service/pluginFacade.ts +++ b/packages/extension/src/extension/pipeline/service/pluginFacade.ts @@ -91,6 +91,14 @@ export abstract class WorkspacePipelinePluginFacade extends WorkspacePipelineInt return hasWorkspacePipelineIndex(this._getWorkspaceRoot()); } + hasLoadedGraphState(): boolean { + const workspaceRoot = this._getWorkspaceRoot(); + return Boolean( + workspaceRoot + && this._lastWorkspaceRoot === workspaceRoot, + ); + } + getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { return getWorkspacePipelineIndexStatus({ hasIndex: () => this.hasIndex(), diff --git a/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts b/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts new file mode 100644 index 0000000000..98801ec92f --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts @@ -0,0 +1,180 @@ +export type WorkspaceCacheUpdateStatus = + | { state: 'queued'; fileCount: number; detail: string } + | { state: 'updating'; fileCount: number; detail: string } + | { state: 'idle'; fileCount: 0; detail: string } + | { state: 'error'; fileCount: number; detail: string }; + +export interface WorkspaceCacheUpdateProgress { + phase: string; + current: number; + total: number; +} + +export interface WorkspaceCacheUpdateSchedulerOptions { + debounceMs: number; + hasGraphCache(): boolean; + maxBatchAgeMs: number; + onStatus(status: WorkspaceCacheUpdateStatus): void; + update( + filePaths: readonly string[], + signal: AbortSignal, + onProgress: (progress: WorkspaceCacheUpdateProgress) => void, + ): Promise; +} + +export interface WorkspaceCacheUpdateScheduler { + dispose(): void; + notify(filePaths: readonly string[]): void; +} + +class WorkspaceCacheUpdateSchedulerState implements WorkspaceCacheUpdateScheduler { + private activeController: AbortController | undefined; + private activeUpdate: Promise | undefined; + private debounceTimer: ReturnType | undefined; + private disposed = false; + private maxBatchAgeTimer: ReturnType | undefined; + private readonly pendingFilePaths = new Set(); + + constructor(private readonly options: WorkspaceCacheUpdateSchedulerOptions) {} + + notify(filePaths: readonly string[]): void { + if (this.disposed || !this.options.hasGraphCache()) { + return; + } + for (const filePath of filePaths) { + this.pendingFilePaths.add(filePath); + } + if (this.pendingFilePaths.size === 0) { + return; + } + this.options.onStatus(createQueuedStatus(this.pendingFilePaths.size)); + this.schedule(); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.pendingFilePaths.clear(); + this.clearTimers(); + this.activeController?.abort(); + } + + private schedule(): void { + if (this.activeUpdate) { + return; + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => this.startUpdate(), this.options.debounceMs); + this.maxBatchAgeTimer ??= setTimeout( + () => this.startUpdate(), + this.options.maxBatchAgeMs, + ); + } + + private startUpdate(): void { + if (this.disposed || this.activeUpdate || this.pendingFilePaths.size === 0) { + return; + } + this.clearTimers(); + const filePaths: string[] = [...this.pendingFilePaths]; + this.pendingFilePaths.clear(); + const controller = new AbortController(); + this.activeController = controller; + this.options.onStatus(createUpdatingStatus(filePaths.length)); + this.activeUpdate = this.options.update( + filePaths, + controller.signal, + progress => this.reportProgress(filePaths.length, progress), + ); + void this.activeUpdate + .then(() => { + if (!this.disposed && this.pendingFilePaths.size === 0) { + this.options.onStatus({ + state: 'idle', + fileCount: 0, + detail: 'Graph Cache is current.', + }); + } + }) + .catch((error: unknown) => { + if (!this.disposed && !controller.signal.aborted) { + this.options.onStatus({ + state: 'error', + fileCount: filePaths.length, + detail: `Graph Cache update failed: ${formatError(error)}`, + }); + } + }) + .finally(() => { + if (this.activeController === controller) { + this.activeController = undefined; + this.activeUpdate = undefined; + } + if (!this.disposed && this.pendingFilePaths.size > 0) { + this.options.onStatus(createQueuedStatus(this.pendingFilePaths.size)); + this.schedule(); + } + }); + } + + private reportProgress( + fileCount: number, + progress: WorkspaceCacheUpdateProgress, + ): void { + if (this.disposed) { + return; + } + const total = Math.max(1, progress.total); + const current = Math.min(total, Math.max(0, progress.current)); + this.options.onStatus({ + state: 'updating', + fileCount, + detail: `${progress.phase}: ${current} of ${total}.`, + }); + } + + private clearTimers(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + if (this.maxBatchAgeTimer) { + clearTimeout(this.maxBatchAgeTimer); + } + this.debounceTimer = undefined; + this.maxBatchAgeTimer = undefined; + } +} + +function createQueuedStatus(fileCount: number): WorkspaceCacheUpdateStatus { + return { + state: 'queued', + fileCount, + detail: fileCount === 1 + ? '1 saved workspace file is queued for Graph Cache update.' + : `${fileCount} saved workspace files are queued for Graph Cache update.`, + }; +} + +function createUpdatingStatus(fileCount: number): WorkspaceCacheUpdateStatus { + return { + state: 'updating', + fileCount, + detail: fileCount === 1 + ? 'Updating the Graph Cache for 1 workspace file.' + : `Updating the Graph Cache for ${fileCount} workspace files.`, + }; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createWorkspaceCacheUpdateScheduler( + options: WorkspaceCacheUpdateSchedulerOptions, +): WorkspaceCacheUpdateScheduler { + return new WorkspaceCacheUpdateSchedulerState(options); +} diff --git a/packages/extension/src/extension/workspaceFiles/cacheUpdates/paths.ts b/packages/extension/src/extension/workspaceFiles/cacheUpdates/paths.ts new file mode 100644 index 0000000000..3b7108ab81 --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/cacheUpdates/paths.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; + +export function collectWorkspaceCacheUpdatePaths( + workspaceRoot: string, + filePaths: readonly string[], +): string[] { + const selectedPaths: string[] = []; + const seenPaths = new Set(); + + for (const filePath of filePaths) { + const absolutePath = path.resolve(filePath); + const relativePath = path.relative(workspaceRoot, absolutePath); + const normalizedPath = relativePath.split(path.sep).join('/'); + + if ( + !normalizedPath + || normalizedPath === '..' + || normalizedPath.startsWith('../') + || path.isAbsolute(relativePath) + || isCodeGraphyGeneratedPath(normalizedPath) + || seenPaths.has(absolutePath) + ) { + continue; + } + + seenPaths.add(absolutePath); + selectedPaths.push(absolutePath); + } + + return selectedPaths; +} + +function isCodeGraphyGeneratedPath(relativePath: string): boolean { + return relativePath.startsWith('.codegraphy/') + && relativePath !== '.codegraphy/settings.json'; +} diff --git a/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts b/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts new file mode 100644 index 0000000000..d15a65e658 --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts @@ -0,0 +1,150 @@ +import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; +import * as vscode from 'vscode'; +import { + createWorkspaceCacheUpdateScheduler, + type WorkspaceCacheUpdateScheduler, + type WorkspaceCacheUpdateSchedulerOptions, + type WorkspaceCacheUpdateStatus, +} from './model'; +import { collectWorkspaceCacheUpdatePaths } from './paths'; + +const CACHE_UPDATE_DEBOUNCE_MS = 500; +const CACHE_UPDATE_MAX_BATCH_AGE_MS = 2_000; + +interface FileUri { + fsPath: string; + scheme: string; +} + +interface Disposable { + dispose(): void; +} + +interface StatusBarItem extends Disposable { + text: string; + tooltip: unknown; + hide(): void; + show(): void; +} + +interface WorkspaceCacheUpdateContext { + subscriptions: Disposable[]; +} + +interface WorkspaceCacheUpdateProvider { + updateWorkspaceFiles(filePaths: readonly string[]): Promise; +} + +export interface WorkspaceCacheUpdateRegistrationDependencies { + createScheduler( + options: WorkspaceCacheUpdateSchedulerOptions, + ): WorkspaceCacheUpdateScheduler; + createStatusBarItem(): StatusBarItem; + hasGraphCache(workspaceRoot: string): boolean; + onDidCreateFiles( + listener: (event: { files: readonly FileUri[] }) => void, + ): Disposable; + onDidDeleteFiles( + listener: (event: { files: readonly FileUri[] }) => void, + ): Disposable; + onDidRenameFiles( + listener: ( + event: { + files: ReadonlyArray<{ oldUri: FileUri; newUri: FileUri }>; + }, + ) => void, + ): Disposable; + onDidSaveTextDocument( + listener: (document: { uri: FileUri }) => void, + ): Disposable; + workspaceRoot(): string | undefined; +} + +const defaultDependencies: WorkspaceCacheUpdateRegistrationDependencies = { + createScheduler: createWorkspaceCacheUpdateScheduler, + createStatusBarItem: () => + vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 20), + hasGraphCache: workspaceRoot => + readCodeGraphyWorkspaceStatus(workspaceRoot).hasGraphCache, + onDidCreateFiles: listener => vscode.workspace.onDidCreateFiles(listener), + onDidDeleteFiles: listener => vscode.workspace.onDidDeleteFiles(listener), + onDidRenameFiles: listener => vscode.workspace.onDidRenameFiles(listener), + onDidSaveTextDocument: listener => + vscode.workspace.onDidSaveTextDocument(listener), + workspaceRoot: () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, +}; + +export function registerWorkspaceCacheUpdates( + context: WorkspaceCacheUpdateContext, + provider: WorkspaceCacheUpdateProvider, + dependencies: WorkspaceCacheUpdateRegistrationDependencies = defaultDependencies, +): void { + const statusBarItem = dependencies.createStatusBarItem(); + const scheduler = dependencies.createScheduler({ + debounceMs: CACHE_UPDATE_DEBOUNCE_MS, + hasGraphCache: () => { + const workspaceRoot = dependencies.workspaceRoot(); + return workspaceRoot !== undefined + && dependencies.hasGraphCache(workspaceRoot); + }, + maxBatchAgeMs: CACHE_UPDATE_MAX_BATCH_AGE_MS, + onStatus: status => renderStatus(statusBarItem, status), + update: async (filePaths, signal) => { + if (!signal.aborted) { + await provider.updateWorkspaceFiles(filePaths); + } + }, + }); + + const notify = (uris: readonly FileUri[]): void => { + const workspaceRoot = dependencies.workspaceRoot(); + if (!workspaceRoot) { + return; + } + const filePaths: string[] = collectWorkspaceCacheUpdatePaths( + workspaceRoot, + uris.filter(uri => uri.scheme === 'file').map(uri => uri.fsPath), + ); + if (filePaths.length > 0) { + scheduler.notify(filePaths); + } + }; + + const eventDisposables: Disposable[] = [ + dependencies.onDidSaveTextDocument(document => notify([document.uri])), + dependencies.onDidCreateFiles(event => notify(event.files)), + dependencies.onDidDeleteFiles(event => notify(event.files)), + dependencies.onDidRenameFiles(event => + notify(event.files.flatMap(file => [file.oldUri, file.newUri]))), + ]; + context.subscriptions.push(...eventDisposables, scheduler, statusBarItem); +} + +function renderStatus( + statusBarItem: StatusBarItem, + status: WorkspaceCacheUpdateStatus, +): void { + if (status.state === 'idle') { + statusBarItem.hide(); + return; + } + + statusBarItem.text = statusBarText(status); + statusBarItem.tooltip = status.detail; + statusBarItem.show(); +} + +function statusBarText(status: Exclude): string { + switch (status.state) { + case 'queued': + return status.fileCount === 1 + ? '$(clock) CodeGraphy: 1 change queued' + : `$(clock) CodeGraphy: ${status.fileCount} changes queued`; + case 'updating': + return status.fileCount === 1 + ? '$(sync~spin) CodeGraphy: Updating 1 file' + : `$(sync~spin) CodeGraphy: Updating ${status.fileCount} files`; + case 'error': + return '$(error) CodeGraphy: Cache update failed'; + } +} diff --git a/packages/extension/tests/__mocks__/vscode.ts b/packages/extension/tests/__mocks__/vscode.ts index 0ee7a620cd..781d057b0e 100644 --- a/packages/extension/tests/__mocks__/vscode.ts +++ b/packages/extension/tests/__mocks__/vscode.ts @@ -8,6 +8,13 @@ export const Uri = { }; export const window = { + createStatusBarItem: vi.fn(() => ({ + text: '', + tooltip: '', + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + })), registerWebviewViewProvider: vi.fn(), registerUriHandler: vi.fn(), showInformationMessage: vi.fn(), @@ -74,3 +81,8 @@ export enum ConfigurationTarget { Workspace = 2, WorkspaceFolder = 3, } + +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} diff --git a/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts new file mode 100644 index 0000000000..e53421206d --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/incremental.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + refreshGraphViewChangedFiles, +} from '../../../../../src/extension/graphView/analysis/execution/incremental'; +import type { + GraphViewAnalysisExecutionState, +} from '../../../../../src/extension/graphView/analysis/execution'; + +function createState( + analyzerOverrides: Record = {}, +): GraphViewAnalysisExecutionState { + return { + analyzer: { + analyze: vi.fn(), + hasIndex: vi.fn(() => true), + initialize: vi.fn(), + loadCachedGraph: vi.fn(async () => ({ nodes: [], edges: [] })), + refreshChangedFiles: vi.fn(async () => ({ + nodes: [{ id: 'src/app.ts', label: 'app.ts', color: '#fff' }], + edges: [], + })), + registry: { + notifyPostAnalyze: vi.fn(), + }, + syncWorkspacePlugins: vi.fn(), + ...analyzerOverrides, + }, + analyzerInitialized: true, + analyzerInitPromise: undefined, + changedFilePaths: ['/workspace/src/app.ts'], + disabledPlugins: new Set(['plugin.disabled']), + filterPatterns: ['generated/**'], + mode: 'incremental', + }; +} + +describe('graphView/analysis/execution/incremental', () => { + it('hydrates the existing Graph Cache before the first saved-file update', async () => { + const state = createState({ + hasLoadedGraphState: vi.fn(() => false), + }); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + + await expect( + refreshGraphViewChangedFiles(signal, state, onProgress), + ).resolves.toEqual({ + nodes: [{ id: 'src/app.ts', label: 'app.ts', color: '#fff' }], + edges: [], + }); + + expect(state.analyzer?.loadCachedGraph).toHaveBeenCalledWith( + ['generated/**'], + new Set(['plugin.disabled']), + signal, + ); + expect(state.analyzer?.refreshChangedFiles).toHaveBeenCalledWith( + ['/workspace/src/app.ts'], + ['generated/**'], + new Set(['plugin.disabled']), + signal, + onProgress, + ); + }); + + it('reuses loaded analysis state for later saved-file updates', async () => { + const state = createState({ + hasLoadedGraphState: vi.fn(() => true), + }); + + await refreshGraphViewChangedFiles( + new AbortController().signal, + state, + vi.fn(), + ); + + expect(state.analyzer?.loadCachedGraph).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts index 63d93ff33e..1ed07ddb9f 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts @@ -106,6 +106,32 @@ describe('graph view analysis execution load', () => { expect(refreshIndex).toHaveBeenCalledOnce(); }); + it('uses the bounded changed-file refresh for an incremental update', async () => { + const graph = { + nodes: [{ id: 'src/changed.ts', label: 'changed.ts', color: '#ffffff' }], + edges: [], + }; + const refreshChangedFiles = vi.fn(async () => graph); + const refreshIndex = vi.fn(async () => ({ nodes: [], edges: [] })); + const state = createExecutionState({ + changedFilePaths: ['/workspace/src/changed.ts'], + mode: 'incremental', + analyzer: createExecutionAnalyzer({ + hasLoadedGraphState: vi.fn(() => true), + refreshChangedFiles, + refreshIndex, + }), + }); + const { handlers } = createExecutionHandlers(); + + await expect( + loadGraphViewRawData(new AbortController().signal, state, handlers), + ).resolves.toEqual(graph); + + expect(refreshChangedFiles).toHaveBeenCalledOnce(); + expect(refreshIndex).not.toHaveBeenCalled(); + }); + it('returns an empty graph when no analyzer is available', async () => { const state = createExecutionState({ mode: 'load', analyzer: undefined }); const { handlers } = createExecutionHandlers(); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.factories.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.factories.test.ts index 1ad6baef95..57bcc8eae1 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.factories.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.factories.test.ts @@ -49,17 +49,19 @@ describe('graphView/provider/analysis/methods factories', () => { const doRunners = { load: vi.fn(async () => undefined), index: vi.fn(async () => undefined), + incremental: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), }; const requestRunners = { load: vi.fn(async () => undefined), index: vi.fn(async () => undefined), + incremental: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), }; vi.mocked(createGraphViewProviderDoAnalyzeAndSendData).mockImplementation( (_source, _dependencies, _delegates, mode) => { - if (mode !== 'load' && mode !== 'index' && mode !== 'refresh') { + if (mode !== 'load' && mode !== 'index' && mode !== 'incremental' && mode !== 'refresh') { throw new Error(`Unexpected analysis mode: ${mode}`); } return doRunners[mode]; @@ -67,7 +69,7 @@ describe('graphView/provider/analysis/methods factories', () => { ); vi.mocked(createGraphViewProviderAnalyzeAndSendData).mockImplementation( (_source, _dependencies, _delegates, doAnalyzeAndSendData, mode) => { - if (mode !== 'load' && mode !== 'index' && mode !== 'refresh') { + if (mode !== 'load' && mode !== 'index' && mode !== 'incremental' && mode !== 'refresh') { throw new Error(`Unexpected analysis mode: ${mode}`); } expect(doAnalyzeAndSendData).toBe(doRunners[mode]); @@ -80,20 +82,24 @@ describe('graphView/provider/analysis/methods factories', () => { await methods._loadAndSendData(); await methods._indexAndSendData(); + await methods._updateChangedFilesAndSendData(['/workspace/src/app.ts']); await methods._refreshAndSendData(); expect(vi.mocked(createGraphViewProviderDoAnalyzeAndSendData).mock.calls.map(call => call[3])).toEqual([ 'load', 'index', + 'incremental', 'refresh', ]); expect(vi.mocked(createGraphViewProviderAnalyzeAndSendData).mock.calls.map(call => call[4])).toEqual([ 'load', 'index', + 'incremental', 'refresh', ]); expect(requestRunners.load).toHaveBeenCalledOnce(); expect(requestRunners.index).toHaveBeenCalledOnce(); + expect(requestRunners.incremental).toHaveBeenCalledOnce(); expect(requestRunners.refresh).toHaveBeenCalledOnce(); }); }); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 89614cd194..d5fa58fd63 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -224,6 +224,45 @@ describe('graphView/provider/analysis/methods', () => { expect(runAnalysisRequest).toHaveBeenCalledOnce(); }); + it('runs saved-file updates after an active reindex completes', async () => { + const source = createSource(); + let finishRefresh: (() => void) | undefined; + const runAnalysisRequest = vi.fn(async state => { + if (state.mode !== 'refresh') { + return; + } + + await new Promise(resolve => { + finishRefresh = resolve; + }); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + + const refresh = methods._refreshAndSendData(); + await Promise.resolve(); + const update = methods._updateChangedFilesAndSendData(['/workspace/src/saved.ts']); + + expect(runAnalysisRequest).toHaveBeenCalledOnce(); + + finishRefresh?.(); + await refresh; + await update; + + expect(runAnalysisRequest).toHaveBeenCalledTimes(2); + expect(runAnalysisRequest.mock.calls.map(([state]) => state.mode)).toEqual([ + 'refresh', + 'incremental', + ]); + }); + it('keeps webview-ready loading from interrupting an active first index', async () => { const source = createSource(); let finishIndex: (() => void) | undefined; diff --git a/packages/extension/tests/extension/graphView/provider/analysis/request.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/request.test.ts index 7b17c9ebaa..ac2c13b4c8 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/request.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/request.test.ts @@ -107,4 +107,36 @@ describe('graphView/provider/analysis/request', () => { expect(loadAndSendData).toHaveBeenCalledWith(expect.any(AbortSignal), 7); expect(source._doLoadAndSendData).not.toHaveBeenCalled(); }); + + it('keeps the coalesced paths on an incremental analysis request', async () => { + const source = createSource(); + const updateChangedFiles = vi.fn(async () => undefined); + const dependencies = createDependencies({ + runAnalysisRequest: vi.fn(async (state, handlers) => { + expect(state.mode).toBe('incremental'); + expect(state.changedFilePaths).toEqual([ + '/workspace/src/a.ts', + '/workspace/src/b.ts', + ]); + await handlers.executeAnalysis(new AbortController().signal, 9); + }), + }); + const filePaths = ['/workspace/src/a.ts', '/workspace/src/b.ts']; + + await createGraphViewProviderAnalyzeAndSendData( + source, + dependencies, + { + callIsAbortError: vi.fn(() => false), + }, + updateChangedFiles, + 'incremental', + )(filePaths); + + expect(updateChangedFiles).toHaveBeenCalledWith( + expect.any(AbortSignal), + 9, + filePaths, + ); + }); }); diff --git a/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts b/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts index c60e6a266d..f4490f9d09 100644 --- a/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts +++ b/packages/extension/tests/extension/graphView/provider/wiring/publicApi.test.ts @@ -39,6 +39,9 @@ function createTarget() { emitEvent: vi.fn(), }; const pluginMethods = {}; + const analysisMethods = { + _updateChangedFilesAndSendData: vi.fn(async () => undefined), + }; const queryMethods = { queryGraph: vi.fn(() => ({ nodes: [{ path: 'src/app.ts', nodeType: 'file' }], @@ -95,6 +98,7 @@ function createTarget() { sendToWebview: vi.fn(), onWebviewMessage, _methodContainers: { + analysis: analysisMethods, refresh: refreshMethods, command: commandMethods, plugin: pluginMethods, @@ -120,6 +124,7 @@ describe('assignGraphViewProviderPublicMethods', () => { target.refreshSettings(); target.refreshToggleSettings(); await target.clearCacheAndRefresh(); + await target.updateWorkspaceFiles(['/workspace/src/app.ts']); target.sendCommand('FIT_VIEW'); expect(await target.undo()).toBe('undo'); expect(await target.redo()).toBe('redo'); @@ -141,6 +146,9 @@ describe('assignGraphViewProviderPublicMethods', () => { expect(target._methodContainers.refresh.refreshSettings).toHaveBeenCalledTimes(1); expect(target._methodContainers.refresh.refreshToggleSettings).toHaveBeenCalledTimes(1); expect(target._methodContainers.refresh.clearCacheAndRefresh).toHaveBeenCalledTimes(1); + expect( + target._methodContainers.analysis._updateChangedFilesAndSendData, + ).toHaveBeenCalledWith(['/workspace/src/app.ts']); expect(target._methodContainers.command.sendCommand).toHaveBeenCalledWith('FIT_VIEW'); expect(target._methodContainers.command.undo).toHaveBeenCalledTimes(1); expect(target._methodContainers.command.redo).toHaveBeenCalledTimes(1); diff --git a/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts index b45545ace1..195149a3a6 100644 --- a/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts @@ -82,6 +82,10 @@ class TestPluginFacade extends WorkspacePipelinePluginFacade { this._disposeWorkspacePluginHost(); } + setLoadedGraphState(workspaceRoot: string): void { + this._lastWorkspaceRoot = workspaceRoot; + } + protected override _getPluginSignature(): string | null { return 'plugin-signature'; } @@ -244,4 +248,14 @@ describe('extension/pipeline/service/pluginFacade', () => { expect(statusInput.hasIndex()).toBe(true); expect(hasWorkspacePipelineIndex).toHaveBeenLastCalledWith('/workspace'); }); + + it('reports whether graph state has been loaded for the current workspace', () => { + const facade = new TestPluginFacade(); + + expect(facade.hasLoadedGraphState()).toBe(false); + + facade.setLoadedGraphState('/workspace'); + + expect(facade.hasLoadedGraphState()).toBe(true); + }); }); diff --git a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts new file mode 100644 index 0000000000..b62ce00491 --- /dev/null +++ b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createWorkspaceCacheUpdateScheduler, + type WorkspaceCacheUpdateSchedulerOptions, + type WorkspaceCacheUpdateStatus, +} from '../../../../src/extension/workspaceFiles/cacheUpdates/model'; + +describe('workspaceFiles/cacheUpdates/model', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('waits for an existing Graph Cache and coalesces saved paths', async () => { + vi.useFakeTimers(); + const update = vi.fn(async () => undefined); + const statuses: WorkspaceCacheUpdateStatus[] = []; + let hasGraphCache = false; + const scheduler = createWorkspaceCacheUpdateScheduler({ + debounceMs: 500, + hasGraphCache: () => hasGraphCache, + maxBatchAgeMs: 2_000, + onStatus: status => statuses.push(status), + update, + }); + + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(500); + + expect(update).not.toHaveBeenCalled(); + expect(statuses).toEqual([]); + + hasGraphCache = true; + scheduler.notify([ + '/workspace/src/a.ts', + '/workspace/src/b.ts', + '/workspace/src/a.ts', + ]); + await vi.advanceTimersByTimeAsync(499); + + expect(update).not.toHaveBeenCalled(); + expect(statuses.at(-1)).toEqual({ + state: 'queued', + fileCount: 2, + detail: '2 saved workspace files are queued for Graph Cache update.', + }); + + await vi.advanceTimersByTimeAsync(1); + + expect(update).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith( + ['/workspace/src/a.ts', '/workspace/src/b.ts'], + expect.any(AbortSignal), + expect.any(Function), + ); + expect(statuses.at(-1)).toEqual({ + state: 'idle', + fileCount: 0, + detail: 'Graph Cache is current.', + }); + + scheduler.dispose(); + }); + + it('serializes updates and retains saves that arrive during active work', async () => { + vi.useFakeTimers(); + let finishFirstUpdate!: () => void; + const firstUpdateGate = new Promise((resolve) => { + finishFirstUpdate = resolve; + }); + const update = vi.fn(async () => { + if (update.mock.calls.length === 1) { + await firstUpdateGate; + } + }); + const scheduler = createWorkspaceCacheUpdateScheduler({ + debounceMs: 500, + hasGraphCache: () => true, + maxBatchAgeMs: 2_000, + onStatus: vi.fn(), + update, + }); + + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(500); + scheduler.notify(['/workspace/src/b.ts']); + await vi.advanceTimersByTimeAsync(2_000); + + expect(update).toHaveBeenCalledOnce(); + + finishFirstUpdate(); + await vi.advanceTimersByTimeAsync(500); + + expect(update).toHaveBeenCalledTimes(2); + expect(update.mock.calls[1]?.[0]).toEqual(['/workspace/src/b.ts']); + + scheduler.dispose(); + }); + + it('forces a continuously changing batch at its maximum age', async () => { + vi.useFakeTimers(); + const update = vi.fn(async () => undefined); + const scheduler = createWorkspaceCacheUpdateScheduler({ + debounceMs: 500, + hasGraphCache: () => true, + maxBatchAgeMs: 1_000, + onStatus: vi.fn(), + update, + }); + + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(400); + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(400); + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(200); + + expect(update).toHaveBeenCalledOnce(); + + scheduler.dispose(); + }); + + it('cancels active work and drops pending saves when disposed', async () => { + vi.useFakeTimers(); + let updateSignal: AbortSignal | undefined; + const update = vi.fn(async ( + _filePaths: readonly string[], + signal: AbortSignal, + ) => { + updateSignal = signal; + await new Promise(() => undefined); + }); + const onStatus = vi.fn(); + const scheduler = createWorkspaceCacheUpdateScheduler({ + debounceMs: 500, + hasGraphCache: () => true, + maxBatchAgeMs: 2_000, + onStatus, + update, + }); + + scheduler.notify(['/workspace/src/a.ts']); + await vi.advanceTimersByTimeAsync(500); + scheduler.notify(['/workspace/src/b.ts']); + scheduler.dispose(); + await vi.advanceTimersByTimeAsync(2_000); + + expect(updateSignal?.aborted).toBe(true); + expect(update).toHaveBeenCalledOnce(); + expect(onStatus).not.toHaveBeenCalledWith(expect.objectContaining({ state: 'error' })); + }); +}); diff --git a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/paths.test.ts b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/paths.test.ts new file mode 100644 index 0000000000..5f45569a32 --- /dev/null +++ b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/paths.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + collectWorkspaceCacheUpdatePaths, +} from '../../../../src/extension/workspaceFiles/cacheUpdates/paths'; + +describe('workspaceFiles/cacheUpdates/paths', () => { + it('keeps workspace source and lifecycle paths without a cache feedback loop', () => { + expect(collectWorkspaceCacheUpdatePaths('/workspace', [ + '/workspace/src/app.ts', + '/workspace/.gitignore', + '/workspace/packages/example/.gitignore', + '/workspace/.codegraphy/settings.json', + '/workspace/.codegraphy/graph.sqlite', + '/workspace/.codegraphy/graph.sqlite-wal', + '/other/src/app.ts', + '/workspace', + ])).toEqual([ + '/workspace/src/app.ts', + '/workspace/.gitignore', + '/workspace/packages/example/.gitignore', + '/workspace/.codegraphy/settings.json', + ]); + }); +}); diff --git a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts new file mode 100644 index 0000000000..019c080e37 --- /dev/null +++ b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + WorkspaceCacheUpdateSchedulerOptions, + WorkspaceCacheUpdateStatus, +} from '../../../../src/extension/workspaceFiles/cacheUpdates/model'; +import { + registerWorkspaceCacheUpdates, + type WorkspaceCacheUpdateRegistrationDependencies, +} from '../../../../src/extension/workspaceFiles/cacheUpdates/register'; + +interface FileUri { + fsPath: string; + scheme: string; +} + +function fileUri(fsPath: string): FileUri { + return { fsPath, scheme: 'file' }; +} + +function createHarness() { + let saveListener: ((document: { uri: FileUri }) => void) | undefined; + let createListener: ((event: { files: readonly FileUri[] }) => void) | undefined; + let deleteListener: ((event: { files: readonly FileUri[] }) => void) | undefined; + let renameListener: ( + (event: { files: ReadonlyArray<{ oldUri: FileUri; newUri: FileUri }> }) => void + ) | undefined; + let schedulerOptions: WorkspaceCacheUpdateSchedulerOptions | undefined; + const notify = vi.fn(); + const statusBarItem = { + text: '', + tooltip: '', + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + }; + const disposable = { dispose: vi.fn() }; + const dependencies: WorkspaceCacheUpdateRegistrationDependencies = { + createScheduler: vi.fn((options) => { + schedulerOptions = options; + return { dispose: vi.fn(), notify }; + }), + createStatusBarItem: vi.fn(() => statusBarItem), + hasGraphCache: vi.fn(() => true), + onDidCreateFiles: vi.fn((listener) => { + createListener = listener; + return disposable; + }), + onDidDeleteFiles: vi.fn((listener) => { + deleteListener = listener; + return disposable; + }), + onDidRenameFiles: vi.fn((listener) => { + renameListener = listener; + return disposable; + }), + onDidSaveTextDocument: vi.fn((listener) => { + saveListener = listener; + return disposable; + }), + workspaceRoot: vi.fn(() => '/workspace'), + }; + + return { + dependencies, + listeners: { + create: () => createListener, + delete: () => deleteListener, + rename: () => renameListener, + save: () => saveListener, + }, + notify, + schedulerOptions: () => schedulerOptions, + statusBarItem, + }; +} + +describe('workspaceFiles/cacheUpdates/register', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps VS Code save, create, delete, and rename events to workspace cache paths', () => { + const harness = createHarness(); + const context = { subscriptions: [] as Array<{ dispose(): void }> }; + + registerWorkspaceCacheUpdates( + context, + { updateWorkspaceFiles: vi.fn(async () => undefined) }, + harness.dependencies, + ); + + harness.listeners.save()?.({ uri: fileUri('/workspace/src/saved.ts') }); + harness.listeners.save()?.({ uri: fileUri('/workspace/.codegraphy/graph.sqlite') }); + harness.listeners.create()?.({ + files: [fileUri('/workspace/src/created.ts')], + }); + harness.listeners.delete()?.({ + files: [fileUri('/workspace/src/deleted.ts')], + }); + harness.listeners.rename()?.({ + files: [{ + oldUri: fileUri('/workspace/src/old.ts'), + newUri: fileUri('/workspace/src/new.ts'), + }], + }); + + expect(harness.notify.mock.calls).toEqual([ + [['/workspace/src/saved.ts']], + [['/workspace/src/created.ts']], + [['/workspace/src/deleted.ts']], + [['/workspace/src/old.ts', '/workspace/src/new.ts']], + ]); + expect(context.subscriptions).toHaveLength(6); + }); + + it('shows queued, updating, and failed cache state in the VS Code status bar', () => { + const harness = createHarness(); + + registerWorkspaceCacheUpdates( + { subscriptions: [] }, + { updateWorkspaceFiles: vi.fn(async () => undefined) }, + harness.dependencies, + ); + + const report = (status: WorkspaceCacheUpdateStatus): void => { + harness.schedulerOptions()?.onStatus(status); + }; + report({ + state: 'queued', + fileCount: 2, + detail: '2 files queued.', + }); + expect(harness.statusBarItem.text).toBe('$(clock) CodeGraphy: 2 changes queued'); + expect(harness.statusBarItem.tooltip).toBe('2 files queued.'); + expect(harness.statusBarItem.show).toHaveBeenCalledOnce(); + + report({ + state: 'updating', + fileCount: 2, + detail: 'Updating 2 files.', + }); + expect(harness.statusBarItem.text).toBe('$(sync~spin) CodeGraphy: Updating 2 files'); + + report({ + state: 'error', + fileCount: 2, + detail: 'Graph Cache update failed.', + }); + expect(harness.statusBarItem.text).toBe('$(error) CodeGraphy: Cache update failed'); + + report({ + state: 'idle', + fileCount: 0, + detail: 'Graph Cache is current.', + }); + expect(harness.statusBarItem.hide).toHaveBeenCalledOnce(); + }); +}); From d66149bdb7f9d7303629713ab0a68a48ac5e19ac Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 30 Jul 2026 09:46:56 -0700 Subject: [PATCH 2/3] docs(extension): define native cache update lifecycle --- .changeset/calm-caches-update.md | 5 +++ CONTEXT.md | 2 +- packages/core/README.md | 2 +- packages/extension/docs/boundaries.md | 2 +- .../extension/docs/graph-cache-lifecycle.md | 18 +++++----- .../extension/graphView/analysis/request.ts | 4 +++ .../graphView/provider/analysis/methods.ts | 25 +++++++++++-- .../graphView/provider/analysis/state.ts | 1 + .../graphView/provider/wiring/publicApi.ts | 10 ++++-- .../workspaceFiles/cacheUpdates/model.ts | 4 +-- .../workspaceFiles/cacheUpdates/register.ts | 13 ++++--- .../graphView/analysis/request.test.ts | 17 +++++++++ .../provider/analysis/methods.test.ts | 35 +++++++++++++++++++ .../workspaceFiles/cacheUpdates/model.test.ts | 2 +- .../cacheUpdates/register.test.ts | 23 ++++++++++++ 15 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 .changeset/calm-caches-update.md diff --git a/.changeset/calm-caches-update.md b/.changeset/calm-caches-update.md new file mode 100644 index 0000000000..6af255cea7 --- /dev/null +++ b/.changeset/calm-caches-update.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": minor +--- + +Keep an existing Graph Cache current when files are saved, created, deleted, or renamed in VS Code. diff --git a/CONTEXT.md b/CONTEXT.md index 8d2f3b5a64..159d146a2d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -97,7 +97,7 @@ Interaction rules: Indexing runs File Discovery, Tree-sitter Analysis, Plugin Analysis, and Graph Projection. JavaScript-family reexports are explicit Relationships; renamed exports are Alias Symbol Nodes, so calls can resolve through barrels to implementation Symbols across full and incremental Indexing. The Graph Cache stores unscoped analysis facts so Graph Scope can hide data without deleting it. Active Filters and Git ignored state exclude files from fresh analysis and the file budget; facts cached while those files were eligible remain reusable but stay out of the current graph. Expensive facts such as Symbol or plugin-owned tiers can load when their scope needs them and remain cached for reuse. -The VS Code extension runs Indexing only after an explicit Index or Re-index Workspace action. Saving, creating, deleting, or renaming a workspace file does not process source files or change the cached Relationship Graph. Opening the Graph View reads the last Graph Cache without warming analysis or updating stale inputs in the background. The Graph View keeps the current graph visible during an explicit Re-index and uses graph-local progress. +The VS Code extension creates the first Graph Cache only after an explicit Index Workspace action. After that cache exists, native VS Code save, create, delete, and rename events maintain it through targeted incremental Indexing. The Extension batches changed paths for 500 ms with a two-second maximum batch age and processes one batch at a time. It does not start a Core watcher, poll the workspace, or process source files during activation. Opening the Graph View reads the last Graph Cache without warming analysis. The Graph View keeps the current graph visible during an explicit Re-index and uses graph-local progress. The separate `codegraphy watch` CLI command is an explicit foreground workflow for sessions that need a continuously current Graph Cache. It subscribes before initial synchronization, batches workspace changes for 500 ms with a two-second maximum batch age, preserves arrivals during active work, skips cache artifacts and paths excluded by active Filters, and flushes pending changes during shutdown. Graph Cache writers coordinate through operation-scoped exclusive transactions in a separate SQLite coordinator, which releases ownership automatically when a process terminates; no long-lived watcher ownership or heartbeat is required. Full and incremental updates acquire writer ownership before their final discovery and source verification and retain it through persistence, retrying when analyzed inputs were superseded. If an incremental write finds a corrupt Graph Cache, it rebuilds the complete database from current in-memory analysis under that same ownership instead of replaying a partial patch. diff --git a/packages/core/README.md b/packages/core/README.md index 855da38ddc..0bdb73f3e1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -81,7 +81,7 @@ Run `codegraphy --help` for the command list and `codegraphy --help` f - Target Query: inspect one exact File or Symbol with prioritized declarations and bounded Relationships. - Graph Query: list scoped Nodes and Edges, then use complete cached types by default for exact targeted relationships and bounded paths unless an invocation explicitly projects Node or Edge Types. -The core package exposes `indexCodeGraphyWorkspace` for one-shot Indexing and composes `createCodeGraphyWorkspaceCacheUpdater` with `subscribeCodeGraphyWorkspaceChanges` for the explicit foreground CLI watcher. The VS Code Extension uses Core only after an explicit Index or Re-index Workspace action; it does not subscribe to source-file changes. +The core package exposes `indexCodeGraphyWorkspace` for one-shot Indexing and composes `createCodeGraphyWorkspaceCacheUpdater` with `subscribeCodeGraphyWorkspaceChanges` for the explicit foreground CLI watcher. The VS Code Extension creates the first cache only after an explicit Index Workspace action. It then maps native VS Code file events to Core targeted refresh APIs through its Extension-host plugin pipeline; it does not start the Core watcher. ## Built-In Language Coverage diff --git a/packages/extension/docs/boundaries.md b/packages/extension/docs/boundaries.md index 833b156fba..eb477e628f 100644 --- a/packages/extension/docs/boundaries.md +++ b/packages/extension/docs/boundaries.md @@ -22,7 +22,7 @@ The extension source tree is intentionally feature-first inside each runtime bou - `src/extension/graphView/` and `src/extension/graphViewProvider.ts` own VS Code host orchestration for the Graph View. - `src/extension/pipeline/` owns extension-facing adapters over `@codegraphy-dev/core` pipeline APIs and extension-only orchestration. - `src/extension/repoSettings/` owns repo-local settings, freshness decisions, and Graph Cache trust state. -- `src/extension/workspaceFiles/` owns workspace file watching and refresh scheduling. +- `src/extension/workspaceFiles/` owns native VS Code file-event adapters and bounded refresh scheduling. - `@codegraphy-dev/core` owns File Discovery, Tree-sitter Analysis, headless plugin analysis, cache reads/writes, Graph Projection inputs, and agent-readable Graph Query. - `@codegraphy-dev/graph-renderer` owns WebGPU drawing and deterministic WebAssembly physics/layout. The extension owns how users configure and interact with it, including settings, persistence, plugins, selection, hover, picking, and context menus. - `src/shared/visibleGraph/` owns the host/webview shared projection that turns scoped, filtered, and searched graph data into the Visible Graph. diff --git a/packages/extension/docs/graph-cache-lifecycle.md b/packages/extension/docs/graph-cache-lifecycle.md index 5f9408ec28..bdbdf41d43 100644 --- a/packages/extension/docs/graph-cache-lifecycle.md +++ b/packages/extension/docs/graph-cache-lifecycle.md @@ -1,6 +1,6 @@ # Graph Cache Lifecycle -The VS Code extension changes workspace source facts only after an explicit **Index Workspace** or **Re-index Workspace** action. This keeps repository analysis off the shared extension host until the user requests it. +The VS Code extension creates workspace source facts only after an explicit **Index Workspace** action. After the first Graph Cache exists, native VS Code file events keep it current through targeted updates. ## Startup @@ -11,21 +11,23 @@ flowchart TD A["Graph View opens"] --> B{"Readable Graph Cache exists?"} B -->|"yes"| C["Render cached Relationship Graph"] B -->|"no"| D["Render the unindexed workspace state"] - C --> E["Wait for an explicit Re-index Workspace action"] + C --> E["Wait for VS Code file events or an explicit Re-index"] D --> F["Wait for an explicit Index Workspace action"] - E --> G["Run Indexing and replace the graph"] + E --> G["Update the Graph Cache and replace the graph"] F --> G ``` -A stale Graph Cache remains visible and useful. Freshness can tell the user that cached facts differ from the workspace, but it does not authorize background Indexing. +A stale Graph Cache remains visible and useful. Opening the Graph View does not authorize source analysis. A later native file event can update the existing cache. ## Workspace changes -Saving, creating, changing, deleting, or renaming files does not process source files or alter the cached Relationship Graph. Settings and display actions may re-project already indexed facts; they must not analyze changed source files. +After the first Graph Cache exists, the Extension responds to native VS Code save, create, delete, and rename events. It deduplicates paths, waits 500 ms after the latest event, and starts a batch after at most two seconds. It processes one batch at a time and retains events that arrive during active work. -Users choose **Re-index Workspace** when they want the Extension to rediscover files, run built-in and Extension-host plugin analysis, project the complete Relationship Graph, and replace the Graph Cache. +Each batch uses the targeted Core refresh path through the existing Extension plugin host. A source-file event refreshes only the affected files. A `.gitignore` or `.codegraphy/settings.json` event can refresh discovery metadata. Generated `.codegraphy` cache files do not trigger another update. -The separate foreground `codegraphy watch` command belongs to the Core CLI. It can maintain the same workspace cache during an explicit terminal session, but the Extension does not launch or own that process. +Users can still choose **Re-index Workspace** to rediscover and analyze the complete workspace. A queued file batch waits for an active Re-index to finish before it runs. + +The separate foreground `codegraphy watch` command belongs to the Core CLI. The Extension does not launch that process, subscribe through Core watch mode, or poll the workspace. ## Progress UI @@ -36,4 +38,4 @@ The whole-view loading state is only for the first graph payload. During an expl - disable only actions that cannot run safely during Indexing; - replace the graph payload when the new data is ready. -This explicit lifecycle prevents source analysis from competing with editor work or changing the graph before the user requests it. +The Extension status bar reports queued, updating, and failed cache updates. An idle Extension does not run source analysis or retain update timers. diff --git a/packages/extension/src/extension/graphView/analysis/request.ts b/packages/extension/src/extension/graphView/analysis/request.ts index c0a54dc488..3f463dffa6 100644 --- a/packages/extension/src/extension/graphView/analysis/request.ts +++ b/packages/extension/src/extension/graphView/analysis/request.ts @@ -3,6 +3,7 @@ import type { DiagnosticEventInput } from '@codegraphy-dev/core'; export interface GraphViewAnalysisRequestState { analysisController: AbortController | undefined; analysisRequestId: number; + propagateErrors?: boolean; } export interface GraphViewAnalysisRequestHandlers { @@ -74,6 +75,9 @@ export async function runGraphViewAnalysisRequest( }, }); handlers.logError('[CodeGraphy] Analysis failed:', error); + if (state.propagateErrors) { + throw error; + } } } finally { if (state.analysisController === controller) { diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 3987226a4b..587da2dce4 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -69,7 +69,10 @@ export interface GraphViewProviderAnalysisMethodsSource { export interface GraphViewProviderAnalysisMethods { _loadAndSendData(): Promise; _indexAndSendData(): Promise; - _updateChangedFilesAndSendData(filePaths: readonly string[]): Promise; + _updateChangedFilesAndSendData( + filePaths: readonly string[], + signal?: AbortSignal, + ): Promise; _refreshAndSendData(): Promise; _doLoadAndSendData(signal: AbortSignal, requestId: number): Promise; _markWorkspaceReady(graph: IGraphData, disabledPlugins?: ReadonlySet): void; @@ -171,9 +174,25 @@ export function createGraphViewProviderAnalysisMethods( await _loadAndSendData(); }, _indexAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_indexAndSendData), - _updateChangedFilesAndSendData: async filePaths => { + _updateChangedFilesAndSendData: async (filePaths, signal) => { await fullIndexAnalysis.waitForFullIndexAnalysis(); - await _updateChangedFilesAndSendData(filePaths); + if (signal?.aborted) { + return; + } + + const abortUpdate = (): void => { + source._analysisController?.abort(); + }; + signal?.addEventListener('abort', abortUpdate, { once: true }); + try { + const update = _updateChangedFilesAndSendData(filePaths); + if (signal?.aborted) { + abortUpdate(); + } + await update; + } finally { + signal?.removeEventListener('abort', abortUpdate); + } }, _refreshAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_refreshAndSendData), _doLoadAndSendData, diff --git a/packages/extension/src/extension/graphView/provider/analysis/state.ts b/packages/extension/src/extension/graphView/provider/analysis/state.ts index 1258a29c8a..5aff597943 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/state.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/state.ts @@ -48,6 +48,7 @@ export function createGraphViewProviderAnalysisState( return source._installedPluginActivationPromise; }, mode, + propagateErrors: mode === 'incremental', ...(changedFilePaths ? { changedFilePaths } : {}), get filterPatterns() { return source._filterPatterns; diff --git a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts index 81a2d33310..6568e53f46 100644 --- a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts +++ b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts @@ -29,7 +29,10 @@ interface GraphViewProviderPublicMethodsOwner { export interface GraphViewProviderPublicMethods { refresh: () => Promise; - updateWorkspaceFiles: (filePaths: readonly string[]) => Promise; + updateWorkspaceFiles: ( + filePaths: readonly string[], + signal?: AbortSignal, + ) => Promise; refreshIndex: () => Promise; hydrateGraphScope: () => Promise; hydratePluginGraphScope: (pluginIds: readonly string[]) => Promise; @@ -84,8 +87,9 @@ export function assignGraphViewProviderPublicMethods( target: GraphViewProviderPublicMethodsTarget, ): void { target.refresh = () => target._methodContainers.refresh.refresh(); - target.updateWorkspaceFiles = filePaths => - target._methodContainers.analysis._updateChangedFilesAndSendData(filePaths); + target.updateWorkspaceFiles = (filePaths, signal) => signal + ? target._methodContainers.analysis._updateChangedFilesAndSendData(filePaths, signal) + : target._methodContainers.analysis._updateChangedFilesAndSendData(filePaths); target.refreshIndex = () => target._methodContainers.refresh.refreshIndex(); target.hydrateGraphScope = () => target._methodContainers.refresh.hydrateGraphScope(); target.hydratePluginGraphScope = pluginIds => diff --git a/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts b/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts index 98801ec92f..aa269be029 100644 --- a/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts +++ b/packages/extension/src/extension/workspaceFiles/cacheUpdates/model.ts @@ -154,8 +154,8 @@ function createQueuedStatus(fileCount: number): WorkspaceCacheUpdateStatus { state: 'queued', fileCount, detail: fileCount === 1 - ? '1 saved workspace file is queued for Graph Cache update.' - : `${fileCount} saved workspace files are queued for Graph Cache update.`, + ? '1 workspace file change is queued for Graph Cache update.' + : `${fileCount} workspace file changes are queued for Graph Cache update.`, }; } diff --git a/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts b/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts index d15a65e658..003c391f7f 100644 --- a/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts +++ b/packages/extension/src/extension/workspaceFiles/cacheUpdates/register.ts @@ -1,4 +1,5 @@ -import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; +import { existsSync } from 'node:fs'; +import { getGraphCachePath } from '@codegraphy-dev/core'; import * as vscode from 'vscode'; import { createWorkspaceCacheUpdateScheduler, @@ -32,7 +33,10 @@ interface WorkspaceCacheUpdateContext { } interface WorkspaceCacheUpdateProvider { - updateWorkspaceFiles(filePaths: readonly string[]): Promise; + updateWorkspaceFiles( + filePaths: readonly string[], + signal?: AbortSignal, + ): Promise; } export interface WorkspaceCacheUpdateRegistrationDependencies { @@ -64,8 +68,7 @@ const defaultDependencies: WorkspaceCacheUpdateRegistrationDependencies = { createScheduler: createWorkspaceCacheUpdateScheduler, createStatusBarItem: () => vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 20), - hasGraphCache: workspaceRoot => - readCodeGraphyWorkspaceStatus(workspaceRoot).hasGraphCache, + hasGraphCache: workspaceRoot => existsSync(getGraphCachePath(workspaceRoot)), onDidCreateFiles: listener => vscode.workspace.onDidCreateFiles(listener), onDidDeleteFiles: listener => vscode.workspace.onDidDeleteFiles(listener), onDidRenameFiles: listener => vscode.workspace.onDidRenameFiles(listener), @@ -91,7 +94,7 @@ export function registerWorkspaceCacheUpdates( onStatus: status => renderStatus(statusBarItem, status), update: async (filePaths, signal) => { if (!signal.aborted) { - await provider.updateWorkspaceFiles(filePaths); + await provider.updateWorkspaceFiles(filePaths, signal); } }, }); diff --git a/packages/extension/tests/extension/graphView/analysis/request.test.ts b/packages/extension/tests/extension/graphView/analysis/request.test.ts index c43893ce9c..cbed1b325a 100644 --- a/packages/extension/tests/extension/graphView/analysis/request.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/request.test.ts @@ -97,6 +97,23 @@ describe('graph view analysis request', () => { expect(state.analysisController).toBeUndefined(); }); + it('reports unexpected failures to callers that own visible status', async () => { + const state = createState({ propagateErrors: true }); + const error = new Error('cache write failed'); + const logError = vi.fn(); + + await expect(runGraphViewAnalysisRequest(state, { + executeAnalysis: vi.fn(() => Promise.reject(error)), + isAbortError: vi.fn(() => false), + logError, + updateAnalysisController: vi.fn(), + updateAnalysisRequestId: vi.fn(), + })).rejects.toBe(error); + + expect(logError).toHaveBeenCalledWith('[CodeGraphy] Analysis failed:', error); + expect(state.analysisController).toBeUndefined(); + }); + it('does not log abort failures and still clears the active request', async () => { const state = createState(); const error = new Error('cancelled'); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index d5fa58fd63..18abe5c828 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -263,6 +263,41 @@ describe('graphView/provider/analysis/methods', () => { ]); }); + it('cancels an active saved-file update when its caller is disposed', async () => { + const source = createSource(); + let analysisSignal: AbortSignal | undefined; + const runAnalysisRequest = vi.fn(async state => { + const controller = new AbortController(); + state.analysisController = controller; + source._analysisController = controller; + analysisSignal = controller.signal; + await new Promise(resolve => { + controller.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + const owner = new AbortController(); + + const update = methods._updateChangedFilesAndSendData( + ['/workspace/src/saved.ts'], + owner.signal, + ); + await Promise.resolve(); + + owner.abort(); + await update; + + expect(analysisSignal?.aborted).toBe(true); + }); + it('keeps webview-ready loading from interrupting an active first index', async () => { const source = createSource(); let finishIndex: (() => void) | undefined; diff --git a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts index b62ce00491..d558475a67 100644 --- a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/model.test.ts @@ -41,7 +41,7 @@ describe('workspaceFiles/cacheUpdates/model', () => { expect(statuses.at(-1)).toEqual({ state: 'queued', fileCount: 2, - detail: '2 saved workspace files are queued for Graph Cache update.', + detail: '2 workspace file changes are queued for Graph Cache update.', }); await vi.advanceTimersByTimeAsync(1); diff --git a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts index 019c080e37..69efe76946 100644 --- a/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/cacheUpdates/register.test.ts @@ -155,4 +155,27 @@ describe('workspaceFiles/cacheUpdates/register', () => { }); expect(harness.statusBarItem.hide).toHaveBeenCalledOnce(); }); + + it('passes scheduler cancellation to the graph update', async () => { + const harness = createHarness(); + const updateWorkspaceFiles = vi.fn(async () => undefined); + const controller = new AbortController(); + + registerWorkspaceCacheUpdates( + { subscriptions: [] }, + { updateWorkspaceFiles }, + harness.dependencies, + ); + + await harness.schedulerOptions()?.update( + ['/workspace/src/saved.ts'], + controller.signal, + vi.fn(), + ); + + expect(updateWorkspaceFiles).toHaveBeenCalledWith( + ['/workspace/src/saved.ts'], + controller.signal, + ); + }); }); From d215df953f02011f1b4dfa3b4bd9e0f257b471d6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 30 Jul 2026 09:54:43 -0700 Subject: [PATCH 3/3] test(extension): count cache update subscriptions --- packages/extension/tests/extension/extension.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/extension/tests/extension/extension.test.ts b/packages/extension/tests/extension/extension.test.ts index b77aba3678..1e9f621f24 100644 --- a/packages/extension/tests/extension/extension.test.ts +++ b/packages/extension/tests/extension/extension.test.ts @@ -67,8 +67,9 @@ describe('Extension', () => { // view provider (1) + config listener (1) + active editor listener (1) // + URI handler (1) + runtime bridge listener (1) + // + cache update file events (4) + scheduler (1) + status bar (1) // + 14 commands (open, openInEditor, fitView, zoomIn, zoomOut, undo, redo, exportPng, exportSvg, exportJpeg, exportJson, exportMarkdown, clearCache, toggleDepthMode) - expect(mockContext.subscriptions.length).toBe(19); + expect(mockContext.subscriptions.length).toBe(25); }); });