diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..dbcb1914bb 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -139,10 +139,15 @@ vi.mock("../services/mcp/McpServerManager", () => ({ }, })) -vi.mock("../services/code-index/manager", () => ({ - CodeIndexManager: { - getInstance: vi.fn().mockReturnValue(null), - }, +const codeIndexScope = { + init: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), +} + +vi.mock("../services/code-index/code-index-scope", () => ({ + CodeIndexScope: vi.fn().mockImplementation(function () { + return codeIndexScope + }), })) vi.mock("../services/mdm/MdmService", () => ({ @@ -459,6 +464,13 @@ describe("extension.ts", () => { vi.resetModules() }) + test("disposes the code index lifecycle service on deactivation", async () => { + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + await deactivate() + expect(codeIndexScope.dispose).toHaveBeenCalledTimes(1) + }) + test("still runs terminal cleanup when telemetry shutdown rejects", async () => { const { TelemetryService } = await import("@roo-code/telemetry") const { Terminal } = await import("../integrations/terminal/Terminal") diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..a6cd24f8d3 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,6 +1,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" +import type { CodeIndexScope } from "../../services/code-index/code-index-scope" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" @@ -67,12 +68,6 @@ vi.mock("../../core/config/importExport", () => ({ importSettingsWithFeedback: vi.fn(), })) -vi.mock("../../services/code-index/manager", () => ({ - CodeIndexManager: { - getInstance: vi.fn(), - }, -})) - vi.mock("../../services/mdm/MdmService", () => ({ MdmService: { getInstance: vi.fn(), @@ -412,7 +407,17 @@ describe("openClineInNewTab", () => { }) it("creates a webview panel with title 'Zoo Code'", async () => { - await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + // Only identity matters here: the mocked provider owns the consumer registration. + const codeIndexScope = {} as CodeIndexScope + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel, codeIndexScope }) + expect(ClineProvider).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + undefined, + codeIndexScope, + ) expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( "zoo-code.TabPanelProvider", diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..ea4396d6b1 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -10,11 +10,11 @@ import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" import { focusPanel } from "../utils/focusPanel" import { handleNewTask } from "./handleTask" -import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" import { t } from "../i18n" +import type { CodeIndexScope } from "../services/code-index/code-index-scope" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -60,6 +60,7 @@ export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel provider: ClineProvider + codeIndexScope?: CodeIndexScope } export const registerCommands = (options: RegisterCommandOptions) => { @@ -89,6 +90,7 @@ const getCommandsMap = ({ context, outputChannel, provider, + codeIndexScope, }: RegisterCommandOptions): Record, CommandCallback> => ({ activationCompleted: () => {}, plusButtonClicked: async () => { @@ -110,9 +112,9 @@ const getCommandsMap = ({ popoutButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("popout") - return openClineInNewTab({ context, outputChannel }) + return openClineInNewTab({ context, outputChannel, codeIndexScope }) }, - openInNewTab: () => openClineInNewTab({ context, outputChannel }), + openInNewTab: () => openClineInNewTab({ context, outputChannel, codeIndexScope }), settingsButtonClicked: () => { const visibleProvider = getVisibleProviderOrLog(outputChannel) @@ -221,13 +223,16 @@ const getCommandsMap = ({ }, }) -export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { +export const openClineInNewTab = async ({ + context, + outputChannel, + codeIndexScope, +}: Omit) => { // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts const contextProxy = await ContextProxy.getInstance(context) - const codeIndexManager = CodeIndexManager.getInstance(context) // Get the existing MDM service instance to ensure consistent policy enforcement let mdmService: MdmService | undefined @@ -238,7 +243,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit editor.viewColumn || 0)) // Check if there are any visible text editors, otherwise open a new group diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 93f4a52846..b64f6dfd16 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -8,7 +8,6 @@ import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" import { McpHub } from "../../services/mcp/McpHub" -import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" import type { SystemPromptSettings } from "./types" @@ -79,8 +78,6 @@ async function generatePrompt( } const shouldIncludeMcp = hasMcpGroup && hasMcpServers - const codeIndexManager = CodeIndexManager.getInstance(context, cwd) - // Tool calling is native-only. const effectiveProtocol = "native" diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7418920cb1..6b2b82a6ef 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -130,7 +130,8 @@ vi.mock("p-wait-for", () => ({ default: vi.fn().mockImplementation(async () => Promise.resolve()), })) -vi.mock("vscode", () => { +vi.mock("vscode", async () => { + const { makeUri } = await import("../../../test-utils/vscode") const mockDisposable = { dispose: vi.fn() } const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } @@ -139,6 +140,7 @@ vi.mock("vscode", () => { const mockTabGroup = { tabs: [mockTab] } return { + Uri: { file: vi.fn((filePath: string) => makeUri(filePath)) }, TabInputTextDiff: vi.fn(), CodeActionKind: { QuickFix: { value: "quickfix" }, diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..9c43efb631 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -96,8 +96,10 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const mcpHub = provider.getMcpHub() // Get CodeIndexManager for feature checking. - const { CodeIndexManager } = await import("../../services/code-index/manager") - const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd) + const codeIndexManager = provider.codeIndexScope?.workspaceRegistry.getScope( + provider.context, + cwd, + )?.codeIndexManager // Build settings object for tool filtering. const filterSettings = { diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd..6bfa8235ad 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -2,7 +2,6 @@ import * as vscode from "vscode" import path from "path" import { Task } from "../task/Task" -import { CodeIndexManager } from "../../services/code-index/manager" import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { VectorStoreSearchResult } from "../../services/code-index/interfaces" @@ -57,7 +56,9 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { throw new Error("Extension context is not available.") } - const manager = CodeIndexManager.getInstance(context) + const manager = task.providerRef + .deref() + ?.codeIndexScope?.workspaceRegistry.getScope(context)?.codeIndexManager if (!manager) { throw new Error("CodeIndexManager is not available.") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87a899344c..cfc4e1c45f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -85,8 +85,9 @@ import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" -import { CodeIndexManager } from "../../services/code-index/manager" -import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" +import type { CodeIndexWorkspaceScope } from "../../services/code-index/code-index-workspace-scope" +import type { CodeIndexScope } from "../../services/code-index/code-index-scope" +import type { CodeIndexStatus, CodeIndexStatusConsumer } from "../../services/code-index/interfaces/status-consumer" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -175,7 +176,7 @@ type GetStateOptions = { export class ClineProvider extends EventEmitter - implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike + implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike, CodeIndexStatusConsumer { // Used in package.json as the view's id. This value cannot be changed due // to how VSCode caches views based on their id, and updating the id would @@ -199,8 +200,8 @@ export class ClineProvider private taskScheduler = new TaskScheduler() private delegationTransitionLocks?: Map> private cancelledDelegationChildIds = new Set() - private codeIndexStatusSubscription?: vscode.Disposable - private codeIndexManager?: CodeIndexManager + private readonly codeIndexWebviewReadyEmitter = new vscode.EventEmitter() + public readonly onDidCodeIndexWebviewReady = this.codeIndexWebviewReadyEmitter.event private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager @@ -319,6 +320,7 @@ export class ClineProvider private readonly renderContext: "sidebar" | "editor" = "sidebar", public readonly contextProxy: ContextProxy, mdmService?: MdmService, + public readonly codeIndexScope?: CodeIndexScope, ) { super() this.currentWorkspacePath = getWorkspacePath() @@ -326,6 +328,10 @@ export class ClineProvider ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) + this.disposables.push(this.codeIndexWebviewReadyEmitter) + if (codeIndexScope) { + this.disposables.push(codeIndexScope.statusManager.addConsumer(this)) + } ClineProvider.activeInstances.add(this) @@ -1070,17 +1076,6 @@ export class ClineProvider // and executes code based on the message that is received. this.setWebviewMessageListener(webviewView.webview) - // Initialize code index status subscription for the current workspace. - this.updateCodeIndexStatusSubscription() - - // Listen for active editor changes to update code index status for the - // current workspace. - const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => { - // Update subscription when workspace might have changed. - this.updateCodeIndexStatusSubscription() - }) - this.webviewDisposables.push(activeEditorSubscription) - // Listen for when the panel becomes visible. // https://github.com/microsoft/vscode-discussions/discussions/840 if ("onDidChangeViewState" in webviewView) { @@ -1118,8 +1113,6 @@ export class ClineProvider } else { this.log("Clearing webview resources for sidebar view") this.clearWebviewResources() - // Reset current workspace manager reference when view is disposed - this.codeIndexManager = undefined } }, null, @@ -3285,58 +3278,18 @@ export class ClineProvider } /** - * Gets the CodeIndexManager for the current active workspace - * @returns CodeIndexManager instance for the current workspace or the default one + * Gets the workspace scope for the current active workspace or the default one. */ - public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManager.getInstance(this.context) + public getCurrentWorkspaceCodeIndexScope(): CodeIndexWorkspaceScope | undefined { + return this.codeIndexScope?.workspaceRegistry.getScope(this.context) } - /** - * Updates the code index status subscription to listen to the current workspace manager - */ - private updateCodeIndexStatusSubscription(): void { - // Get the current workspace manager - const currentManager = this.getCurrentWorkspaceCodeIndexManager() - - // If the manager hasn't changed, no need to update subscription - if (currentManager === this.codeIndexManager) { - return - } - - // Dispose the old subscription if it exists - if (this.codeIndexStatusSubscription) { - this.codeIndexStatusSubscription.dispose() - this.codeIndexStatusSubscription = undefined - } - - // Update the current workspace manager reference - this.codeIndexManager = currentManager - - // Subscribe to the new manager's progress updates if it exists - if (currentManager) { - this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { - // Only send updates if this manager is still the current one - if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { - // Get the full status from the manager to ensure we have all fields correctly formatted - const fullStatus = currentManager.getCurrentStatus() - void this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: fullStatus, - }) - } - }) - - if (this.view) { - this.webviewDisposables.push(this.codeIndexStatusSubscription) - } + public notifyCodeIndexWebviewReady(): void { + this.codeIndexWebviewReadyEmitter.fire() + } - // Send initial status for the current workspace - void this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentManager.getCurrentStatus(), - }) - } + public async postCodeIndexStatus(status: CodeIndexStatus): Promise { + await this.postMessageToWebview({ type: "indexingStatusUpdate", values: status }) } /** diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 99d254cb9a..209f26047c 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" // npx vitest core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts import * as vscode from "vscode" @@ -40,6 +41,9 @@ vi.mock("delay", () => { }) vi.mock("vscode", () => ({ + EventEmitter: vi.fn().mockImplementation(function () { + return makeEventEmitter() + }), ExtensionContext: vi.fn(), OutputChannel: vi.fn(), WebviewView: vi.fn(), diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index f2832b2468..635169ee2a 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" import * as vscode from "vscode" @@ -48,10 +49,7 @@ vi.mock("vscode", () => { language: "en", }, EventEmitter: vi.fn().mockImplementation(function () { - return { - event: vi.fn(), - fire: vi.fn(), - } + return makeEventEmitter() }), Disposable: { from: vi.fn(), diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index f42eb401f2..a1c30db820 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" // npx vitest run core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts import * as vscode from "vscode" @@ -7,6 +8,9 @@ import { ContextProxy } from "../../config/ContextProxy" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ + EventEmitter: vi.fn().mockImplementation(function () { + return makeEventEmitter() + }), ExtensionContext: vi.fn(), OutputChannel: vi.fn(), WebviewView: vi.fn(), diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..a35ba9619f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -27,6 +27,8 @@ import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { makeEventEmitter } from "../../../test-utils/vscode" +import type { CodeIndexScope } from "../../../services/code-index/code-index-scope" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -567,6 +569,39 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + test.each(["sidebar", "editor"] as const)( + "registers the %s consumer and detaches it on disposal", + async (renderContext) => { + const registration = { dispose: vi.fn() } + const addConsumer = vi.fn(() => registration) + // This test exercises only provider registration, not feature initialization. + const scope = { statusManager: { addConsumer } } as unknown as CodeIndexScope + const consumer = new ClineProvider( + mockContext, + mockOutputChannel, + renderContext, + new ContextProxy(mockContext), + undefined, + scope, + ) + expect(addConsumer).toHaveBeenCalledExactlyOnceWith(consumer) + await consumer.dispose() + expect(registration.dispose).toHaveBeenCalledOnce() + }, + ) + + test("signals webview readiness without installing active-editor listeners", () => { + const ready = makeEventEmitter() + const listener = vi.fn() + ready.event(listener) + vi.spyOn(provider["codeIndexWebviewReadyEmitter"], "fire").mockImplementation(() => ready.fire()) + provider.notifyCodeIndexWebviewReady() + provider.notifyCodeIndexWebviewReady() + expect(listener).toHaveBeenCalledTimes(2) + expect(vscode.window.onDidChangeActiveTextEditor).not.toHaveBeenCalled() + ready.dispose() + }) + test("loads full model details when preparing an LM Studio task", async () => { await provider.performPreparationTasks({ apiConfiguration: { @@ -2936,7 +2971,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { postMessageToWebview: vi.fn().mockResolvedValue(true), postStateToWebview: vi.fn().mockResolvedValue(undefined), getCurrentTask: vi.fn(), - getCurrentWorkspaceCodeIndexManager: vi.fn(), + getCurrentWorkspaceCodeIndexScope: vi.fn(), getMcpHub: vi.fn().mockReturnValue({ getMcpSettingsFilePath: vi.fn().mockResolvedValue("/test/mcp.json"), }), @@ -2979,6 +3014,8 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { overrides, ) + const createIndexScope = (codeIndexManager: ReturnType) => ({ codeIndexManager }) + beforeEach(() => { vi.clearAllMocks() }) @@ -2992,7 +3029,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockReturnValue(indexingPromise), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createIndexScope(manager)), }) await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() @@ -3149,13 +3186,13 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { it("covers changed indexing status, secret, and missing-manager responses", async () => { const manager = createIndexManager() - const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) - const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + const getScope = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(createIndexScope(manager)) + const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: getScope }) await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) await webviewMessageHandler(provider, { type: "requestCodeIndexSecretStatus" }) - getManager.mockReturnValueOnce(undefined) + getScope.mockReturnValueOnce(undefined) await webviewMessageHandler(provider, { type: "startIndexing" }) expect(provider.postMessageToWebview).toHaveBeenCalledWith( @@ -3173,7 +3210,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { .mockRejectedValueOnce(new Error("second failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createIndexScope(manager)), }) await webviewMessageHandler(provider, { type: "startIndexing" }) @@ -3189,7 +3226,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockRejectedValue(new Error("toggle failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createIndexScope(manager)), }) await webviewMessageHandler(provider, { type: "stopIndexing" }) @@ -3204,7 +3241,9 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { }) it("catches auto-enabled indexing failures and posts the resulting status", async () => { - const { CodeIndexManager } = await import("../../../services/code-index/manager") + const { CodeIndexWorkspaceScopeRegistry } = + await import("../../../services/code-index/code-index-workspace-scope-registry") + const codeIndexWorkspaceScopeRegistry = new CodeIndexWorkspaceScopeRegistry() let workspaceEnabled = false const manager = createIndexManager({ setAutoEnableDefault: vi.fn().mockImplementation(async () => { @@ -3213,11 +3252,14 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockRejectedValue(new Error("auto-enable failure")), }) Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => workspaceEnabled }) - const getAllInstances = vi - .spyOn(CodeIndexManager, "getAllInstances") - .mockReturnValue([manager] as unknown as ReturnType) + const getAllScopes = vi + .spyOn(codeIndexWorkspaceScopeRegistry, "getAllScopes") + .mockReturnValue([{ codeIndexManager: manager }] as unknown as ReturnType< + typeof codeIndexWorkspaceScopeRegistry.getAllScopes + >) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createIndexScope(manager)), + codeIndexScope: { workspaceRegistry: codeIndexWorkspaceScopeRegistry }, }) try { @@ -3230,14 +3272,14 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { expect.objectContaining({ type: "indexingStatusUpdate" }), ) } finally { - getAllInstances.mockRestore() + getAllScopes.mockRestore() } }) it("covers changed clear-index response paths", async () => { const manager = createIndexManager() - const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) - const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + const getScope = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(createIndexScope(manager)) + const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: getScope }) await webviewMessageHandler(provider, { type: "clearIndexData" }) await webviewMessageHandler(provider, { type: "clearIndexData" }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index fedfa13030..36cd35221a 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" // npx vitest core/webview/__tests__/ClineProvider.sticky-mode.spec.ts import * as vscode from "vscode" @@ -9,6 +10,9 @@ import type { HistoryItem, ProviderName } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ + EventEmitter: vi.fn().mockImplementation(function () { + return makeEventEmitter() + }), ExtensionContext: vi.fn(), OutputChannel: vi.fn(), WebviewView: vi.fn(), diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 7d8493fba3..fa195a9a2c 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" // npx vitest run core/webview/__tests__/ClineProvider.sticky-profile.spec.ts import * as vscode from "vscode" @@ -8,6 +9,9 @@ import type { HistoryItem } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" vi.mock("vscode", () => ({ + EventEmitter: vi.fn().mockImplementation(function () { + return makeEventEmitter() + }), ExtensionContext: vi.fn(), OutputChannel: vi.fn(), WebviewView: vi.fn(), diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 0365283222..19167702b6 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -1,3 +1,4 @@ +import { makeEventEmitter } from "../../../test-utils/vscode" // pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.taskHistory.spec.ts import * as vscode from "vscode" @@ -99,6 +100,9 @@ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ })) vi.mock("vscode", () => ({ + EventEmitter: vi.fn().mockImplementation(function () { + return makeEventEmitter() + }), ExtensionContext: vi.fn(), OutputChannel: vi.fn(), WebviewView: vi.fn(), diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..78a0b9cf42 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -97,6 +97,7 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf // Mock ClineProvider const mockClineProvider = { + notifyCodeIndexWebviewReady: vi.fn(), getState: vi.fn(), postMessageToWebview: vi.fn(), customModesManager: { @@ -2265,6 +2266,7 @@ describe("webviewMessageHandler - telemetrySetting", () => { providerForLaunch.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "unset" }) await expect(webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })).resolves.not.toThrow() + expect(mockClineProvider.notifyCodeIndexWebviewReady).toHaveBeenCalledOnce() // The queued telemetry update is fire-and-forget from the handler's own point of // view -- flush a microtask turn so its .then() callback runs before asserting. diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..0467d9beb5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -62,7 +62,6 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { CodeIndexManager } from "../../services/code-index/manager" import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" @@ -687,6 +686,7 @@ export const webviewMessageHandler = async ( ) provider.isViewLaunched = true + provider.notifyCodeIndexWebviewReady() break case "newTask": // Initializing new instance of Cline will make sure that any @@ -3078,7 +3078,7 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() // Then handle validation and initialization for the current workspace - const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexManager() + const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (currentCodeIndexManager) { // If embedder provider changed, perform proactive validation if (embedderProviderChanged) { @@ -3157,7 +3157,7 @@ export const webviewMessageHandler = async ( } case "requestIndexingStatus": { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { // No workspace open - send error status await provider.postMessageToWebview({ @@ -3221,7 +3221,7 @@ export const webviewMessageHandler = async ( } case "startIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { await provider.postMessageToWebview({ type: "indexingStatusUpdate", @@ -3262,12 +3262,12 @@ export const webviewMessageHandler = async ( } case "stopIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { provider.log("Cannot stop indexing: No workspace folder open") return } - manager.stopIndexing() + await manager.stopIndexing() await provider.postMessageToWebview({ type: "indexingStatusUpdate", values: manager.getCurrentStatus(), @@ -3279,7 +3279,7 @@ export const webviewMessageHandler = async ( } case "toggleWorkspaceIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { provider.log("Cannot toggle workspace indexing: No workspace folder open") return @@ -3290,7 +3290,7 @@ export const webviewMessageHandler = async ( await manager.initialize(provider.contextProxy) void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) } else if (!enabled) { - manager.stopIndexing() + await manager.stopIndexing() } await provider.postMessageToWebview({ type: "indexingStatusUpdate", @@ -3305,21 +3305,24 @@ export const webviewMessageHandler = async ( } case "setAutoEnableDefault": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { provider.log("Cannot set auto-enable default: No workspace folder open") return } // Capture prior state for every manager before persisting the global change - const allManagers = CodeIndexManager.getAllInstances() - const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) + const allScopes = provider.codeIndexScope?.workspaceRegistry.getAllScopes() ?? [] + const priorStates = new Map( + allScopes.map((scope) => [scope, scope.codeIndexManager.isWorkspaceEnabled]), + ) await manager.setAutoEnableDefault(message.bool ?? true) // Apply stop/start to every affected manager - for (const m of allManagers) { - const wasEnabled = priorStates.get(m)! + for (const scope of allScopes) { + const m = scope.codeIndexManager + const wasEnabled = priorStates.get(scope) ?? false const isNowEnabled = m.isWorkspaceEnabled if (wasEnabled && !isNowEnabled) { - m.stopIndexing() + await m.stopIndexing() } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { await m.initialize(provider.contextProxy) void m.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) @@ -3338,7 +3341,7 @@ export const webviewMessageHandler = async ( } case "clearIndexData": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const manager = provider.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager if (!manager) { provider.log("Cannot clear index data: No workspace folder open") await provider.postMessageToWebview({ diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 381cf0c1e0..04e5a25b7c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1301,7 +1301,7 @@ }, "services/code-index/__tests__/manager.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 89 + "count": 81 } }, "services/code-index/__tests__/orchestrator.spec.ts": { diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..227beaf9aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,7 +34,7 @@ import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" -import { CodeIndexManager } from "./services/code-index/manager" +import { CodeIndexScope } from "./services/code-index/code-index-scope" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -62,6 +62,7 @@ import { initZooCodeAuth } from "./services/zoo-code-auth" let outputChannel: vscode.OutputChannel let extensionContext: vscode.ExtensionContext let cloudService: CloudService | undefined +let codeIndexScope: CodeIndexScope | undefined let settingsUpdatedHandler: (() => void) | undefined @@ -195,31 +196,12 @@ export async function activate(context: vscode.ExtensionContext) { }), ) - // Initialize code index managers for all workspace folders. - const codeIndexManagers: CodeIndexManager[] = [] - - if (vscode.workspace.workspaceFolders) { - for (const folder of vscode.workspace.workspaceFolders) { - const manager = CodeIndexManager.getInstance(context, folder.uri.fsPath) - - if (manager) { - codeIndexManagers.push(manager) - - // Initialize in background; do not block extension activation - void manager.initialize(contextProxy).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - outputChannel.appendLine( - `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for ${folder.uri.fsPath}: ${message}`, - ) - }) - - context.subscriptions.push(manager) - } - } - } - // Initialize the provider *before* the Roo Code Cloud service. - const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) + codeIndexScope = new CodeIndexScope(context, contextProxy, outputChannel) + const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService, codeIndexScope) + // Initialize in background; do not block extension activation. + void codeIndexScope.init() + context.subscriptions.push(codeIndexScope) // Initialize Roo Code Cloud service. settingsUpdatedHandler = () => { @@ -271,7 +253,7 @@ export async function activate(context: vscode.ExtensionContext) { ) } - registerCommands({ context, outputChannel, provider }) + registerCommands({ context, outputChannel, provider, codeIndexScope }) /** * We use the text document content provider API to show the left side for diff @@ -384,6 +366,9 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { outputChannel.appendLine(`${Package.name} extension deactivated`) + await codeIndexScope?.dispose() + codeIndexScope = undefined + if (cloudService && CloudService.hasInstance()) { try { if (settingsUpdatedHandler) { diff --git a/src/extension/api.ts b/src/extension/api.ts index 316e7a6c9d..1e0b2888d5 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -187,7 +187,11 @@ export class API extends EventEmitter implements RooCodeAPI { await vscode.commands.executeCommand("workbench.action.files.revert") await vscode.commands.executeCommand("workbench.action.closeAllEditors") - provider = await openClineInNewTab({ context: this.context, outputChannel: this.outputChannel }) + provider = await openClineInNewTab({ + context: this.context, + outputChannel: this.outputChannel, + codeIndexScope: this.sidebarProvider.codeIndexScope, + }) this.registerListeners(provider) } else { await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) diff --git a/src/services/code-index/__tests__/code-index-scope.spec.ts b/src/services/code-index/__tests__/code-index-scope.spec.ts new file mode 100644 index 0000000000..0068b92468 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-scope.spec.ts @@ -0,0 +1,122 @@ +import * as vscode from "vscode" + +import type { ContextProxy } from "../../../core/config/ContextProxy" +import { makeExtensionContext } from "../../../test-utils/vscode" +import { CodeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" +import { CodeIndexDisposalError } from "../errors/code-index-disposal-error" +import { CodeIndexScope } from "../code-index-scope" + +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [], + }, +})) + +vi.mock("../code-index-workspace-scope-registry", () => ({ + CodeIndexWorkspaceScopeRegistry: vi.fn().mockImplementation(function () { + return { getScope: vi.fn(), disposeAll: vi.fn() } + }), +})) +vi.mock("../code-index-status-manager", () => ({ + CodeIndexStatusManager: vi.fn().mockImplementation(function () { + return { init: vi.fn(), dispose: vi.fn() } + }), +})) + +describe("CodeIndexScope", () => { + const context = makeExtensionContext() + const contextProxy = {} as ContextProxy + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + let service: CodeIndexScope + let codeIndexWorkspaceScopeRegistry: CodeIndexWorkspaceScopeRegistry + const createService = () => service + + beforeEach(() => { + vi.clearAllMocks() + service = new CodeIndexScope(context, contextProxy, outputChannel) + codeIndexWorkspaceScopeRegistry = service.workspaceRegistry + vi.mocked(vscode.workspace).workspaceFolders = [] + }) + + it("initializes a scope for every workspace folder in the background", async () => { + const init = vi.fn().mockResolvedValue(undefined) + const folders = ["/workspace/one", "/workspace/two"].map((fsPath, index) => ({ + uri: { fsPath }, + name: `workspace-${index}`, + index, + })) as vscode.WorkspaceFolder[] + vi.mocked(vscode.workspace).workspaceFolders = folders + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue({ init } as never) + + await createService().init() + + expect(codeIndexWorkspaceScopeRegistry.getScope).toHaveBeenNthCalledWith(1, context, "/workspace/one") + expect(codeIndexWorkspaceScopeRegistry.getScope).toHaveBeenNthCalledWith(2, context, "/workspace/two") + expect(init).toHaveBeenCalledTimes(2) + expect(init).toHaveBeenCalledWith(contextProxy) + }) + + it("logs background initialization failures", async () => { + vi.mocked(vscode.workspace).workspaceFolders = [ + { uri: { fsPath: "/workspace/failing" }, name: "failing", index: 0 }, + ] as vscode.WorkspaceFolder[] + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue({ + init: vi.fn().mockRejectedValue(new Error("configuration failed")), + } as never) + + await createService().init() + + expect(outputChannel.appendLine).toHaveBeenCalledWith( + "[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for /workspace/failing: configuration failed", + ) + }) + + it("disposes status subscriptions before workspace resources", async () => { + const service = createService() + await service.init() + vi.mocked(codeIndexWorkspaceScopeRegistry.disposeAll).mockImplementation(async () => { + expect(service.statusManager.dispose).toHaveBeenCalledOnce() + }) + await service.dispose() + + expect(codeIndexWorkspaceScopeRegistry.disposeAll).toHaveBeenCalledTimes(1) + }) + + it("starts status subscriptions only after workspace initialization settles", async () => { + vi.mocked(vscode.workspace).workspaceFolders = [ + { uri: { fsPath: "/workspace" }, name: "workspace", index: 0 }, + ] as vscode.WorkspaceFolder[] + const init = vi.fn(async () => { + expect(service.statusManager.init).not.toHaveBeenCalled() + await Promise.resolve() + expect(service.statusManager.init).not.toHaveBeenCalled() + }) + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue({ init } as never) + await service.init() + expect(service.statusManager.init).toHaveBeenCalledOnce() + }) + + it("logs aggregate disposal failures", async () => { + vi.mocked(codeIndexWorkspaceScopeRegistry.disposeAll).mockImplementationOnce(() => { + throw new CodeIndexDisposalError([new Error("index cleanup failed")]) + }) + + await createService().dispose() + + expect(outputChannel.appendLine).toHaveBeenCalledWith( + "CodeIndexDisposalError: Failed to dispose code index managers (1 errors):\n1. index cleanup failed", + ) + }) + + it("labels unexpected disposal failures", async () => { + vi.mocked(codeIndexWorkspaceScopeRegistry.disposeAll).mockImplementationOnce(() => { + throw new Error("unexpected cleanup failure") + }) + + await createService().dispose() + + expect(outputChannel.appendLine).toHaveBeenCalledWith( + "Unexpected error while disposing code index managers: unexpected cleanup failure", + ) + }) +}) diff --git a/src/services/code-index/__tests__/code-index-status-manager.spec.ts b/src/services/code-index/__tests__/code-index-status-manager.spec.ts new file mode 100644 index 0000000000..fcecf40640 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-status-manager.spec.ts @@ -0,0 +1,148 @@ +import * as vscode from "vscode" + +import { makeEventEmitter, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexStatusManager } from "../code-index-status-manager" +import { CodeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" +import type { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" +import type { CodeIndexStatus } from "../interfaces/status-consumer" + +describe("CodeIndexStatusManager", () => { + const first = { uri: makeUri("/first"), name: "first", index: 0 } + const second = { uri: makeUri("/second"), name: "second", index: 1 } + let editorChanges: vscode.EventEmitter + let manager: CodeIndexStatusManager + let registry: { getExistingScope: ReturnType CodeIndexWorkspaceScope | undefined>> } + const output = { appendLine: vi.fn() } + + function workspace(message: string) { + const progress = makeEventEmitter() + const status: CodeIndexStatus = { + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: true, + systemStatus: "Standby", + message, + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + } + const codeIndexManager = { + onProgressUpdate: vi.fn(progress.event), + getCurrentStatus: vi.fn(() => status), + } + // The status manager reads only the workspace's progress/status port. + const scope = { codeIndexManager, isInitialized: true } as unknown as CodeIndexWorkspaceScope + return { scope, progress, codeIndexManager, status } + } + + function consumer() { + const ready = makeEventEmitter() + const port = { + onDidCodeIndexWebviewReady: ready.event, + postCodeIndexStatus: vi.fn().mockResolvedValue(undefined), + } + const registration = manager.addConsumer(port) + return { ready, port, registration } + } + + beforeEach(() => { + vi.clearAllMocks() + editorChanges = makeEventEmitter() + vi.spyOn(vscode.window, "onDidChangeActiveTextEditor").mockImplementation(editorChanges.event) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) + registry = { getExistingScope: vi.fn() } + const workspaceRegistry = new CodeIndexWorkspaceScopeRegistry() + vi.spyOn(workspaceRegistry, "getExistingScope").mockImplementation(registry.getExistingScope) + manager = new CodeIndexStatusManager(workspaceRegistry, output) + }) + + afterEach(() => manager.dispose()) + + it("waits for init, subscribes once and replays readiness to sidebar and late editor consumers", () => { + const a = workspace("first") + registry.getExistingScope.mockReturnValue(a.scope) + const sidebar = consumer() + sidebar.ready.fire() + expect(a.codeIndexManager.onProgressUpdate).not.toHaveBeenCalled() + manager.init() + expect(sidebar.port.postCodeIndexStatus).toHaveBeenCalledExactlyOnceWith(a.status) + const editor = consumer() + sidebar.ready.fire() + editor.ready.fire() + expect(sidebar.port.postCodeIndexStatus).toHaveBeenCalledTimes(2) + expect(editor.port.postCodeIndexStatus).toHaveBeenCalledTimes(2) + expect(a.codeIndexManager.onProgressUpdate).toHaveBeenCalledOnce() + a.progress.fire(a.status) + expect(editor.port.postCodeIndexStatus).toHaveBeenCalledTimes(3) + }) + + it("uses the latest active workspace at startup and drops the previous progress subscription", () => { + const a = workspace("first") + const b = workspace("second") + registry.getExistingScope.mockImplementation((path: string) => (path === "/first" ? a.scope : b.scope)) + const ui = consumer() + manager.init() + const editor = makeTextEditor() + Object.defineProperty(vscode.window, "activeTextEditor", { value: editor }) + vi.spyOn(vscode.workspace, "getWorkspaceFolder").mockReturnValue(second) + editorChanges.fire(editor) + a.progress.fire(a.status) + expect(ui.port.postCodeIndexStatus).toHaveBeenCalledTimes(2) + b.progress.fire(b.status) + expect(ui.port.postCodeIndexStatus).toHaveBeenLastCalledWith(b.status) + editorChanges.fire(editor) + expect(b.codeIndexManager.onProgressUpdate).toHaveBeenCalledOnce() + }) + + it("unsubscribes outside a workspace without creating scopes", () => { + const a = workspace("first") + registry.getExistingScope.mockReturnValue(a.scope) + const ui = consumer() + manager.init() + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + vi.spyOn(vscode.workspace, "getWorkspaceFolder").mockReturnValue(undefined) + editorChanges.fire(vscode.window.activeTextEditor) + a.progress.fire(a.status) + ui.ready.fire() + expect(ui.port.postCodeIndexStatus).toHaveBeenCalledOnce() + expect(registry.getExistingScope).toHaveBeenCalledOnce() + }) + + it("does not subscribe to a lazily created, uninitialized workspace", () => { + const a = workspace("first") + Object.defineProperty(a.scope, "isInitialized", { value: false }) + registry.getExistingScope.mockReturnValue(a.scope) + consumer() + manager.init() + expect(a.codeIndexManager.onProgressUpdate).not.toHaveBeenCalled() + }) + + it("detaches disposed consumers and stops all events after disposal", () => { + const a = workspace("first") + registry.getExistingScope.mockReturnValue(a.scope) + const sidebar = consumer() + const editor = consumer() + manager.init() + editor.registration.dispose() + editor.ready.fire() + a.progress.fire(a.status) + expect(editor.port.postCodeIndexStatus).toHaveBeenCalledOnce() + manager.dispose() + sidebar.ready.fire() + editorChanges.fire(undefined) + a.progress.fire(a.status) + expect(sidebar.port.postCodeIndexStatus).toHaveBeenCalledTimes(2) + }) + + it("logs rejected asynchronous publications without reviving a disposed subscription", async () => { + const a = workspace("first") + registry.getExistingScope.mockReturnValue(a.scope) + const ui = consumer() + ui.port.postCodeIndexStatus.mockRejectedValue(new Error("closed")) + manager.init() + manager.dispose() + await Promise.resolve() + expect(output.appendLine).toHaveBeenCalledWith("Failed to publish code index status: Error: closed") + }) +}) diff --git a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts new file mode 100644 index 0000000000..dc6b800c8e --- /dev/null +++ b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts @@ -0,0 +1,98 @@ +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" +import type { ContextProxy } from "../../../core/config/ContextProxy" +import { CodeIndexManager } from "../manager" +import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" +import { CodeIndexStateManager } from "../state-manager" + +vi.mock("../state-manager", () => ({ + CodeIndexStateManager: vi.fn().mockImplementation(function () { + return { init: vi.fn(), dispose: vi.fn() } + }), +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { initialize: vi.fn(), dispose: vi.fn().mockResolvedValue(undefined) } + }), +})) + +describe("CodeIndexWorkspaceScope", () => { + beforeEach(() => vi.clearAllMocks()) + + function createScope() { + const context = makeExtensionContext() + const uri = makeUri("/workspace") + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, context) + return { scope, context, uri } + } + + function getManager(scope: CodeIndexWorkspaceScope) { + return scope.codeIndexManager + } + + it("creates and injects dependencies in the constructor without loading manager configuration", () => { + const { scope, context, uri } = createScope() + expect(CodeIndexStateManager).toHaveBeenCalledExactlyOnceWith() + const codeIndexStateManager = vi.mocked(CodeIndexStateManager).mock.results[0].value + expect(codeIndexStateManager.init).not.toHaveBeenCalled() + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith(uri.fsPath, uri, context, codeIndexStateManager) + expect(scope.codeIndexManager).toBe(vi.mocked(CodeIndexManager).mock.results[0].value) + expect(getManager(scope).initialize).not.toHaveBeenCalled() + }) + + it("creates a separate state manager for each scope", () => { + createScope() + createScope() + const calls = vi.mocked(CodeIndexManager).mock.calls + expect(CodeIndexStateManager).toHaveBeenCalledTimes(2) + expect(calls[0][3]).not.toBe(calls[1][3]) + }) + + it("initializes its manager", async () => { + const { scope } = createScope() + const manager = getManager(scope) + const contextProxy = {} as ContextProxy + + await scope.init(contextProxy) + + expect(vi.mocked(CodeIndexStateManager).mock.results[0].value.init).toHaveBeenCalledExactlyOnceWith() + expect(manager.initialize).toHaveBeenCalledExactlyOnceWith(contextProxy) + }) + + it("rethrows initialization failures", async () => { + const { scope } = createScope() + const error = new Error("initialization failed") + vi.mocked(getManager(scope).initialize).mockRejectedValue(error) + + await expect(scope.init({} as ContextProxy)).rejects.toBe(error) + }) + + it("disposes its resources", async () => { + const { scope } = createScope() + const codeIndexManager = getManager(scope) + const stateManager = vi.mocked(CodeIndexStateManager).mock.results[0].value + await scope.dispose() + expect(codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + expect(stateManager.dispose).toHaveBeenCalledExactlyOnceWith() + }) + + it("continues disposing resources when a disposal rejects", async () => { + const { scope } = createScope() + const codeIndexManager = getManager(scope) + const stateManager = vi.mocked(CodeIndexStateManager).mock.results[0].value + const error = new Error("disposal failed") + vi.mocked(codeIndexManager.dispose).mockRejectedValue(error) + + let caught: unknown + try { + await scope.dispose() + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors).toEqual([error]) + expect(codeIndexManager.dispose).toHaveBeenCalledOnce() + expect(stateManager.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index ce52593ed5..304aaed48f 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -1,8 +1,10 @@ -import { CodeIndexManager } from "../manager" +import type { CodeIndexManager } from "../manager" +import { CodeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { makeExtensionContext } from "../../../test-utils/vscode" // Helper: create a mock vscode.Uri from an fsPath function mockUri(fsPath: string, scheme = "file") { @@ -93,6 +95,7 @@ vi.mock("ignore", () => ({ vi.mock("../state-manager", () => ({ CodeIndexStateManager: vi.fn().mockImplementation(function () { return { + init: vi.fn(), onProgressUpdate: vi.fn(), getCurrentStatus: vi.fn(), dispose: vi.fn(), @@ -113,6 +116,8 @@ vi.mock("@roo-code/telemetry", () => ({ vi.mock("../service-factory") const MockedCodeIndexServiceFactory = CodeIndexServiceFactory as MockedClass +const codeIndexWorkspaceScopeRegistry = new CodeIndexWorkspaceScopeRegistry() + describe("CodeIndexManager - handleSettingsChange regression", () => { let mockContext: any let manager: CodeIndexManager @@ -124,9 +129,9 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { const testGlobalStoragePath = path.join(path.sep, "test", "global-storage") const testLogPath = path.join(path.sep, "test", "log") - beforeEach(() => { + beforeEach(async () => { // Clear all instances before each test - CodeIndexManager.disposeAll() + await codeIndexWorkspaceScopeRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -160,11 +165,24 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { languageModelAccessInformation: {} as any, } - manager = CodeIndexManager.getInstance(mockContext)! + manager = codeIndexWorkspaceScopeRegistry.getScope(mockContext)!.codeIndexManager + }) + + afterEach(async () => { + await codeIndexWorkspaceScopeRegistry.disposeAll() }) - afterEach(() => { - CodeIndexManager.disposeAll() + describe("initialize", () => { + it("rethrows initialization failures without updating lifecycle state", async () => { + const error = new Error("configuration failed") + manager["_configManager"] = { + loadConfiguration: vi.fn().mockRejectedValue(error), + } as never + + await expect(manager.initialize({} as never)).rejects.toBe(error) + + expect(manager["_stateManager"].setSystemState).not.toHaveBeenCalled() + }) }) describe("handleSettingsChange", () => { @@ -733,7 +751,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) it("should store enablement per folder URI, not per window", async () => { - CodeIndexManager.disposeAll() + await codeIndexWorkspaceScopeRegistry.disposeAll() const vscode = await import("vscode") @@ -743,20 +761,24 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { const folderBUri = mockUri(folderBPath) // Both folders share the same workspaceState (same window) - const sharedStore: Record = {} - const sharedContext = { - ...mockContext, + const sharedStore: Record = {} + const sharedContext = makeExtensionContext({ workspaceState: { - get: vi.fn((key: string, defaultValue?: any) => sharedStore[key] ?? defaultValue), - update: vi.fn(async (key: string, value: any) => { + get: vi.fn( + (key: string, defaultValue?: T) => (sharedStore[key] as T | undefined) ?? defaultValue, + ), + update: vi.fn(async (key: string, value: unknown) => { sharedStore[key] = value }), - } as any, + keys: () => Object.keys(sharedStore), + }, globalState: { - get: vi.fn((_key: string, _defaultValue?: any) => false), + get: vi.fn((_key: string, _defaultValue?: T) => false as T), update: vi.fn(), - } as any, - } + keys: () => [], + setKeysForSync: vi.fn(), + }, + }) // Patch workspaceFolders to include both folders ;(vscode.workspace as any).workspaceFolders = [ @@ -764,8 +786,8 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { { uri: folderBUri, name: "folderB", index: 1 }, ] - const managerA = CodeIndexManager.getInstance(sharedContext as any, folderAPath)! - const managerB = CodeIndexManager.getInstance(sharedContext as any, folderBPath)! + const managerA = codeIndexWorkspaceScopeRegistry.getScope(sharedContext, folderAPath)!.codeIndexManager + const managerB = codeIndexWorkspaceScopeRegistry.getScope(sharedContext, folderBPath)!.codeIndexManager // Both start disabled (autoEnableDefault is false via globalState mock) expect(managerA.isWorkspaceEnabled).toBe(false) @@ -784,12 +806,12 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { expect(managerA.isWorkspaceEnabled).toBe(false) expect(managerB.isWorkspaceEnabled).toBe(true) - CodeIndexManager.disposeAll() + await codeIndexWorkspaceScopeRegistry.disposeAll() }) }) describe("stopIndexing", () => { - it("should delegate to orchestrator.stopIndexing()", () => { + it("should delegate to orchestrator.stopIndexing()", async () => { const mockOrchestrator = { stopIndexing: vi.fn(), stopWatcher: vi.fn(), @@ -797,15 +819,15 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { } ;(manager as any)._orchestrator = mockOrchestrator - manager.stopIndexing() + await manager.stopIndexing() expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() }) - it("should be safe to call when orchestrator is not set", () => { + it("should be safe to call when orchestrator is not set", async () => { ;(manager as any)._orchestrator = undefined - expect(() => manager.stopIndexing()).not.toThrow() + await expect(manager.stopIndexing()).resolves.toBeUndefined() }) }) diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index 86b0f94808..74695f57d7 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -313,7 +313,7 @@ describe("CodeIndexOrchestrator - stopIndexing", () => { await new Promise((resolve) => setTimeout(resolve, 10)) // Stop indexing - orchestrator.stopIndexing() + await orchestrator.stopIndexing() // Wait for indexing to complete await indexingPromise @@ -352,7 +352,7 @@ describe("CodeIndexOrchestrator - stopIndexing", () => { const indexingPromise = orchestrator.startIndexing() await new Promise((resolve) => setTimeout(resolve, 10)) - orchestrator.stopIndexing() + await orchestrator.stopIndexing() await indexingPromise // Should NOT have set Error state — abort is handled gracefully @@ -390,7 +390,7 @@ describe("CodeIndexOrchestrator - stopIndexing", () => { const indexingPromise = orchestrator.startIndexing() await new Promise((resolve) => setTimeout(resolve, 10)) - orchestrator.stopIndexing() + await orchestrator.stopIndexing() await indexingPromise // Cache should NOT be cleared on user-initiated stop diff --git a/src/services/code-index/__tests__/scope-registry.spec.ts b/src/services/code-index/__tests__/scope-registry.spec.ts new file mode 100644 index 0000000000..ab061fad8a --- /dev/null +++ b/src/services/code-index/__tests__/scope-registry.spec.ts @@ -0,0 +1,237 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" +import { CodeIndexDisposalError } from "../errors/code-index-disposal-error" + +vi.mock("vscode", () => ({ + window: { activeTextEditor: undefined }, + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, + Uri: { file: vi.fn() }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +vi.mock("../state-manager", () => ({ + CodeIndexStateManager: vi.fn().mockImplementation(function () { + return { init: vi.fn(), dispose: vi.fn() } + }), +})) + +const codeIndexWorkspaceScopeRegistry = new CodeIndexWorkspaceScopeRegistry() + +describe("codeIndexWorkspaceScopeRegistry", () => { + let context: vscode.ExtensionContext + const first: vscode.WorkspaceFolder = { uri: makeUri("/first"), name: "first", index: 0 } + const second: vscode.WorkspaceFolder = { + uri: makeUri("/second", { scheme: "vscode-remote", authority: "ssh-remote+host" }), + name: "second", + index: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + context = makeExtensionContext() + vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) + Object.defineProperty(vscode.window, "activeTextEditor", { value: undefined, configurable: true }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + value: [first, second], + configurable: true, + }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + }) + + afterEach(async () => codeIndexWorkspaceScopeRegistry.disposeAll()) + + it("returns no scope without a workspace or explicit path", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { value: undefined }) + expect(codeIndexWorkspaceScopeRegistry.getScope(context)).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("returns no scope for an explicitly empty path", () => { + expect(codeIndexWorkspaceScopeRegistry.getScope(context, "")).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("defaults to the first workspace and reuses its scope", () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context) + expect(codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)).toBe(scope) + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith( + first.uri.fsPath, + first.uri, + context, + expect.anything(), + ) + }) + + it("does not register individual managers for extension-context disposal", () => { + const manager = codeIndexWorkspaceScopeRegistry.getScope(context) + expect(context.subscriptions).not.toContain(manager) + }) + + it("uses the active editor workspace and preserves its remote URI", () => { + const editor = makeTextEditor() + Object.defineProperty(vscode.window, "activeTextEditor", { value: editor }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) + codeIndexWorkspaceScopeRegistry.getScope(context) + expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(editor.document.uri) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context, expect.anything()) + }) + + it("falls back to the first workspace when the active editor is outside it", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + codeIndexWorkspaceScopeRegistry.getScope(context) + expect(CodeIndexManager).toHaveBeenCalledWith(first.uri.fsPath, first.uri, context, expect.anything()) + }) + + it("prefers an explicit workspace over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context, expect.anything()) + expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() + }) + + it("creates a file URI for an explicit path outside workspace folders", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { value: undefined }) + const uri = makeUri("/outside") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + codeIndexWorkspaceScopeRegistry.getScope(context, "/outside") + expect(vscode.Uri.file).toHaveBeenCalledWith("/outside") + expect(CodeIndexManager).toHaveBeenCalledWith("/outside", uri, context, expect.anything()) + }) + + it("creates a file URI for an explicit path that matches no workspace folder", () => { + const explicitPath = "/outside" + const uri = makeUri(explicitPath) + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + + codeIndexWorkspaceScopeRegistry.getScope(context, explicitPath) + + expect(vscode.Uri.file).toHaveBeenCalledWith(explicitPath) + expect(CodeIndexManager).toHaveBeenCalledWith(explicitPath, uri, context, expect.anything()) + }) + + it("creates distinct scopes for different workspaces", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath)! + expect(a).not.toBe(b) + }) + + it("lists all registered scopes", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath)! + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([a, b]) + }) + + it("disposes every registered scope", async () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath)! + const disposeA = vi.spyOn(a, "dispose") + const disposeB = vi.spyOn(b, "dispose") + await codeIndexWorkspaceScopeRegistry.disposeAll() + expect(disposeA).toHaveBeenCalledTimes(1) + expect(disposeB).toHaveBeenCalledTimes(1) + }) + + it("removes all scopes from the registry on disposal", async () => { + codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath) + codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath) + await codeIndexWorkspaceScopeRegistry.disposeAll() + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + }) + + it("does not dispose scopes again when cleanup is repeated", async () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const dispose = vi.spyOn(scope, "dispose") + await codeIndexWorkspaceScopeRegistry.disposeAll() + await codeIndexWorkspaceScopeRegistry.disposeAll() + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it("creates a new scope for the same workspace after disposal", async () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + await codeIndexWorkspaceScopeRegistry.disposeAll() + expect(codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)).not.toBe(scope) + }) + + it("attempts every disposal and reports all errors", async () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, second.uri.fsPath)! + const firstError = new Error("first cleanup failed") + const secondError = new Error("second cleanup failed") + vi.spyOn(a, "dispose").mockImplementation(() => { + throw firstError + }) + const disposeB = vi.spyOn(b, "dispose").mockImplementation(() => { + throw secondError + }) + let caught: unknown + try { + await codeIndexWorkspaceScopeRegistry.disposeAll() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(CodeIndexDisposalError) + if (!(caught instanceof CodeIndexDisposalError)) throw new Error("Expected disposal error") + expect(caught.name).toBe("CodeIndexDisposalError") + expect(caught.errors).toEqual([firstError, secondError]) + expect(caught.errors[0]).toBe(firstError) + expect(caught.errors[1]).toBe(secondError) + expect(caught.message).toBe( + "Failed to dispose code index managers (2 errors):\n1. first cleanup failed\n2. second cleanup failed", + ) + expect(disposeB).toHaveBeenCalledTimes(1) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + }) + + it("preserves non-Error thrown values in disposal diagnostics", async () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + vi.spyOn(scope, "dispose").mockImplementation(() => { + throw "cleanup rejected" + }) + await expect(codeIndexWorkspaceScopeRegistry.disposeAll()).rejects.toThrow( + "Failed to dispose code index managers (1 errors):\n1. cleanup rejected", + ) + }) + + it("creates and retains a new scope after disposal fails", async () => { + const disposedScope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + vi.spyOn(disposedScope, "dispose").mockImplementation(() => { + throw new Error("cleanup failed") + }) + + await expect(codeIndexWorkspaceScopeRegistry.disposeAll()).rejects.toThrow("cleanup failed") + + const newScope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath) + expect(newScope).toBeDefined() + expect(newScope).not.toBe(disposedScope) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([newScope]) + }) + + it("clears the registry before disposal callbacks run", async () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + const dispose = vi.spyOn(scope, "dispose").mockImplementation(async () => { + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + }) + await codeIndexWorkspaceScopeRegistry.disposeAll() + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it("does not create or retain scopes during disposal callbacks", async () => { + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)! + vi.spyOn(scope, "dispose").mockImplementation(async () => { + expect(codeIndexWorkspaceScopeRegistry.getScope(context, first.uri.fsPath)).toBeUndefined() + }) + + await codeIndexWorkspaceScopeRegistry.disposeAll() + + expect(CodeIndexManager).toHaveBeenCalledTimes(1) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + }) +}) diff --git a/src/services/code-index/code-index-scope.ts b/src/services/code-index/code-index-scope.ts new file mode 100644 index 0000000000..b366b83d13 --- /dev/null +++ b/src/services/code-index/code-index-scope.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode" + +import type { ContextProxy } from "../../core/config/ContextProxy" +import { CodeIndexStatusManager } from "./code-index-status-manager" +import { CodeIndexWorkspaceScopeRegistry } from "./code-index-workspace-scope-registry" +import { CodeIndexDisposalError } from "./errors/code-index-disposal-error" + +/** Owns feature resources. The caller must await init() before disposing, and dispose only once. */ +export class CodeIndexScope implements vscode.Disposable { + public readonly workspaceRegistry = new CodeIndexWorkspaceScopeRegistry() + public readonly statusManager: CodeIndexStatusManager + + public constructor( + private readonly context: vscode.ExtensionContext, + private readonly contextProxy: ContextProxy, + private readonly outputChannel: vscode.OutputChannel, + ) { + this.statusManager = new CodeIndexStatusManager(this.workspaceRegistry, outputChannel) + } + + /** Initializes managers for every workspace folder. */ + public async init(): Promise { + await Promise.all((vscode.workspace.workspaceFolders ?? []).map((folder) => this.initWorkspace(folder))) + this.statusManager.init() + } + + private async initWorkspace(folder: vscode.WorkspaceFolder): Promise { + try { + const scope = this.workspaceRegistry.getScope(this.context, folder.uri.fsPath) + await scope?.init(this.contextProxy) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.outputChannel.appendLine( + `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for ${folder.uri.fsPath}: ${message}`, + ) + } + } + + public async dispose(): Promise { + this.statusManager.dispose() + + try { + await this.workspaceRegistry.disposeAll() + } catch (error) { + if (error instanceof CodeIndexDisposalError) { + this.outputChannel.appendLine(`CodeIndexDisposalError: ${error.message}`) + return + } + + this.outputChannel.appendLine( + `Unexpected error while disposing code index managers: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } +} diff --git a/src/services/code-index/code-index-status-manager.ts b/src/services/code-index/code-index-status-manager.ts new file mode 100644 index 0000000000..ff48d1e0c9 --- /dev/null +++ b/src/services/code-index/code-index-status-manager.ts @@ -0,0 +1,83 @@ +import * as vscode from "vscode" + +import type { CodeIndexWorkspaceScopeRegistry } from "./code-index-workspace-scope-registry" +import type { CodeIndexStatusConsumer } from "./interfaces/status-consumer" +import type { CodeIndexManager } from "./manager" + +/** Owns one active-workspace progress subscription for all UI consumers. */ +export class CodeIndexStatusManager implements vscode.Disposable { + private readonly consumers = new Map() + private editorSubscription: vscode.Disposable | undefined + private progressSubscription: vscode.Disposable | undefined + private activeManager: CodeIndexManager | undefined + + public constructor( + private readonly registry: CodeIndexWorkspaceScopeRegistry, + private readonly outputChannel: Pick, + ) {} + + /** Called after workspace initialization; listen before reading the active editor. */ + public init(): void { + this.editorSubscription = vscode.window.onDidChangeActiveTextEditor(() => this.refresh()) + this.refresh() + } + + public addConsumer(consumer: CodeIndexStatusConsumer): vscode.Disposable { + const subscription = consumer.onDidCodeIndexWebviewReady(() => this.publish(consumer)) + this.consumers.set(consumer, subscription) + this.publish(consumer) + return { + dispose: () => { + subscription.dispose() + this.consumers.delete(consumer) + }, + } + } + + private refresh(): void { + const editor = vscode.window.activeTextEditor + const folder = editor + ? vscode.workspace.getWorkspaceFolder(editor.document.uri) + : vscode.workspace.workspaceFolders?.[0] + const scope = folder ? this.registry.getExistingScope(folder.uri.fsPath) : undefined + const manager = scope?.isInitialized ? scope.codeIndexManager : undefined + if (manager === this.activeManager) { + return + } + this.progressSubscription?.dispose() + this.progressSubscription = undefined + this.activeManager = manager + if (manager) { + this.progressSubscription = manager.onProgressUpdate(() => { + if (this.activeManager === manager) { + this.publishAll() + } + }) + this.publishAll() + } + } + + private publishAll(): void { + for (const consumer of this.consumers.keys()) { + this.publish(consumer) + } + } + + private publish(consumer: CodeIndexStatusConsumer): void { + if (this.activeManager) { + void consumer.postCodeIndexStatus(this.activeManager.getCurrentStatus()).catch((error: unknown) => { + this.outputChannel.appendLine(`Failed to publish code index status: ${String(error)}`) + }) + } + } + + public dispose(): void { + this.editorSubscription?.dispose() + this.progressSubscription?.dispose() + this.activeManager = undefined + for (const subscription of this.consumers.values()) { + subscription.dispose() + } + this.consumers.clear() + } +} diff --git a/src/services/code-index/code-index-workspace-scope-registry.ts b/src/services/code-index/code-index-workspace-scope-registry.ts new file mode 100644 index 0000000000..8625dd28c9 --- /dev/null +++ b/src/services/code-index/code-index-workspace-scope-registry.ts @@ -0,0 +1,78 @@ +import * as vscode from "vscode" +import { CodeIndexWorkspaceScope } from "./code-index-workspace-scope" +import { CodeIndexDisposalError } from "./errors/code-index-disposal-error" + +/** Creates and retains one code index scope per workspace path. */ +export class CodeIndexWorkspaceScopeRegistry { + private scopesByWorkspacePath = new Map() + private isDisposing = false + + public getScope(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexWorkspaceScope | undefined { + if (this.isDisposing) { + return undefined + } + + const folder = this.resolveWorkspaceFolder(workspacePath) + const resolvedPath = workspacePath ?? folder?.uri.fsPath + if (!resolvedPath) { + return undefined + } + + const existingScope = this.scopesByWorkspacePath.get(resolvedPath) + if (existingScope) { + return existingScope + } + + // folder may be undefined when workspacePath was provided but doesn't match + // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. + const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) + const scope = new CodeIndexWorkspaceScope(resolvedPath, folderUri, context) + this.scopesByWorkspacePath.set(resolvedPath, scope) + return scope + } + + private resolveWorkspaceFolder(workspacePath?: string): vscode.WorkspaceFolder | undefined { + if (workspacePath) { + return vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === workspacePath) + } + + const activeEditor = vscode.window.activeTextEditor + if (activeEditor) { + const folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) + if (folder) { + return folder + } + } + + return vscode.workspace.workspaceFolders?.[0] + } + + public getExistingScope(workspacePath: string): CodeIndexWorkspaceScope | undefined { + return this.scopesByWorkspacePath.get(workspacePath) + } + + public getAllScopes(): CodeIndexWorkspaceScope[] { + return Array.from(this.scopesByWorkspacePath.values()) + } + + public async disposeAll(): Promise { + const scopes = this.getAllScopes() + this.scopesByWorkspacePath.clear() + this.isDisposing = true + const errors: unknown[] = [] + try { + for (const scope of scopes) { + try { + await scope.dispose() + } catch (error) { + errors.push(error) + } + } + } finally { + this.isDisposing = false + } + if (errors.length > 0) { + throw new CodeIndexDisposalError(errors) + } + } +} diff --git a/src/services/code-index/code-index-workspace-scope.ts b/src/services/code-index/code-index-workspace-scope.ts new file mode 100644 index 0000000000..c86ac2a0d9 --- /dev/null +++ b/src/services/code-index/code-index-workspace-scope.ts @@ -0,0 +1,49 @@ +import type * as vscode from "vscode" + +import type { ContextProxy } from "../../core/config/ContextProxy" +import { CodeIndexManager } from "./manager" +import { CodeIndexStateManager } from "./state-manager" + +type Disposable = { + dispose(): void | Promise +} + +/** Owns the code-index resources associated with one workspace. */ +export class CodeIndexWorkspaceScope { + public readonly codeIndexManager: CodeIndexManager + private readonly stateManager: CodeIndexStateManager + private initialized = false + + public get isInitialized(): boolean { + return this.initialized + } + + public constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + this.stateManager = new CodeIndexStateManager() + this.codeIndexManager = new CodeIndexManager(workspacePath, folderUri, context, this.stateManager) + } + + public async init(contextProxy: ContextProxy): Promise { + this.stateManager.init() + this.initialized = true + await this.codeIndexManager.initialize(contextProxy) + } + + public async dispose(): Promise { + this.initialized = false + const disposables: Disposable[] = [this.codeIndexManager, this.stateManager] + const errors: unknown[] = [] + + for (const disposable of disposables) { + try { + await disposable.dispose() + } catch (error) { + errors.push(error) + } + } + + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to dispose code index scope resources") + } + } +} diff --git a/src/services/code-index/errors/code-index-disposal-error.ts b/src/services/code-index/errors/code-index-disposal-error.ts new file mode 100644 index 0000000000..88454ef665 --- /dev/null +++ b/src/services/code-index/errors/code-index-disposal-error.ts @@ -0,0 +1,12 @@ +/** Reports every failure encountered while disposing code index managers. */ +export class CodeIndexDisposalError extends AggregateError { + declare errors: unknown[] + + constructor(errors: readonly unknown[]) { + const details = errors.map( + (error, index) => `${index + 1}. ${error instanceof Error ? error.message : String(error)}`, + ) + super(errors, `Failed to dispose code index managers (${errors.length} errors):\n${details.join("\n")}`) + this.name = "CodeIndexDisposalError" + } +} diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index cdda7a7053..99c0218842 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -42,12 +42,7 @@ export interface ICodeIndexManager { /** * Stops any in-progress indexing operation and the file watcher */ - stopIndexing(): void - - /** - * Stops the file watcher - */ - stopWatcher(): void + stopIndexing(): Promise /** * Clears the index data @@ -71,7 +66,7 @@ export interface ICodeIndexManager { /** * Disposes of resources used by the manager */ - dispose(): void + dispose(): Promise } export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" diff --git a/src/services/code-index/interfaces/status-consumer.ts b/src/services/code-index/interfaces/status-consumer.ts new file mode 100644 index 0000000000..c515839d37 --- /dev/null +++ b/src/services/code-index/interfaces/status-consumer.ts @@ -0,0 +1,11 @@ +import type * as vscode from "vscode" + +import type { CodeIndexManager } from "../manager" + +export type CodeIndexStatus = ReturnType + +/** Consumer-side port used to replay and publish the active code-index status. */ +export interface CodeIndexStatusConsumer { + readonly onDidCodeIndexWebviewReady: vscode.Event + postCodeIndexStatus(status: CodeIndexStatus): Promise +} diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd36a32d88..b12056fb65 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -3,7 +3,7 @@ import { ContextProxy } from "../../core/config/ContextProxy" import { VectorStoreSearchResult } from "./interfaces" import { IndexingState } from "./interfaces/manager" import { CodeIndexConfigManager } from "./config-manager" -import { CodeIndexStateManager } from "./state-manager" +import type { CodeIndexStateManager } from "./state-manager" import { CodeIndexServiceFactory } from "./service-factory" import { CodeIndexSearchService } from "./search-service" import { CodeIndexOrchestrator } from "./orchestrator" @@ -18,9 +18,6 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" export class CodeIndexManager { - // --- Singleton Implementation --- - private static instances = new Map() // Map workspace path to instance - // Specialized class instances private _configManager: CodeIndexConfigManager | undefined private readonly _stateManager: CodeIndexStateManager @@ -33,65 +30,20 @@ export class CodeIndexManager { // Flag to prevent race conditions during error recovery private _isRecoveringFromError = false - public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - // Resolve the workspace folder to get both fsPath and the real URI - let folder: vscode.WorkspaceFolder | undefined - - if (workspacePath) { - folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === workspacePath) - } else { - const activeEditor = vscode.window.activeTextEditor - if (activeEditor) { - folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) - } - if (!folder) { - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - return undefined - } - folder = workspaceFolders[0] - } - workspacePath = folder.uri.fsPath - } - - if (!CodeIndexManager.instances.has(workspacePath)) { - // folder may be undefined when workspacePath was provided but doesn't match - // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. - const folderUri = - folder?.uri ?? - ({ - fsPath: workspacePath, - scheme: "file", - authority: "", - path: workspacePath, - toString: () => `file://${workspacePath}`, - } as unknown as vscode.Uri) - CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, folderUri, context)) - } - return CodeIndexManager.instances.get(workspacePath)! - } - - public static getAllInstances(): CodeIndexManager[] { - return Array.from(CodeIndexManager.instances.values()) - } - - public static disposeAll(): void { - for (const instance of CodeIndexManager.instances.values()) { - instance.dispose() - } - CodeIndexManager.instances.clear() - } - private readonly workspacePath: string private readonly _folderUri: vscode.Uri private readonly context: vscode.ExtensionContext - // Private constructor for singleton pattern - private constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + public constructor( + workspacePath: string, + folderUri: vscode.Uri, + context: vscode.ExtensionContext, + stateManager: CodeIndexStateManager, + ) { this.workspacePath = workspacePath this._folderUri = folderUri this.context = context - this._stateManager = new CodeIndexStateManager() + this._stateManager = stateManager } // --- Public API --- @@ -179,24 +131,21 @@ export class CodeIndexManager { // 2. Check if feature is enabled if (!this.isFeatureEnabled) { - if (this._orchestrator) { - this._orchestrator.stopWatcher() - } - if (this._sembleProvider) { - this._sembleProvider.stopIndexing() - } + await this.stopIndexing() return { requiresRestart } } // 3. Check if workspace is available const workspacePath = this.workspacePath if (!workspacePath) { + await this.stopIndexing() this._stateManager.setSystemState("Standby", "No workspace folder open") return { requiresRestart } } // 4. Check workspace-level enablement (before creating expensive services) if (!this.isWorkspaceEnabled) { + await this.stopIndexing() this._stateManager.setSystemState("Standby", "Indexing not enabled for this workspace") return { requiresRestart } } @@ -275,25 +224,13 @@ export class CodeIndexManager { /** * Stops any in-progress indexing operation and the file watcher. */ - public stopIndexing(): void { + public async stopIndexing(): Promise { if (this._sembleProvider) { - this._sembleProvider.stopIndexing() + await this._sembleProvider.stopIndexing() return } if (this._orchestrator) { - this._orchestrator.stopIndexing() - } - } - - /** - * Stops the file watcher and potentially cleans up resources. - */ - public stopWatcher(): void { - if (!this.isFeatureEnabled) { - return - } - if (this._orchestrator) { - this._orchestrator.stopWatcher() + await this._orchestrator.stopIndexing() } } @@ -341,13 +278,12 @@ export class CodeIndexManager { /** * Cleans up the manager instance. */ - public dispose(): void { - this.stopIndexing() + public async dispose(): Promise { + await this.stopIndexing() if (this._sembleProvider) { this._sembleProvider.dispose() this._sembleProvider = undefined } - this._stateManager.dispose() } /** @@ -395,10 +331,9 @@ export class CodeIndexManager { * Used by both initialize() and handleSettingsChange(). */ private async _recreateServices(): Promise { - // Stop watcher if it exists - if (this._orchestrator) { - this.stopWatcher() - } + // Stop active indexing and watcher before replacing their services. + await this.stopIndexing() + // Dispose existing semble provider if switching away if (this._sembleProvider) { this._sembleProvider.dispose() @@ -504,7 +439,7 @@ export class CodeIndexManager { // If feature is disabled, stop the service (including any active scan) if (!isFeatureEnabled) { - this.stopIndexing() + await this.stopIndexing() this._stateManager.setSystemState("Standby", "Code indexing is disabled") return } diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 1efe647be9..bb410c6a9a 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -186,7 +186,7 @@ export class CodeIndexOrchestrator { if (signal.aborted) { await this.cacheManager.flush() - this.stopWatcher() + await this.stopWatcher() this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) return } @@ -247,7 +247,7 @@ export class CodeIndexOrchestrator { if (signal.aborted) { await this.cacheManager.flush() - this.stopWatcher() + await this.stopWatcher() this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) return } @@ -305,7 +305,7 @@ export class CodeIndexOrchestrator { if (error?.name === "AbortError" || signal.aborted) { console.log("[CodeIndexOrchestrator] Indexing aborted by user.") await this.cacheManager.flush() - this.stopWatcher() + await this.stopWatcher() this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) return } @@ -350,7 +350,7 @@ export class CodeIndexOrchestrator { errorMessage: error.message || t("embeddings:orchestrator.unknownError"), }), ) - this.stopWatcher() + await this.stopWatcher() } finally { this._isProcessing = false this._abortController = null @@ -360,19 +360,19 @@ export class CodeIndexOrchestrator { /** * Stops any in-progress indexing by aborting the scan and stopping the file watcher. */ - public stopIndexing(): void { + public async stopIndexing(): Promise { if (this._abortController) { this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial")) this._abortController.abort() this._abortController = null } - this.stopWatcher() + await this.stopWatcher() } /** * Stops the file watcher and cleans up resources. */ - public stopWatcher(): void { + public async stopWatcher(): Promise { this.fileWatcher.dispose() this._fileWatcherSubscriptions.forEach((sub) => sub.dispose()) this._fileWatcherSubscriptions = [] diff --git a/src/services/code-index/semble/__tests__/provider.spec.ts b/src/services/code-index/semble/__tests__/provider.spec.ts index ecf5fea520..13507489b3 100644 --- a/src/services/code-index/semble/__tests__/provider.spec.ts +++ b/src/services/code-index/semble/__tests__/provider.spec.ts @@ -208,8 +208,8 @@ describe("SembleProvider", () => { }) describe("stopIndexing", () => { - it("should be a no-op", () => { - provider.stopIndexing() + it("should be a no-op", async () => { + await provider.stopIndexing() // No error thrown, no state change expect(provider.state).toBe("Standby") }) diff --git a/src/services/code-index/semble/provider.ts b/src/services/code-index/semble/provider.ts index 1600c03fda..d4c8dc9c4d 100644 --- a/src/services/code-index/semble/provider.ts +++ b/src/services/code-index/semble/provider.ts @@ -152,7 +152,7 @@ export class SembleProvider implements ISembleProvider { /** * Stops indexing (no-op — semble has no background indexing process). */ - stopIndexing(): void { + async stopIndexing(): Promise { // No-op: semble indexes on-the-fly per search call } diff --git a/src/services/code-index/semble/types.ts b/src/services/code-index/semble/types.ts index b897eaa75d..a82d06e935 100644 --- a/src/services/code-index/semble/types.ts +++ b/src/services/code-index/semble/types.ts @@ -60,7 +60,7 @@ export interface ISembleProvider { startIndexing(): Promise /** Stops indexing (no-op — semble has no background process). */ - stopIndexing(): void + stopIndexing(): Promise /** Searches the codebase for relevant code. */ searchIndex(query: string, directoryPrefix?: string): Promise diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index b678825147..3406f17b40 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -8,11 +8,25 @@ export class CodeIndexStateManager { private _processedItems: number = 0 private _totalItems: number = 0 private _currentItemUnit: string = "blocks" - private _progressEmitter = new vscode.EventEmitter>() + private _progressEmitter: vscode.EventEmitter> | undefined // --- Public API --- - public readonly onProgressUpdate = this._progressEmitter.event + public init(): void { + this._progressEmitter ??= new vscode.EventEmitter>() + } + + public get onProgressUpdate(): vscode.Event> { + return this.progressEmitter.event + } + + private get progressEmitter(): vscode.EventEmitter> { + if (!this._progressEmitter) { + throw new Error("CodeIndexStateManager is not initialized") + } + + return this._progressEmitter + } public get state(): IndexingState { return this._systemStatus @@ -51,7 +65,7 @@ export class CodeIndexStateManager { if (newState === "Error" && message === undefined) this._statusMessage = "An error occurred." } - this._progressEmitter.fire(this.getCurrentStatus()) + this.progressEmitter.fire(this.getCurrentStatus()) } } @@ -75,7 +89,7 @@ export class CodeIndexStateManager { // Only fire update if status, message or progress actually changed if (oldStatus !== this._systemStatus || oldMessage !== this._statusMessage || progressChanged) { - this._progressEmitter.fire(this.getCurrentStatus()) + this.progressEmitter.fire(this.getCurrentStatus()) } } } @@ -108,12 +122,13 @@ export class CodeIndexStateManager { this._statusMessage = message if (oldStatus !== this._systemStatus || oldMessage !== this._statusMessage || progressChanged) { - this._progressEmitter.fire(this.getCurrentStatus()) + this.progressEmitter.fire(this.getCurrentStatus()) } } } public dispose(): void { - this._progressEmitter.dispose() + this._progressEmitter?.dispose() + this._progressEmitter = undefined } }