From 2afa9f17bc44da8734046ea4bad3e5e7aa1403a9 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 13 Sep 2026 02:13:48 +0300 Subject: [PATCH 1/4] refactor(code-index): extract manager registry --- src/__tests__/extension.spec.ts | 4 +- .../__tests__/registerCommands.spec.ts | 4 +- src/activate/registerCommands.ts | 4 +- src/core/prompts/system.ts | 4 +- src/core/task/build-tools.ts | 4 +- src/core/tools/CodebaseSearchTool.ts | 4 +- src/core/webview/ClineProvider.ts | 3 +- .../webview/__tests__/ClineProvider.spec.ts | 6 +- src/core/webview/webviewMessageHandler.ts | 4 +- src/extension.ts | 3 +- .../code-index-manager-registry.spec.ts | 120 ++++++++++++++++++ .../code-index/__tests__/manager.spec.ts | 15 ++- .../code-index/code-index-manager-registry.ts | 53 ++++++++ src/services/code-index/manager.ts | 55 +------- 14 files changed, 203 insertions(+), 80 deletions(-) create mode 100644 src/services/code-index/__tests__/code-index-manager-registry.spec.ts create mode 100644 src/services/code-index/code-index-manager-registry.ts diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..c6485f4abe 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -139,8 +139,8 @@ vi.mock("../services/mcp/McpServerManager", () => ({ }, })) -vi.mock("../services/code-index/manager", () => ({ - CodeIndexManager: { +vi.mock("../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getInstance: vi.fn().mockReturnValue(null), }, })) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..88e96f80be 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -67,8 +67,8 @@ vi.mock("../../core/config/importExport", () => ({ importSettingsWithFeedback: vi.fn(), })) -vi.mock("../../services/code-index/manager", () => ({ - CodeIndexManager: { +vi.mock("../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getInstance: vi.fn(), }, })) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..f062b56eab 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -10,7 +10,7 @@ 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 { CodeIndexManagerRegistry } from "../services/code-index/code-index-manager-registry" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" @@ -227,7 +227,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit { throw new Error("Extension context is not available.") } - const manager = CodeIndexManager.getInstance(context) + const manager = CodeIndexManagerRegistry.getInstance(context) if (!manager) { throw new Error("CodeIndexManager is not available.") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..854c02899d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -91,6 +91,7 @@ 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 { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -3307,7 +3308,7 @@ export class ClineProvider * @returns CodeIndexManager instance for the current workspace or the default one */ public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManager.getInstance(this.context) + return CodeIndexManagerRegistry.getInstance(this.context) } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..97c4dd877e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3225,7 +3225,7 @@ 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 { CodeIndexManagerRegistry } = await import("../../../services/code-index/code-index-manager-registry") let workspaceEnabled = false const manager = createIndexManager({ setAutoEnableDefault: vi.fn().mockImplementation(async () => { @@ -3235,8 +3235,8 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { }) Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => workspaceEnabled }) const getAllInstances = vi - .spyOn(CodeIndexManager, "getAllInstances") - .mockReturnValue([manager] as unknown as ReturnType) + .spyOn(CodeIndexManagerRegistry, "getAllInstances") + .mockReturnValue([manager] as unknown as ReturnType) const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..34a35ea3ca 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -62,7 +62,7 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { CodeIndexManager } from "../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" @@ -3311,7 +3311,7 @@ export const webviewMessageHandler = async ( return } // Capture prior state for every manager before persisting the global change - const allManagers = CodeIndexManager.getAllInstances() + const allManagers = CodeIndexManagerRegistry.getAllInstances() const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) await manager.setAutoEnableDefault(message.bool ?? true) // Apply stop/start to every affected manager diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..13bcf61666 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,6 +35,7 @@ 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 { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -200,7 +201,7 @@ export async function activate(context: vscode.ExtensionContext) { if (vscode.workspace.workspaceFolders) { for (const folder of vscode.workspace.workspaceFolders) { - const manager = CodeIndexManager.getInstance(context, folder.uri.fsPath) + const manager = CodeIndexManagerRegistry.getInstance(context, folder.uri.fsPath) if (manager) { codeIndexManagers.push(manager) diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts new file mode 100644 index 0000000000..fd5547f61c --- /dev/null +++ b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts @@ -0,0 +1,120 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" + +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, + window: { activeTextEditor: undefined }, + Uri: { file: vi.fn() }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +describe("CodeIndexManagerRegistry", () => { + let context: vscode.ExtensionContext + let first: vscode.WorkspaceFolder + let second: vscode.WorkspaceFolder + + beforeEach(() => { + vi.clearAllMocks() + context = makeExtensionContext() + first = { uri: makeUri("/first"), name: "first", index: 0 } + second = { uri: makeUri("/second"), name: "second", index: 1 } + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) + }) + + afterEach(() => { + CodeIndexManagerRegistry.disposeAll() + vi.restoreAllMocks() + }) + + it.each([{ folders: undefined }, { folders: [] }])("returns no manager with folders=$folders", ({ folders }) => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) + expect(CodeIndexManagerRegistry.getInstance(context)).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("uses the first workspace when there is no active editor", () => { + CodeIndexManagerRegistry.getInstance(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("prefers the active editor's workspace", () => { + const editor = makeTextEditor({ document: makeTextDocument({ uri: makeUri("/second/file.ts") }) }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: editor }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) + CodeIndexManagerRegistry.getInstance(context) + expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(editor.document.uri) + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + }) + + it("falls back to the first workspace for an editor outside all folders", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + CodeIndexManagerRegistry.getInstance(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("gives an explicit path priority over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + CodeIndexManagerRegistry.getInstance(context, "/second") + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() + }) + + it("preserves the actual remote workspace URI", () => { + const uri = makeUri("/remote", { scheme: "vscode-remote", authority: "ssh-remote+host" }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri, name: "remote", index: 0 }], + }) + CodeIndexManagerRegistry.getInstance(context, "/remote") + expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context) + expect(vi.mocked(CodeIndexManager).mock.calls[0][1]).toBe(uri) + expect(vscode.Uri.file).not.toHaveBeenCalled() + }) + + it("constructs a file URI for an explicit path without open workspaces", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: undefined }) + const uri = makeUri("/outside folder/#name") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + CodeIndexManagerRegistry.getInstance(context, uri.fsPath) + expect(vscode.Uri.file).toHaveBeenCalledWith(uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context) + }) + + it("reuses the same path and keeps different paths isolated", () => { + const a = CodeIndexManagerRegistry.getInstance(context, "/first") + expect(CodeIndexManagerRegistry.getInstance(makeExtensionContext(), "/first")).toBe(a) + const b = CodeIndexManagerRegistry.getInstance(context, "/second") + expect(b).not.toBe(a) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([a, b]) + }) + + it("returns a snapshot that cannot mutate the cache", () => { + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + const manager = CodeIndexManagerRegistry.getInstance(context) + CodeIndexManagerRegistry.getAllInstances().pop() + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([manager]) + }) + + it("disposes every manager, supports repeated cleanup and recreates instances", () => { + const a = CodeIndexManagerRegistry.getInstance(context, "/first")! + const b = CodeIndexManagerRegistry.getInstance(context, "/second")! + CodeIndexManagerRegistry.disposeAll() + CodeIndexManagerRegistry.disposeAll() + expect(a.dispose).toHaveBeenCalledTimes(1) + expect(b.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + expect(CodeIndexManagerRegistry.getInstance(context, "/first")).not.toBe(a) + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index ce52593ed5..16657a903c 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -1,4 +1,5 @@ import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" @@ -126,7 +127,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { beforeEach(() => { // Clear all instances before each test - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -160,11 +161,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { languageModelAccessInformation: {} as any, } - manager = CodeIndexManager.getInstance(mockContext)! + manager = CodeIndexManagerRegistry.getInstance(mockContext)! }) afterEach(() => { - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() }) describe("handleSettingsChange", () => { @@ -733,7 +734,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) it("should store enablement per folder URI, not per window", async () => { - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const vscode = await import("vscode") @@ -764,8 +765,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 = CodeIndexManagerRegistry.getInstance(sharedContext as any, folderAPath)! + const managerB = CodeIndexManagerRegistry.getInstance(sharedContext as any, folderBPath)! // Both start disabled (autoEnableDefault is false via globalState mock) expect(managerA.isWorkspaceEnabled).toBe(false) @@ -784,7 +785,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { expect(managerA.isWorkspaceEnabled).toBe(false) expect(managerB.isWorkspaceEnabled).toBe(true) - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() }) }) diff --git a/src/services/code-index/code-index-manager-registry.ts b/src/services/code-index/code-index-manager-registry.ts new file mode 100644 index 0000000000..4610bd5d36 --- /dev/null +++ b/src/services/code-index/code-index-manager-registry.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode" +import { CodeIndexManager } from "./manager" + +/** Resolves workspaces and owns their cached CodeIndexManager instances. */ +export class CodeIndexManagerRegistry { + private static instances = new Map() + + public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { + const folder = this.resolveWorkspaceFolder(workspacePath) + const resolvedPath = workspacePath || folder?.uri.fsPath + if (!resolvedPath) { + return undefined + } + + const existing = this.instances.get(resolvedPath) + if (existing) { + return existing + } + + // Preserve real workspace URIs, including remote schemes and authorities. + const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) + const manager = new CodeIndexManager(resolvedPath, folderUri, context) + this.instances.set(resolvedPath, manager) + return manager + } + + public static getAllInstances(): CodeIndexManager[] { + return Array.from(this.instances.values()) + } + + public static disposeAll(): void { + for (const instance of this.instances.values()) { + instance.dispose() + } + this.instances.clear() + } + + private static 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] + } +} diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd36a32d88..967bb855b3 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -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,61 +30,11 @@ 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) { this.workspacePath = workspacePath this._folderUri = folderUri this.context = context From 2dc6f28c201ec86df269ce4669a53f9299734a8e Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 13 Sep 2026 02:21:37 +0300 Subject: [PATCH 2/4] test(task): mock code index registry in task suite --- src/core/task/__tests__/Task.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 1bcacd459c..dfe410bdb6 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -131,6 +131,13 @@ vi.mock("p-wait-for", () => ({ default: vi.fn().mockImplementation(async () => Promise.resolve()), })) +// Task tests do not exercise indexing; keep workspace resolution and its cache out of this suite. +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { + getInstance: vi.fn().mockReturnValue(undefined), + }, +})) + vi.mock("vscode", () => { const mockDisposable = { dispose: vi.fn() } const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } From 8637e486e88cf14b8b9c4e6d4e7969b93e7fc191 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 13 Sep 2026 09:59:17 +0300 Subject: [PATCH 3/4] test(code-index): remove redundant context casts --- src/eslint-suppressions.json | 2 +- src/services/code-index/__tests__/manager.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..0e5207046c 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": 87 } }, "services/code-index/__tests__/orchestrator.spec.ts": { diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 16657a903c..33ba6b0cd1 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -765,8 +765,8 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { { uri: folderBUri, name: "folderB", index: 1 }, ] - const managerA = CodeIndexManagerRegistry.getInstance(sharedContext as any, folderAPath)! - const managerB = CodeIndexManagerRegistry.getInstance(sharedContext as any, folderBPath)! + const managerA = CodeIndexManagerRegistry.getInstance(sharedContext, folderAPath)! + const managerB = CodeIndexManagerRegistry.getInstance(sharedContext, folderBPath)! // Both start disabled (autoEnableDefault is false via globalState mock) expect(managerA.isWorkspaceEnabled).toBe(false) From 1d70c50d1352960dc56926bae986ef03084a454c Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 13 Sep 2026 12:18:18 +0300 Subject: [PATCH 4/4] refactor(code-index): own workspace services through scopes --- src/__tests__/extension.spec.ts | 193 ++++++++++++++++- .../__tests__/registerCommands.spec.ts | 6 +- src/activate/registerCommands.ts | 2 - src/core/prompts/system.ts | 3 - .../__tests__/filter-tools-for-mode.spec.ts | 128 ++++++++++- .../prompts/tools/filter-tools-for-mode.ts | 27 +-- src/core/task/__tests__/Task.spec.ts | 6 +- .../build-tools-workspace-scope.spec.ts | 103 +++++++++ src/core/task/build-tools.ts | 9 +- src/core/tools/CodebaseSearchTool.ts | 4 +- ...CodebaseSearchTool.workspace-scope.spec.ts | 159 ++++++++++++++ src/core/webview/ClineProvider.ts | 44 ++-- .../webview/__tests__/ClineProvider.spec.ts | 174 +++++++++++++-- ...wMessageHandler.auto-enable-scopes.spec.ts | 138 ++++++++++++ src/core/webview/webviewMessageHandler.ts | 20 +- src/extension.ts | 25 ++- .../code-index-manager-registry.spec.ts | 120 ----------- ...ode-index-workspace-scope-registry.spec.ts | 190 ++++++++++++++++ .../code-index-workspace-scope.spec.ts | 61 ++++++ .../__tests__/manager-lifecycle.spec.ts | 202 ++++++++++++++++++ .../code-index/__tests__/manager.spec.ts | 16 +- .../code-index/code-index-manager-registry.ts | 53 ----- .../code-index-workspace-scope-registry.ts | 82 +++++++ .../code-index/code-index-workspace-scope.ts | 25 +++ 24 files changed, 1517 insertions(+), 273 deletions(-) create mode 100644 src/core/task/__tests__/build-tools-workspace-scope.spec.ts create mode 100644 src/core/tools/__tests__/CodebaseSearchTool.workspace-scope.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.auto-enable-scopes.spec.ts delete mode 100644 src/services/code-index/__tests__/code-index-manager-registry.spec.ts create mode 100644 src/services/code-index/__tests__/code-index-workspace-scope-registry.spec.ts create mode 100644 src/services/code-index/__tests__/code-index-workspace-scope.spec.ts create mode 100644 src/services/code-index/__tests__/manager-lifecycle.spec.ts delete mode 100644 src/services/code-index/code-index-manager-registry.ts create mode 100644 src/services/code-index/code-index-workspace-scope-registry.ts create mode 100644 src/services/code-index/code-index-workspace-scope.ts diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index c6485f4abe..1b3438558c 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -1,6 +1,7 @@ // npx vitest run __tests__/extension.spec.ts import type * as vscode from "vscode" +import { makeUri } from "../test-utils/vscode" vi.mock("vscode", () => ({ window: { @@ -15,6 +16,7 @@ vi.mock("vscode", () => ({ onDidChangeActiveTextEditor: vi.fn(), }, workspace: { + workspaceFolders: undefined, registerTextDocumentContentProvider: vi.fn(), getConfiguration: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue([]), @@ -139,10 +141,10 @@ vi.mock("../services/mcp/McpServerManager", () => ({ }, })) -vi.mock("../services/code-index/code-index-manager-registry", () => ({ - CodeIndexManagerRegistry: { - getInstance: vi.fn().mockReturnValue(null), - }, +vi.mock("../services/code-index/manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), dispose: vi.fn() } + }), })) vi.mock("../services/mdm/MdmService", () => ({ @@ -267,6 +269,189 @@ describe("extension.ts", () => { expect(dotenv.config).toHaveBeenCalledTimes(1) }) + describe("code index workspace activation", () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(async () => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace).workspaceFolders = undefined + const { codeIndexWorkspaceScopeRegistry } = + await import("../services/code-index/code-index-workspace-scope-registry") + codeIndexWorkspaceScopeRegistry.disposeAll() + }) + + test("initializes every workspace once with the shared context without blocking activation", async () => { + const vscode = await import("vscode") + const folders = ["/first", "/second"].map((name, index) => ({ name, index, uri: makeUri(name) })) + vi.mocked(vscode.workspace).workspaceFolders = folders + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const scopes = folders.map((folder) => registry.getScope(mockContext, folder.uri.fsPath)!) + let release!: () => void + const pending = new Promise((resolve) => { + release = resolve + }) + for (const scope of scopes) { + vi.mocked(scope.codeIndexManager.initialize).mockImplementationOnce(async () => { + await pending + return { requiresRestart: false } + }) + } + const { ContextProxy } = await import("../core/config/ContextProxy") + const { activate } = await import("../extension") + let activated = false + const activation = activate(mockContext).then(() => { + activated = true + }) + try { + await vi.waitFor(() => expect(activated).toBe(true)) + const contextProxy = await ContextProxy.getInstance(mockContext) + for (const scope of scopes) { + expect(scope.codeIndexManager.initialize).toHaveBeenCalledExactlyOnceWith(contextProxy) + expect(scope.codeIndexManager.dispose).not.toHaveBeenCalled() + } + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("test-extension.activationCompleted") + } finally { + release() + await activation + } + }) + + test.each([new Error("configuration failed"), "configuration failed"])( + "logs background rejection %s with its workspace and continues other initialization and cleanup", + async (error) => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace).workspaceFolders = ["/broken", "/healthy"].map((name, index) => ({ + name, + index, + uri: makeUri(name), + })) + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const broken = registry.getScope(mockContext, "/broken")! + const healthy = registry.getScope(mockContext, "/healthy")! + vi.mocked(broken.codeIndexManager.initialize).mockRejectedValueOnce(error) + const { activate } = await import("../extension") + await expect(activate(mockContext)).resolves.toBeDefined() + + expect(healthy.codeIndexManager.initialize).toHaveBeenCalledTimes(1) + const channel = vi.mocked(vscode.window.createOutputChannel).mock.results.at(-1)?.value + expect(channel.appendLine).toHaveBeenCalledWith( + "[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for /broken: configuration failed", + ) + await Promise.all(mockContext.subscriptions.map((subscription) => subscription?.dispose?.())) + expect(broken.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(healthy.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(registry.getAllScopes()).toEqual([]) + }, + ) + + test.each([undefined, []])( + "activation without workspace folders (%s) still owns lazy cleanup", + async (folders) => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace).workspaceFolders = folders + const { CodeIndexManager } = await import("../services/code-index/manager") + const { activate } = await import("../extension") + await expect(activate(mockContext)).resolves.toBeDefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + + vi.mocked(vscode.workspace).workspaceFolders = [{ name: "late", index: 0, uri: makeUri("/late") }] + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const lazy = registry.getScope(mockContext, "/late")! + await Promise.all(mockContext.subscriptions.map((subscription) => subscription?.dispose?.())) + expect(lazy.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(registry.getAllScopes()).toEqual([]) + }, + ) + + test("one registry cleanup owner disposes startup and lazy scopes exactly once", async () => { + const vscode = await import("vscode") + const first = { name: "first", index: 0, uri: makeUri("/first") } + const late = { name: "late", index: 1, uri: makeUri("/late") } + vi.mocked(vscode.workspace).workspaceFolders = [first] + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + const startup = registry.getScope(mockContext, first.uri.fsPath)! + vi.mocked(vscode.workspace).workspaceFolders = [first, late] + const lazy = registry.getScope(mockContext, late.uri.fsPath)! + + await deactivate() + for (const subscription of mockContext.subscriptions) { + // Unrelated activation mocks do not all return a disposable. + await subscription?.dispose?.() + } + + expect(startup.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(lazy.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(registry.getAllScopes()).toEqual([]) + expect(mockContext.subscriptions).not.toContain(startup) + expect(mockContext.subscriptions).not.toContain(lazy) + registry.disposeAll() + expect(startup.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(lazy.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + }) + + test.each([false, true])( + "cleanup waits for pending initialization (rejects=%s) before disposal", + async (rejects) => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace).workspaceFolders = [{ name: "first", index: 0, uri: makeUri("/first") }] + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const scope = registry.getScope(mockContext, "/first")! + let release!: () => void + const pending = new Promise((resolve) => { + release = resolve + }) + vi.mocked(scope.codeIndexManager.initialize).mockImplementationOnce(async () => { + await pending + if (rejects) throw new Error("late initialization failure") + return { requiresRestart: false } + }) + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + const cleanup = Promise.all(mockContext.subscriptions.map((subscription) => subscription?.dispose?.())) + const disposedBeforeInitialization = vi.mocked(scope.codeIndexManager.dispose).mock.calls.length + release() + await cleanup + await deactivate() + + expect(disposedBeforeInitialization).toBe(0) + expect(scope.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(registry.getAllScopes()).toEqual([]) + }, + ) + + test("skips an unavailable scope without skipping later workspace initialization", async () => { + const vscode = await import("vscode") + vi.mocked(vscode.workspace).workspaceFolders = ["/unavailable", "/healthy"].map((name, index) => ({ + name, + index, + uri: makeUri(name), + })) + const { codeIndexWorkspaceScopeRegistry: registry } = + await import("../services/code-index/code-index-workspace-scope-registry") + const getScope = vi.spyOn(registry, "getScope").mockReturnValueOnce(undefined) + try { + const { activate } = await import("../extension") + await expect(activate(mockContext)).resolves.toBeDefined() + expect(getScope).toHaveBeenCalledWith(mockContext, "/unavailable") + expect(getScope).toHaveBeenCalledWith(mockContext, "/healthy") + const scopes = registry.getAllScopes() + expect(scopes).toHaveLength(1) + expect(scopes[0].codeIndexManager.initialize).toHaveBeenCalledTimes(1) + } finally { + getScope.mockRestore() + } + }) + }) + describe("cloud organization settings handling", () => { beforeEach(() => { vi.resetModules() diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 88e96f80be..8f480e1bbb 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -67,9 +67,9 @@ vi.mock("../../core/config/importExport", () => ({ importSettingsWithFeedback: vi.fn(), })) -vi.mock("../../services/code-index/code-index-manager-registry", () => ({ - CodeIndexManagerRegistry: { - getInstance: vi.fn(), +vi.mock("../../services/code-index/code-index-workspace-scope-registry", () => ({ + codeIndexWorkspaceScopeRegistry: { + getScope: vi.fn(), }, })) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f062b56eab..da98be291b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -10,7 +10,6 @@ import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" import { focusPanel } from "../utils/focusPanel" import { handleNewTask } from "./handleTask" -import { CodeIndexManagerRegistry } from "../services/code-index/code-index-manager-registry" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" @@ -227,7 +226,6 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit + +function makeWorkspaceScope(readiness: Readiness): CodeIndexWorkspaceScope { + return { + // Filtering only reads readiness; keep the same object so tests can change live state. + codeIndexManager: readiness as CodeIndexManager, + initialize: vi.fn(), + dispose: vi.fn(), + } +} function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { return { @@ -15,6 +29,118 @@ function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { } as OpenAI.Chat.ChatCompletionTool } +describe("workspace-scoped codebase_search filtering", () => { + it("retains search through all three APIs when the supplied scope is ready", () => { + const scope = makeWorkspaceScope({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const search = makeTool("codebase_search") + const read = makeTool("read_file") + + expect(filterNativeToolsForMode([read, search], "code", undefined, undefined, scope)).toEqual([read, search]) + expect(isToolAllowedInMode("codebase_search", "code", undefined, undefined, scope)).toBe(true) + expect(getAvailableToolsInGroup("read", "code", undefined, undefined, scope)).toContain("codebase_search") + }) + + const consumers = [ + { + name: "filterNativeToolsForMode", + available: (scope?: CodeIndexWorkspaceScope) => + filterNativeToolsForMode( + [makeTool("read_file"), makeTool("codebase_search")], + "code", + undefined, + undefined, + scope, + ).flatMap((tool) => (tool.type === "function" ? [tool.function.name] : [])), + }, + { + name: "isToolAllowedInMode", + available: (scope?: CodeIndexWorkspaceScope) => + (["read_file", "codebase_search"] as const).filter((tool) => + isToolAllowedInMode(tool, "code", undefined, undefined, scope), + ), + }, + { + name: "getAvailableToolsInGroup", + available: (scope?: CodeIndexWorkspaceScope) => + getAvailableToolsInGroup("read", "code", undefined, undefined, scope), + }, + ] + + describe.each(consumers)("$name", ({ available }) => { + it.each([ + [false, false, false, false], + [false, false, true, false], + [false, true, false, false], + [false, true, true, false], + [true, false, false, false], + [true, false, true, false], + [true, true, false, false], + [true, true, true, true], + ])( + "enabled=%s configured=%s initialized=%s exposes search=%s", + (isFeatureEnabled, isFeatureConfigured, isInitialized, expected) => { + const scope = makeWorkspaceScope({ isFeatureEnabled, isFeatureConfigured, isInitialized }) + const tools = available(scope) + + expect(tools.includes("codebase_search")).toBe(expected) + expect(tools).toContain("read_file") + }, + ) + + it("hides search without a workspace scope but preserves ordinary read tools", () => { + const tools = available(undefined) + + expect(tools).not.toContain("codebase_search") + expect(tools).toContain("read_file") + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads %s from the same manager on every call", + (flag) => { + const readiness = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + const scope = makeWorkspaceScope(readiness) + + expect(available(scope)).toContain("codebase_search") + readiness[flag] = false + expect(available(scope)).not.toContain("codebase_search") + readiness[flag] = true + expect(available(scope)).toContain("codebase_search") + }, + ) + + it("uses the supplied workspace rather than readiness from a previous workspace", () => { + const ready = makeWorkspaceScope({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const unready = makeWorkspaceScope({ + isFeatureEnabled: true, + isFeatureConfigured: true, + isInitialized: false, + }) + + expect(available(ready)).toContain("codebase_search") + expect(available(unready)).not.toContain("codebase_search") + expect(available(undefined)).not.toContain("codebase_search") + expect(available(ready)).toContain("codebase_search") + }) + }) + + it("does not let a ready workspace bypass a custom mode without the read group", () => { + const scope = makeWorkspaceScope({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const mode: ModeConfig = { + slug: "command-only", + name: "Command only", + roleDefinition: "Run commands only", + groups: ["command"], + } + const command = makeTool("execute_command") + + expect( + filterNativeToolsForMode([command, makeTool("codebase_search")], mode.slug, [mode], undefined, scope), + ).toEqual([command]) + expect(isToolAllowedInMode("codebase_search", mode.slug, [mode], undefined, scope)).toBe(false) + expect(getAvailableToolsInGroup("read", mode.slug, [mode], undefined, scope)).not.toContain("codebase_search") + }) +}) + describe("filterNativeToolsForMode - disabledTools", () => { const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [ makeTool("execute_command"), diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..d5b5c39ef3 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -3,7 +3,7 @@ import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types import { getModeBySlug, getToolsForMode } from "../../../shared/modes" import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" -import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { CodeIndexWorkspaceScope } from "../../../services/code-index/code-index-workspace-scope" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" @@ -230,7 +230,7 @@ export function filterNativeToolsForMode( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, + codeIndexWorkspaceScope?: CodeIndexWorkspaceScope, settings?: Record, mcpHub?: McpHub, allowedMcpServers?: string[], @@ -273,6 +273,7 @@ export function filterNativeToolsForMode( allowedToolNames = customizedTools // Conditionally exclude codebase_search if feature is disabled or not configured + const codeIndexManager = codeIndexWorkspaceScope?.codeIndexManager if ( !codeIndexManager || !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) @@ -371,22 +372,22 @@ export function isToolAllowedInMode( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, + codeIndexWorkspaceScope?: CodeIndexWorkspaceScope, settings?: Record, ): boolean { const modeSlug = mode ?? defaultModeSlug + const codeIndexManager = codeIndexWorkspaceScope?.codeIndexManager + + if ( + toolName === "codebase_search" && + !(codeIndexManager?.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) + ) { + return false + } // Check if it's an always-available tool if (ALWAYS_AVAILABLE_TOOLS.includes(toolName)) { // But still check for conditional exclusions - if (toolName === "codebase_search") { - return !!( - codeIndexManager && - codeIndexManager.isFeatureEnabled && - codeIndexManager.isFeatureConfigured && - codeIndexManager.isInitialized - ) - } if (toolName === "update_todo_list") { return settings?.todoListEnabled !== false } @@ -429,7 +430,7 @@ export function getAvailableToolsInGroup( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, + codeIndexWorkspaceScope?: CodeIndexWorkspaceScope, settings?: Record, ): ToolName[] { const toolGroup = TOOL_GROUPS[groupName] @@ -438,7 +439,7 @@ export function getAvailableToolsInGroup( } return toolGroup.tools.filter((tool) => - isToolAllowedInMode(tool as ToolName, mode, customModes, experiments, codeIndexManager, settings), + isToolAllowedInMode(tool as ToolName, mode, customModes, experiments, codeIndexWorkspaceScope, settings), ) as ToolName[] } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index dfe410bdb6..d5925cf37c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -132,9 +132,9 @@ vi.mock("p-wait-for", () => ({ })) // Task tests do not exercise indexing; keep workspace resolution and its cache out of this suite. -vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ - CodeIndexManagerRegistry: { - getInstance: vi.fn().mockReturnValue(undefined), +vi.mock("../../../services/code-index/code-index-workspace-scope-registry", () => ({ + codeIndexWorkspaceScopeRegistry: { + getScope: vi.fn().mockReturnValue(undefined), }, })) diff --git a/src/core/task/__tests__/build-tools-workspace-scope.spec.ts b/src/core/task/__tests__/build-tools-workspace-scope.spec.ts new file mode 100644 index 0000000000..6dd59e33a9 --- /dev/null +++ b/src/core/task/__tests__/build-tools-workspace-scope.spec.ts @@ -0,0 +1,103 @@ +import type OpenAI from "openai" + +import type { ClineProvider } from "../../webview/ClineProvider" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { CodeIndexWorkspaceScope } from "../../../services/code-index/code-index-workspace-scope" +import { makeExtensionContext } from "../../../test-utils/vscode" +import { codeIndexWorkspaceScopeRegistry } from "../../../services/code-index/code-index-workspace-scope-registry" +import * as filtering from "../../prompts/tools/filter-tools-for-mode" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/code-index-workspace-scope-registry", () => ({ + codeIndexWorkspaceScopeRegistry: { getScope: vi.fn() }, +})) +vi.mock("@roo-code/core", () => ({ customToolRegistry: {}, formatNative: vi.fn() })) +vi.mock("../../../services/roo-config/index.js", () => ({ getRooDirectoriesForCwd: vi.fn() })) +vi.mock("../../prompts/tools/native-tools", () => ({ + getNativeTools: () => + ["read_file", "codebase_search"].map((name) => ({ + type: "function", + function: { name, description: name, parameters: { type: "object", properties: {} } }, + })), + getMcpServerTools: () => [], +})) + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + return tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : [])) +} + +describe("build tools workspace scope", () => { + afterEach(() => vi.restoreAllMocks()) + + it("forwards the full workspace scope and request cwd to real native filtering", async () => { + const context = makeExtensionContext() + // Building tools needs only the context and MCP accessor, not a webview host. + const provider = { context, getMcpHub: () => undefined } as ClineProvider + const readiness = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + const scope: CodeIndexWorkspaceScope = { + // The real filter consumes only readiness; no indexing services are started. + codeIndexManager: readiness as CodeIndexManager, + initialize: vi.fn(), + dispose: vi.fn(), + } + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue(scope) + const filter = vi.spyOn(filtering, "filterNativeToolsForMode") + const options = { + provider, + cwd: "/task-workspace", + mode: "code", + customModes: undefined, + experiments: undefined, + apiConfiguration: undefined, + } + + const result = await buildNativeToolsArrayWithRestrictions(options) + + expect(codeIndexWorkspaceScopeRegistry.getScope).toHaveBeenCalledWith(context, options.cwd) + expect(filter.mock.calls[0][4]).toBe(scope) + expect(toolNames(result.tools)).toEqual(["read_file", "codebase_search"]) + expect(result.allowedFunctionNames).toBeUndefined() + }) + + it.each([false, true])( + "updates search availability across scope changes with restrictions=%s", + async (includeAllToolsWithRestrictions) => { + const context = makeExtensionContext() + // Only the provider members read by the builder are needed at this boundary. + const provider = { context, getMcpHub: () => undefined } as ClineProvider + const readiness = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: false } + const scope: CodeIndexWorkspaceScope = { + // The filter reads these live flags; the remaining manager services are irrelevant here. + codeIndexManager: readiness as CodeIndexManager, + initialize: vi.fn(), + dispose: vi.fn(), + } + const options = { + provider, + cwd: "/other-workspace", + mode: "code", + customModes: undefined, + experiments: undefined, + apiConfiguration: undefined, + includeAllToolsWithRestrictions, + } + const expectSearch = async (expected: boolean) => { + const result = await buildNativeToolsArrayWithRestrictions(options) + const allowed = includeAllToolsWithRestrictions ? result.allowedFunctionNames : toolNames(result.tools) + expect(allowed).toEqual(expected ? ["read_file", "codebase_search"] : ["read_file"]) + if (includeAllToolsWithRestrictions) { + expect(toolNames(result.tools)).toEqual(["read_file", "codebase_search"]) + } + } + + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue(undefined) + await expectSearch(false) + vi.mocked(codeIndexWorkspaceScopeRegistry.getScope).mockReturnValue(scope) + await expectSearch(false) + readiness.isInitialized = true + await expectSearch(true) + readiness.isFeatureEnabled = false + await expectSearch(false) + }, + ) +}) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 998e14de97..b060466255 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -95,9 +95,10 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const mcpHub = provider.getMcpHub() - // Get CodeIndexManager for feature checking. - const { CodeIndexManagerRegistry } = await import("../../services/code-index/code-index-manager-registry") - const codeIndexManager = CodeIndexManagerRegistry.getInstance(provider.context, cwd) + // Get the workspace scope for code-index feature checking. + const { codeIndexWorkspaceScopeRegistry } = + await import("../../services/code-index/code-index-workspace-scope-registry") + const codeIndexWorkspaceScope = codeIndexWorkspaceScopeRegistry.getScope(provider.context, cwd) // Build settings object for tool filtering. const filterSettings = { @@ -126,7 +127,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO mode, customModes, experiments, - codeIndexManager, + codeIndexWorkspaceScope, filterSettings, mcpHub, allowedMcpServers, diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index afc5ee0dd0..e086d3b270 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode" import path from "path" import { Task } from "../task/Task" -import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" +import { codeIndexWorkspaceScopeRegistry } from "../../services/code-index/code-index-workspace-scope-registry" import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { VectorStoreSearchResult } from "../../services/code-index/interfaces" @@ -57,7 +57,7 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { throw new Error("Extension context is not available.") } - const manager = CodeIndexManagerRegistry.getInstance(context) + const manager = codeIndexWorkspaceScopeRegistry.getScope(context, workspacePath)?.codeIndexManager if (!manager) { throw new Error("CodeIndexManager is not available.") diff --git a/src/core/tools/__tests__/CodebaseSearchTool.workspace-scope.spec.ts b/src/core/tools/__tests__/CodebaseSearchTool.workspace-scope.spec.ts new file mode 100644 index 0000000000..a9f98c79d9 --- /dev/null +++ b/src/core/tools/__tests__/CodebaseSearchTool.workspace-scope.spec.ts @@ -0,0 +1,159 @@ +import * as vscode from "vscode" + +import { CodebaseSearchTool } from "../CodebaseSearchTool" +import type { ToolCallbacks } from "../BaseTool" +import type { Task } from "../../task/Task" +import type { ClineProvider } from "../../webview/ClineProvider" +import { CodeIndexManager } from "../../../services/code-index/manager" +import { codeIndexWorkspaceScopeRegistry as registry } from "../../../services/code-index/code-index-workspace-scope-registry" +import { getWorkspacePath } from "../../../utils/path" +import { makeExtensionContext, makeTextEditor, makeUri } from "../../../test-utils/vscode" + +vi.mock("../../../services/code-index/manager", () => ({ CodeIndexManager: vi.fn() })) +vi.mock("../../../utils/path", () => ({ getWorkspacePath: vi.fn() })) +vi.mock("vscode", () => ({ + workspace: { getWorkspaceFolder: vi.fn(), asRelativePath: vi.fn() }, + window: {}, +})) + +describe("CodebaseSearchTool workspace-scope consumer", () => { + const first = { name: "first", index: 0, uri: makeUri("/first") } + const second = { name: "second", index: 1, uri: makeUri("/second") } + const context = makeExtensionContext() + let task: { cwd: string } & Pick + let callbacks: ToolCallbacks + let manager: Pick + + beforeEach(() => { + vi.clearAllMocks() + manager = { + isFeatureEnabled: true, + isFeatureConfigured: true, + searchIndex: vi.fn().mockResolvedValue([]), + dispose: vi.fn(), + } + vi.mocked(CodeIndexManager).mockImplementation(function () { + // Only the search/disposal boundary is exercised; no indexing infrastructure is constructed. + return manager as CodeIndexManager + }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + vi.spyOn(vscode.workspace, "getWorkspaceFolder").mockReturnValue(first) + vi.mocked(getWorkspacePath).mockReturnValue(first.uri.fsPath) + // The tool needs only task cwd, provider context, mistake count and result reporting. + task = { + cwd: second.uri.fsPath, + providerRef: new WeakRef({ context } as ClineProvider), + consecutiveMistakeCount: 2, + say: vi.fn().mockResolvedValue(undefined), + } + callbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + } + }) + + afterEach(() => { + registry.disposeAll() + vi.restoreAllMocks() + }) + + it("forwards the task workspace instead of selecting the active editor's root and searches its manager", async () => { + const resolve = vi.spyOn(registry, "getScope") + await new CodebaseSearchTool().execute({ query: "scope lookup", path: "src/services" }, task as Task, callbacks) + + expect(resolve).toHaveBeenCalledWith(context, second.uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context) + expect(getWorkspacePath).not.toHaveBeenCalled() + expect(manager.searchIndex).toHaveBeenCalledWith("scope lookup", "src/services") + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + 'No relevant code snippets found for the query: "scope lookup"', + ) + expect(callbacks.handleError).not.toHaveBeenCalled() + expect(task.consecutiveMistakeCount).toBe(0) + }) + + it.each(["", " "])("forwards the fallback workspace when task cwd is %j", async (cwd) => { + task.cwd = cwd + vi.mocked(getWorkspacePath).mockReturnValue(second.uri.fsPath) + const resolve = vi.spyOn(registry, "getScope") + + await new CodebaseSearchTool().execute({ query: "fallback" }, task as Task, callbacks) + + expect(resolve).toHaveBeenCalledWith(context, second.uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context) + expect(manager.searchIndex).toHaveBeenCalledWith("fallback", undefined) + expect(callbacks.handleError).not.toHaveBeenCalled() + }) + + it("reports an unavailable scope without searching or emitting a successful result", async () => { + vi.spyOn(registry, "getScope").mockReturnValue(undefined) + + await new CodebaseSearchTool().execute({ query: "missing scope" }, task as Task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith( + "codebase_search", + new Error("CodeIndexManager is not available."), + ) + expect(manager.searchIndex).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).not.toHaveBeenCalled() + }) + + it("publishes populated results returned by the selected workspace manager", async () => { + vi.mocked(manager.searchIndex).mockResolvedValue([ + { + id: "snippet", + score: 0.9, + payload: { + filePath: "/second/src/search.ts", + startLine: 3, + endLine: 5, + codeChunk: " selected workspace code ", + }, + }, + ]) + vi.spyOn(vscode.workspace, "asRelativePath").mockReturnValue("src/search.ts") + + await new CodebaseSearchTool().execute({ query: "healthy search", path: "src" }, task as Task, callbacks) + + expect(manager.searchIndex).toHaveBeenCalledWith("healthy search", "src") + expect(task.say).toHaveBeenCalledWith( + "codebase_search_result", + JSON.stringify({ + tool: "codebaseSearch", + content: { + query: "healthy search", + results: [ + { + filePath: "src/search.ts", + score: 0.9, + startLine: 3, + endLine: 5, + codeChunk: "selected workspace code", + }, + ], + }, + }), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + "Query: healthy search\nResults:\n\nFile path: src/search.ts\nScore: 0.9\nLines: 3-5\nCode Chunk: selected workspace code\n", + ) + expect(callbacks.handleError).not.toHaveBeenCalled() + }) + + it("reports a missing workspace before approval or scope resolution", async () => { + task.cwd = "" + vi.mocked(getWorkspacePath).mockReturnValue("") + const resolve = vi.spyOn(registry, "getScope") + + await new CodebaseSearchTool().execute({ query: "no workspace" }, task as Task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith( + "codebase_search", + new Error("Could not determine workspace path."), + ) + expect(callbacks.askApproval).not.toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 854c02899d..4f01b660fb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -90,8 +90,8 @@ 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 { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" +import type { CodeIndexWorkspaceScope } from "../../services/code-index/code-index-workspace-scope" +import { codeIndexWorkspaceScopeRegistry } from "../../services/code-index/code-index-workspace-scope-registry" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -213,7 +213,7 @@ export class ClineProvider private static readonly delegationTransitionLocks = new Map>() private cancelledDelegationChildIds = new Set() private codeIndexStatusSubscription?: vscode.Disposable - private codeIndexManager?: CodeIndexManager + private codeIndexWorkspaceScope?: CodeIndexWorkspaceScope private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager @@ -816,6 +816,9 @@ export class ClineProvider */ private clearWebviewResources() { this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) + this.codeIndexWorkspaceScope = undefined + this.codeIndexStatusSubscription?.dispose() + this.codeIndexStatusSubscription = undefined while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() if (x) { @@ -1130,8 +1133,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, @@ -3304,22 +3305,21 @@ export class ClineProvider } /** - * Gets the CodeIndexManager for the current active workspace - * @returns CodeIndexManager instance for the current workspace or the default one + * Gets the code-index scope for the current active workspace. + * @returns Workspace scope for the active workspace or the default one. */ - public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManagerRegistry.getInstance(this.context) + public getCurrentWorkspaceCodeIndexScope(): CodeIndexWorkspaceScope | undefined { + return codeIndexWorkspaceScopeRegistry.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() + const currentWorkspaceScope = this.getCurrentWorkspaceCodeIndexScope() - // If the manager hasn't changed, no need to update subscription - if (currentManager === this.codeIndexManager) { + // If the scope hasn't changed, no need to update subscription + if (currentWorkspaceScope === this.codeIndexWorkspaceScope) { return } @@ -3329,14 +3329,18 @@ export class ClineProvider this.codeIndexStatusSubscription = undefined } - // Update the current workspace manager reference - this.codeIndexManager = currentManager + // Update the current workspace scope reference + this.codeIndexWorkspaceScope = currentWorkspaceScope // Subscribe to the new manager's progress updates if it exists - if (currentManager) { + if (currentWorkspaceScope) { + const currentManager = currentWorkspaceScope.codeIndexManager this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { - // Only send updates if this manager is still the current one - if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { + // Only send updates if this scope is still the current one + if ( + currentWorkspaceScope === this.codeIndexWorkspaceScope && + currentWorkspaceScope === this.getCurrentWorkspaceCodeIndexScope() + ) { // Get the full status from the manager to ensure we have all fields correctly formatted const fullStatus = currentManager.getCurrentStatus() void this.postMessageToWebview({ @@ -3346,10 +3350,6 @@ export class ClineProvider } }) - if (this.view) { - this.webviewDisposables.push(this.codeIndexStatusSubscription) - } - // Send initial status for the current workspace void this.postMessageToWebview({ type: "indexingStatusUpdate", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 97c4dd877e..b02c361064 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -35,6 +35,8 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" +import { CodeIndexWorkspaceScope } from "../../../services/code-index/code-index-workspace-scope" +import { codeIndexWorkspaceScopeRegistry } from "../../../services/code-index/code-index-workspace-scope-registry" // Mock setup must come before imports. vi.mock("../../prompts/sections/custom-instructions") @@ -561,6 +563,145 @@ describe("ClineProvider", () => { }) }) + describe("workspace-scope progress subscriptions", () => { + let activeScope: CodeIndexWorkspaceScope | undefined + let changeEditor: () => void + let disposeView: () => Promise + const editorDisposable = { dispose: vi.fn() } + + function workspace(workspacePath: string) { + const scope = new CodeIndexWorkspaceScope(workspacePath, mockContext.extensionUri, mockContext) + const manager = scope.codeIndexManager + const status = { ...manager.getCurrentStatus(), message: workspacePath } + const subscriptions: { callback: (update: typeof status) => void; dispose: ReturnType }[] = [] + const subscribe = vi.fn((callback) => { + const subscription = { callback, dispose: vi.fn() } + subscriptions.push(subscription) + return subscription + }) + Object.defineProperty(manager, "onProgressUpdate", { value: subscribe }) + const getStatus = vi.spyOn(manager, "getCurrentStatus").mockReturnValue(status) + return { scope, status, subscriptions, subscribe, getStatus } + } + + beforeEach(() => { + activeScope = undefined + vi.spyOn(codeIndexWorkspaceScopeRegistry, "getScope").mockImplementation(() => activeScope) + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation((callback) => { + changeEditor = () => callback(undefined) + return editorDisposable + }) + mockWebviewView.onDidDispose = vi.fn((callback: () => Promise) => { + disposeView = callback + return { dispose: vi.fn() } + }) + }) + + afterEach(async () => { + await provider.dispose() + vi.restoreAllMocks() + }) + + it("rejects a captured stale A callback after switching to B even when workspace lookup returns A again", async () => { + const a = workspace("/a") + const b = workspace("/b") + activeScope = a.scope + await provider.resolveWebviewView(mockWebviewView) + activeScope = b.scope + changeEditor() + mockPostMessage.mockClear() + a.getStatus.mockClear() + + // The editor lookup can change before the provider receives the editor event. + activeScope = a.scope + a.subscriptions[0].callback(a.status) + + expect(mockPostMessage).not.toHaveBeenCalled() + expect(a.getStatus).not.toHaveBeenCalled() + }) + + it("reuses scope identity and publishes full current status rather than the progress payload", async () => { + const a = workspace("/a") + activeScope = a.scope + await provider.resolveWebviewView(mockWebviewView) + expect(provider.getCurrentWorkspaceCodeIndexScope()).toBe(a.scope) + expect(codeIndexWorkspaceScopeRegistry.getScope).toHaveBeenCalledWith(mockContext) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "indexingStatusUpdate", values: a.status }) + mockPostMessage.mockClear() + changeEditor() + changeEditor() + expect(a.subscribe).toHaveBeenCalledOnce() + expect(a.subscriptions[0].dispose).not.toHaveBeenCalled() + expect(mockPostMessage).not.toHaveBeenCalled() + + const latest = { ...a.status, processedItems: 7, totalItems: 10 } + a.getStatus.mockReturnValue(latest) + a.subscriptions[0].callback(a.status) + expect(mockPostMessage).toHaveBeenCalledExactlyOnceWith({ type: "indexingStatusUpdate", values: latest }) + }) + + it("handles no workspace initially and detaches progress when the workspace disappears", async () => { + const a = workspace("/a") + await provider.resolveWebviewView(mockWebviewView) + changeEditor() + expect(provider.getCurrentWorkspaceCodeIndexScope()).toBeUndefined() + expect(a.subscribe).not.toHaveBeenCalled() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "indexingStatusUpdate" })) + + activeScope = a.scope + changeEditor() + expect(a.subscribe).toHaveBeenCalledOnce() + mockPostMessage.mockClear() + // Reject progress as soon as lookup changes, before the editor notification. + activeScope = undefined + a.subscriptions[0].callback(a.status) + expect(mockPostMessage).not.toHaveBeenCalled() + changeEditor() + changeEditor() + expect(a.subscriptions[0].dispose).toHaveBeenCalledOnce() + activeScope = a.scope + a.subscriptions[0].callback(a.status) + expect(mockPostMessage).not.toHaveBeenCalled() + await provider.dispose() + expect(a.subscriptions[0].dispose).toHaveBeenCalledOnce() + }) + + it("disposes each subscription once across A to B, sidebar disposal and reattach", async () => { + const a = workspace("/a") + const b = workspace("/b") + const disposeScope = vi.spyOn(b.scope, "dispose") + activeScope = a.scope + await provider.resolveWebviewView(mockWebviewView) + activeScope = b.scope + changeEditor() + expect(a.subscriptions[0].dispose).toHaveBeenCalledOnce() + expect(a.subscriptions[0].dispose.mock.invocationCallOrder[0]).toBeLessThan( + b.subscribe.mock.invocationCallOrder[0], + ) + + await disposeView() + expect(a.subscriptions[0].dispose).toHaveBeenCalledOnce() + expect(b.subscriptions[0].dispose).toHaveBeenCalledOnce() + expect(editorDisposable.dispose).toHaveBeenCalledOnce() + mockPostMessage.mockClear() + b.getStatus.mockClear() + b.subscriptions[0].callback(b.status) + expect(mockPostMessage).not.toHaveBeenCalled() + expect(b.getStatus).not.toHaveBeenCalled() + + await provider.resolveWebviewView(mockWebviewView) + expect(b.subscribe).toHaveBeenCalledTimes(2) + expect(b.subscriptions[0].dispose).toHaveBeenCalledOnce() + mockPostMessage.mockClear() + b.subscriptions[1].callback(b.status) + expect(mockPostMessage).toHaveBeenCalledExactlyOnceWith({ type: "indexingStatusUpdate", values: b.status }) + await provider.dispose() + expect(b.subscriptions[1].dispose).toHaveBeenCalledOnce() + expect(b.subscriptions[0].dispose).toHaveBeenCalledOnce() + expect(disposeScope).not.toHaveBeenCalled() + }) + }) + test("constructor initializes correctly", () => { expect(provider).toBeInstanceOf(ClineProvider) // Since getVisibleInstance returns the last instance where view.visible is true @@ -2957,7 +3098,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"), }), @@ -3013,7 +3154,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockReturnValue(indexingPromise), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), }) await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() @@ -3170,13 +3311,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({ codeIndexManager: 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( @@ -3194,7 +3335,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { .mockRejectedValueOnce(new Error("second failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), }) await webviewMessageHandler(provider, { type: "startIndexing" }) @@ -3210,7 +3351,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({ codeIndexManager: manager }), }) await webviewMessageHandler(provider, { type: "stopIndexing" }) @@ -3225,7 +3366,8 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { }) it("catches auto-enabled indexing failures and posts the resulting status", async () => { - const { CodeIndexManagerRegistry } = await import("../../../services/code-index/code-index-manager-registry") + const { codeIndexWorkspaceScopeRegistry } = + await import("../../../services/code-index/code-index-workspace-scope-registry") let workspaceEnabled = false const manager = createIndexManager({ setAutoEnableDefault: vi.fn().mockImplementation(async () => { @@ -3234,11 +3376,13 @@ 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(CodeIndexManagerRegistry, "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({ codeIndexManager: manager }), }) try { @@ -3251,14 +3395,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({ codeIndexManager: manager }) + const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: getScope }) await webviewMessageHandler(provider, { type: "clearIndexData" }) await webviewMessageHandler(provider, { type: "clearIndexData" }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.auto-enable-scopes.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.auto-enable-scopes.spec.ts new file mode 100644 index 0000000000..2c824307d8 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.auto-enable-scopes.spec.ts @@ -0,0 +1,138 @@ +import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { CodeIndexWorkspaceScope } from "../../../services/code-index/code-index-workspace-scope" +import { codeIndexWorkspaceScopeRegistry as registry } from "../../../services/code-index/code-index-workspace-scope-registry" +import type { ContextProxy } from "../../config/ContextProxy" +import type { ClineProvider } from "../ClineProvider" +import { webviewMessageHandler } from "../webviewMessageHandler" + +vi.mock("../ClineProvider", () => ({ ClineProvider: vi.fn() })) +vi.mock("../../../services/code-index/code-index-workspace-scope-registry", () => ({ + codeIndexWorkspaceScopeRegistry: { getAllScopes: vi.fn() }, +})) + +describe("webviewMessageHandler global auto-enable across workspace scopes", () => { + let autoEnable: boolean + const contextProxy = {} as ContextProxy + + function makeScope(workspacePath: string, explicit?: boolean, configured = true) { + const manager = { + get isWorkspaceEnabled() { + return explicit ?? autoEnable + }, + isFeatureEnabled: true, + isFeatureConfigured: configured, + setAutoEnableDefault: vi.fn(async (enabled: boolean) => { + autoEnable = enabled + }), + initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), + startIndexing: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn(), + getCurrentStatus: vi.fn().mockImplementation(() => ({ + systemStatus: "Standby", + message: workspacePath, + processedItems: 0, + totalItems: 0, + currentItemUnit: "files", + workspacePath, + workspaceEnabled: explicit ?? autoEnable, + autoEnableDefault: autoEnable, + })), + } satisfies Partial + const scope: CodeIndexWorkspaceScope = { + // Private manager infrastructure prevents structural assignment; this double implements only the consumer boundary. + codeIndexManager: manager as unknown as CodeIndexManager, + initialize: manager.initialize, + dispose: vi.fn(), + } + return { scope, manager } + } + + function makeProvider(scope?: CodeIndexWorkspaceScope) { + const provider = { + contextProxy, + getCurrentWorkspaceCodeIndexScope: vi.fn(() => scope), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + } + return provider + } + + async function setDefault(provider: ReturnType, bool?: boolean) { + // ClineProvider has private extension infrastructure; the handler branch only needs these four typed members. + await webviewMessageHandler(provider as unknown as ClineProvider, { type: "setAutoEnableDefault", bool }) + } + + beforeEach(() => { + vi.clearAllMocks() + autoEnable = false + }) + + it.each([true, undefined])( + "starts every newly enabled configured scope when bool=%s, isolating start rejection", + async (bool) => { + const failing = makeScope("/failing") + const healthy = makeScope("/healthy") + const optedOut = makeScope("/opted-out", false) + const alreadyEnabled = makeScope("/already-enabled", true) + const unconfigured = makeScope("/unconfigured", undefined, false) + const scopes = [failing, healthy, optedOut, alreadyEnabled, unconfigured] + vi.mocked(registry.getAllScopes).mockReturnValue(scopes.map(({ scope }) => scope)) + failing.manager.startIndexing.mockRejectedValue(new Error("first scope failed")) + const provider = makeProvider(healthy.scope) + + await setDefault(provider, bool) + + expect(healthy.manager.setAutoEnableDefault).toHaveBeenCalledWith(true) + for (const { manager } of [failing, healthy]) { + expect(manager.initialize).toHaveBeenCalledWith(contextProxy) + expect(manager.startIndexing).toHaveBeenCalledOnce() + expect(manager.stopIndexing).not.toHaveBeenCalled() + } + for (const { manager } of [optedOut, alreadyEnabled, unconfigured]) { + expect(manager.initialize).not.toHaveBeenCalled() + expect(manager.startIndexing).not.toHaveBeenCalled() + expect(manager.stopIndexing).not.toHaveBeenCalled() + } + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: first scope failed") + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: healthy.manager.getCurrentStatus(), + }) + }, + ) + + it("stops all scopes disabled by the global default but preserves explicit workspace choices", async () => { + autoEnable = true + const first = makeScope("/first") + const second = makeScope("/second") + const optedIn = makeScope("/opted-in", true) + const optedOut = makeScope("/opted-out", false) + const scopes = [first, second, optedIn, optedOut] + vi.mocked(registry.getAllScopes).mockReturnValue(scopes.map(({ scope }) => scope)) + const provider = makeProvider(second.scope) + + await setDefault(provider, false) + + expect(second.manager.setAutoEnableDefault).toHaveBeenCalledWith(false) + for (const { manager } of [first, second]) expect(manager.stopIndexing).toHaveBeenCalledOnce() + for (const { manager } of [optedIn, optedOut]) expect(manager.stopIndexing).not.toHaveBeenCalled() + for (const { manager } of scopes) { + expect(manager.initialize).not.toHaveBeenCalled() + expect(manager.startIndexing).not.toHaveBeenCalled() + } + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: second.manager.getCurrentStatus(), + }) + }) + + it("does not enumerate scopes or update status when there is no current workspace scope", async () => { + const provider = makeProvider() + + await setDefault(provider, true) + + expect(provider.log).toHaveBeenCalledWith("Cannot set auto-enable default: No workspace folder open") + expect(registry.getAllScopes).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..049960eafe 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -62,7 +62,7 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" +import { codeIndexWorkspaceScopeRegistry } from "../../services/code-index/code-index-workspace-scope-registry" import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" @@ -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,7 +3262,7 @@ 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 @@ -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 @@ -3305,13 +3305,15 @@ 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 = CodeIndexManagerRegistry.getAllInstances() + const allManagers = codeIndexWorkspaceScopeRegistry + .getAllScopes() + .map((scope) => scope.codeIndexManager) const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) await manager.setAutoEnableDefault(message.bool ?? true) // Apply stop/start to every affected manager @@ -3338,7 +3340,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/extension.ts b/src/extension.ts index 13bcf61666..d463136d8c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,8 +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 { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" +import { codeIndexWorkspaceScopeRegistry } from "./services/code-index/code-index-workspace-scope-registry" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -196,25 +195,29 @@ export async function activate(context: vscode.ExtensionContext) { }), ) - // Initialize code index managers for all workspace folders. - const codeIndexManagers: CodeIndexManager[] = [] + // The registry owns all scopes, including those created lazily after activation. + const codeIndexInitializations: Promise[] = [] + context.subscriptions.push({ + dispose: async () => { + await Promise.all(codeIndexInitializations) + codeIndexWorkspaceScopeRegistry.disposeAll() + }, + }) + // Initialize code index scopes for all workspace folders. if (vscode.workspace.workspaceFolders) { for (const folder of vscode.workspace.workspaceFolders) { - const manager = CodeIndexManagerRegistry.getInstance(context, folder.uri.fsPath) - - if (manager) { - codeIndexManagers.push(manager) + const scope = codeIndexWorkspaceScopeRegistry.getScope(context, folder.uri.fsPath) + if (scope) { // Initialize in background; do not block extension activation - void manager.initialize(contextProxy).catch((error) => { + const initialization = scope.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) + codeIndexInitializations.push(initialization) } } } diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts deleted file mode 100644 index fd5547f61c..0000000000 --- a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import * as vscode from "vscode" -import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" -import { CodeIndexManager } from "../manager" -import { CodeIndexManagerRegistry } from "../code-index-manager-registry" - -vi.mock("vscode", () => ({ - workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, - window: { activeTextEditor: undefined }, - Uri: { file: vi.fn() }, -})) - -vi.mock("../manager", () => ({ - CodeIndexManager: vi.fn().mockImplementation(function () { - return { dispose: vi.fn() } - }), -})) - -describe("CodeIndexManagerRegistry", () => { - let context: vscode.ExtensionContext - let first: vscode.WorkspaceFolder - let second: vscode.WorkspaceFolder - - beforeEach(() => { - vi.clearAllMocks() - context = makeExtensionContext() - first = { uri: makeUri("/first"), name: "first", index: 0 } - second = { uri: makeUri("/second"), name: "second", index: 1 } - Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) - Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) - vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) - vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) - }) - - afterEach(() => { - CodeIndexManagerRegistry.disposeAll() - vi.restoreAllMocks() - }) - - it.each([{ folders: undefined }, { folders: [] }])("returns no manager with folders=$folders", ({ folders }) => { - Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) - expect(CodeIndexManagerRegistry.getInstance(context)).toBeUndefined() - expect(CodeIndexManager).not.toHaveBeenCalled() - }) - - it("uses the first workspace when there is no active editor", () => { - CodeIndexManagerRegistry.getInstance(context) - expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) - }) - - it("prefers the active editor's workspace", () => { - const editor = makeTextEditor({ document: makeTextDocument({ uri: makeUri("/second/file.ts") }) }) - Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: editor }) - vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) - CodeIndexManagerRegistry.getInstance(context) - expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(editor.document.uri) - expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) - }) - - it("falls back to the first workspace for an editor outside all folders", () => { - Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) - CodeIndexManagerRegistry.getInstance(context) - expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) - }) - - it("gives an explicit path priority over the active editor", () => { - Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) - vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) - CodeIndexManagerRegistry.getInstance(context, "/second") - expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) - expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() - }) - - it("preserves the actual remote workspace URI", () => { - const uri = makeUri("/remote", { scheme: "vscode-remote", authority: "ssh-remote+host" }) - Object.defineProperty(vscode.workspace, "workspaceFolders", { - configurable: true, - value: [{ uri, name: "remote", index: 0 }], - }) - CodeIndexManagerRegistry.getInstance(context, "/remote") - expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context) - expect(vi.mocked(CodeIndexManager).mock.calls[0][1]).toBe(uri) - expect(vscode.Uri.file).not.toHaveBeenCalled() - }) - - it("constructs a file URI for an explicit path without open workspaces", () => { - Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: undefined }) - const uri = makeUri("/outside folder/#name") - vi.mocked(vscode.Uri.file).mockReturnValue(uri) - CodeIndexManagerRegistry.getInstance(context, uri.fsPath) - expect(vscode.Uri.file).toHaveBeenCalledWith(uri.fsPath) - expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context) - }) - - it("reuses the same path and keeps different paths isolated", () => { - const a = CodeIndexManagerRegistry.getInstance(context, "/first") - expect(CodeIndexManagerRegistry.getInstance(makeExtensionContext(), "/first")).toBe(a) - const b = CodeIndexManagerRegistry.getInstance(context, "/second") - expect(b).not.toBe(a) - expect(CodeIndexManager).toHaveBeenCalledTimes(2) - expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([a, b]) - }) - - it("returns a snapshot that cannot mutate the cache", () => { - expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) - const manager = CodeIndexManagerRegistry.getInstance(context) - CodeIndexManagerRegistry.getAllInstances().pop() - expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([manager]) - }) - - it("disposes every manager, supports repeated cleanup and recreates instances", () => { - const a = CodeIndexManagerRegistry.getInstance(context, "/first")! - const b = CodeIndexManagerRegistry.getInstance(context, "/second")! - CodeIndexManagerRegistry.disposeAll() - CodeIndexManagerRegistry.disposeAll() - expect(a.dispose).toHaveBeenCalledTimes(1) - expect(b.dispose).toHaveBeenCalledTimes(1) - expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) - expect(CodeIndexManagerRegistry.getInstance(context, "/first")).not.toBe(a) - }) -}) diff --git a/src/services/code-index/__tests__/code-index-workspace-scope-registry.spec.ts b/src/services/code-index/__tests__/code-index-workspace-scope-registry.spec.ts new file mode 100644 index 0000000000..4138d18a3c --- /dev/null +++ b/src/services/code-index/__tests__/code-index-workspace-scope-registry.spec.ts @@ -0,0 +1,190 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { codeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" + +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, + window: { activeTextEditor: undefined }, + Uri: { file: vi.fn() }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +describe("CodeIndexWorkspaceScopeRegistry", () => { + let context: vscode.ExtensionContext + let first: vscode.WorkspaceFolder + let second: vscode.WorkspaceFolder + + beforeEach(() => { + vi.clearAllMocks() + context = makeExtensionContext() + first = { uri: makeUri("/first"), name: "first", index: 0 } + second = { uri: makeUri("/second"), name: "second", index: 1 } + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) + }) + + afterEach(() => { + codeIndexWorkspaceScopeRegistry.disposeAll() + vi.restoreAllMocks() + }) + + it.each([{ folders: undefined }, { folders: [] }])("returns no scope with folders=$folders", ({ folders }) => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) + expect(codeIndexWorkspaceScopeRegistry.getScope(context)).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("uses the first workspace when there is no active editor", () => { + codeIndexWorkspaceScopeRegistry.getScope(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("prefers the active editor's workspace", () => { + const editor = makeTextEditor({ document: makeTextDocument({ uri: makeUri("/second/file.ts") }) }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: editor }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) + codeIndexWorkspaceScopeRegistry.getScope(context) + expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(editor.document.uri) + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + }) + + it("falls back to the first workspace for an editor outside all folders", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + codeIndexWorkspaceScopeRegistry.getScope(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("gives an explicit path priority over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + codeIndexWorkspaceScopeRegistry.getScope(context, "/second") + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() + }) + + it("preserves the actual remote workspace URI", () => { + const uri = makeUri("/remote", { scheme: "vscode-remote", authority: "ssh-remote+host" }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri, name: "remote", index: 0 }], + }) + codeIndexWorkspaceScopeRegistry.getScope(context, "/remote") + expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context) + expect(vi.mocked(CodeIndexManager).mock.calls[0][1]).toBe(uri) + expect(vscode.Uri.file).not.toHaveBeenCalled() + }) + + it("constructs a file URI for an explicit path without open workspaces", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: undefined }) + const uri = makeUri("/outside folder/#name") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + codeIndexWorkspaceScopeRegistry.getScope(context, uri.fsPath) + expect(vscode.Uri.file).toHaveBeenCalledWith(uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context) + }) + + it("reuses the same path and keeps different paths isolated", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, "/first") + expect(codeIndexWorkspaceScopeRegistry.getScope(makeExtensionContext(), "/first")).toBe(a) + const b = codeIndexWorkspaceScopeRegistry.getScope(context, "/second") + expect(b).not.toBe(a) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([a, b]) + }) + + it("returns a snapshot that cannot mutate the cache", () => { + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + const scope = codeIndexWorkspaceScopeRegistry.getScope(context) + codeIndexWorkspaceScopeRegistry.getAllScopes().pop() + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([scope]) + }) + + it("disposes every scope, supports repeated cleanup and recreates scopes", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, "/first")! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, "/second")! + codeIndexWorkspaceScopeRegistry.disposeAll() + codeIndexWorkspaceScopeRegistry.disposeAll() + expect(a.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(b.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + expect(codeIndexWorkspaceScopeRegistry.getScope(context, "/first")).not.toBe(a) + }) + + it("attempts every scope and preserves all thrown values in an aggregate", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, "/first")! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, "/second")! + const c = codeIndexWorkspaceScopeRegistry.getScope(context, "/third")! + const error = new Error("first cleanup failed") + vi.mocked(a.codeIndexManager.dispose).mockImplementationOnce(() => { + throw error + }) + vi.mocked(b.codeIndexManager.dispose).mockImplementationOnce(() => { + throw "second cleanup failed" + }) + + let caught: unknown + try { + codeIndexWorkspaceScopeRegistry.disposeAll() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(AggregateError) + if (!(caught instanceof AggregateError)) throw new Error("Expected aggregate disposal failure") + expect(caught.errors).toEqual([error, "second cleanup failed"]) + for (const scope of [a, b, c]) { + expect(scope.codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + } + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + codeIndexWorkspaceScopeRegistry.disposeAll() + expect(a.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + const replacement = codeIndexWorkspaceScopeRegistry.getScope(context, "/first") + expect(replacement).not.toBe(a) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([replacement]) + }) + + it.each([false, true])("blocks reentrant lookup and cleanup, then resets (failure=%s)", (fails) => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, "/first")! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, "/second")! + vi.mocked(a.codeIndexManager.dispose).mockImplementationOnce(() => { + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + expect(codeIndexWorkspaceScopeRegistry.getScope(context)).toBeUndefined() + expect(codeIndexWorkspaceScopeRegistry.getScope(context, "/first")).toBeUndefined() + expect(codeIndexWorkspaceScopeRegistry.getScope(context, "/new")).toBeUndefined() + codeIndexWorkspaceScopeRegistry.disposeAll() + expect(b.codeIndexManager.dispose).not.toHaveBeenCalled() + if (fails) throw new Error("cleanup failed") + }) + + if (fails) { + expect(() => codeIndexWorkspaceScopeRegistry.disposeAll()).toThrow(AggregateError) + } else { + codeIndexWorkspaceScopeRegistry.disposeAll() + } + expect(a.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(b.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + expect(codeIndexWorkspaceScopeRegistry.getAllScopes()).toEqual([]) + expect(codeIndexWorkspaceScopeRegistry.getScope(context, "/first")).not.toBe(a) + }) + + it("cleans up its own snapshot even when a caller mutates a previously returned list", () => { + const a = codeIndexWorkspaceScopeRegistry.getScope(context, "/first")! + const b = codeIndexWorkspaceScopeRegistry.getScope(context, "/second")! + const snapshot = codeIndexWorkspaceScopeRegistry.getAllScopes() + vi.mocked(a.codeIndexManager.dispose).mockImplementationOnce(() => { + snapshot.splice(0, snapshot.length) + }) + + codeIndexWorkspaceScopeRegistry.disposeAll() + expect(b.codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + expect(snapshot).toEqual([]) + }) +}) 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..2a6f3dc9ac --- /dev/null +++ b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts @@ -0,0 +1,61 @@ +import { ContextProxy } from "../../../core/config/ContextProxy" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), dispose: vi.fn() } + }), +})) + +describe("CodeIndexWorkspaceScope", () => { + beforeEach(() => vi.clearAllMocks()) + + it("owns, initializes and disposes its manager", async () => { + const context = makeExtensionContext() + const uri = makeUri("/workspace") + const contextProxy = {} as ContextProxy + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, context) + + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith(uri.fsPath, uri, context) + await expect(scope.initialize(contextProxy)).resolves.toEqual({ requiresRestart: false }) + expect(scope.codeIndexManager.initialize).toHaveBeenCalledExactlyOnceWith(contextProxy) + + scope.dispose() + expect(scope.codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + }) + + it.each([true, false])("propagates requiresRestart=%s unchanged", async (requiresRestart) => { + const uri = makeUri("/workspace") + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, makeExtensionContext()) + const result = { requiresRestart } + vi.mocked(scope.codeIndexManager.initialize).mockResolvedValueOnce(result) + + await expect(scope.initialize({} as ContextProxy)).resolves.toBe(result) + }) + + it("propagates initialization rejection without taking over consumer cleanup", async () => { + const uri = makeUri("/workspace") + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, makeExtensionContext()) + const error = new Error("initialization failed") + vi.mocked(scope.codeIndexManager.initialize).mockRejectedValueOnce(error) + + await expect(scope.initialize({} as ContextProxy)).rejects.toBe(error) + expect(scope.codeIndexManager.dispose).not.toHaveBeenCalled() + scope.dispose() + expect(scope.codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + }) + + it("propagates disposal errors to its owner", () => { + const uri = makeUri("/workspace") + const scope = new CodeIndexWorkspaceScope(uri.fsPath, uri, makeExtensionContext()) + const error = new Error("disposal failed") + vi.mocked(scope.codeIndexManager.dispose).mockImplementationOnce(() => { + throw error + }) + + expect(() => scope.dispose()).toThrow(error) + expect(scope.codeIndexManager.dispose).toHaveBeenCalledExactlyOnceWith() + }) +}) diff --git a/src/services/code-index/__tests__/manager-lifecycle.spec.ts b/src/services/code-index/__tests__/manager-lifecycle.spec.ts new file mode 100644 index 0000000000..d545f6921e --- /dev/null +++ b/src/services/code-index/__tests__/manager-lifecycle.spec.ts @@ -0,0 +1,202 @@ +import type { ContextProxy } from "../../../core/config/ContextProxy" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { SembleProvider } from "../semble" + +const mocks = vi.hoisted(() => ({ + loadConfiguration: vi.fn<() => Promise<{ requiresRestart: boolean }>>(), + initializeCache: vi.fn<() => Promise>(), + initializeProvider: vi.fn<() => Promise>(), + startIndexing: vi.fn<() => Promise>(), + stopIndexing: vi.fn(), + disposeProvider: vi.fn(), + disposeState: vi.fn(), + setSystemState: vi.fn(), +})) + +vi.mock("../config-manager", () => ({ + CodeIndexConfigManager: vi.fn().mockImplementation(function () { + return { + loadConfiguration: mocks.loadConfiguration, + isFeatureEnabled: true, + isFeatureConfigured: true, + currentEmbedderProvider: "semble", + } + }), +})) +vi.mock("../cache-manager", () => ({ + CacheManager: vi.fn().mockImplementation(function () { + return { initialize: mocks.initializeCache } + }), +})) +vi.mock("../state-manager", () => ({ + CodeIndexStateManager: vi.fn().mockImplementation(function () { + return { dispose: mocks.disposeState, setSystemState: mocks.setSystemState } + }), +})) +vi.mock("../semble", () => ({ + SembleProvider: vi.fn().mockImplementation(function () { + return { + initialize: mocks.initializeProvider, + startIndexing: mocks.startIndexing, + stopIndexing: mocks.stopIndexing, + dispose: mocks.disposeProvider, + } + }), +})) +vi.mock("../service-factory") +vi.mock("../search-service") +vi.mock("../orchestrator") +vi.mock("../../../core/ignore/RooIgnoreController") + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe("CodeIndexManager consumer-owned lifecycle", () => { + let manager: CodeIndexManager + // Configuration is mocked, so this dependency is only passed through. + const contextProxy = {} as ContextProxy + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadConfiguration.mockReset() + mocks.initializeCache.mockReset() + mocks.initializeProvider.mockReset() + mocks.loadConfiguration.mockResolvedValue({ requiresRestart: false }) + mocks.initializeCache.mockResolvedValue(undefined) + mocks.initializeProvider.mockResolvedValue(undefined) + mocks.startIndexing.mockResolvedValue(undefined) + const uri = makeUri("/workspace") + manager = new CodeIndexManager(uri.fsPath, uri, makeExtensionContext()) + vi.spyOn(manager, "isWorkspaceEnabled", "get").mockReturnValue(true) + }) + + it("starts resources before the consumer performs its single disposal", async () => { + await expect(manager.initialize(contextProxy)).resolves.toEqual({ requiresRestart: false }) + expect(mocks.initializeCache).toHaveBeenCalledOnce() + expect(mocks.initializeProvider).toHaveBeenCalledOnce() + expect(mocks.startIndexing).toHaveBeenCalledOnce() + manager.dispose() + expect(mocks.stopIndexing).toHaveBeenCalledOnce() + expect(mocks.disposeProvider).toHaveBeenCalledOnce() + expect(mocks.disposeState).toHaveBeenCalledOnce() + }) + + it("supports intentional sequential configuration reload without recreating unchanged services", async () => { + await manager.initialize(contextProxy) + await expect(manager.initialize(contextProxy)).resolves.toEqual({ requiresRestart: false }) + expect(mocks.loadConfiguration).toHaveBeenCalledTimes(2) + expect(SembleProvider).toHaveBeenCalledOnce() + expect(mocks.startIndexing).toHaveBeenCalledOnce() + manager.dispose() + }) + + it("recreates services for a sequential restart request", async () => { + await manager.initialize(contextProxy) + mocks.loadConfiguration.mockResolvedValueOnce({ requiresRestart: true }) + await expect(manager.initialize(contextProxy)).resolves.toEqual({ requiresRestart: true }) + expect(SembleProvider).toHaveBeenCalledTimes(2) + expect(mocks.disposeProvider).toHaveBeenCalledOnce() + expect(mocks.startIndexing).toHaveBeenCalledTimes(2) + manager.dispose() + }) + + it("allows initialization after explicit error recovery", async () => { + const error = new Error("configuration unavailable") + mocks.loadConfiguration.mockRejectedValueOnce(error) + await expect(manager.initialize(contextProxy)).rejects.toBe(error) + await manager.recoverFromError() + await expect(manager.initialize(contextProxy)).resolves.toEqual({ requiresRestart: false }) + expect(mocks.startIndexing).toHaveBeenCalledOnce() + manager.dispose() + }) + + it.each(["configuration", "cache", "provider"] as const)( + "propagates %s initialization rejection", + async (stage) => { + const error = new Error(`${stage} unavailable`) + const operation = { + configuration: mocks.loadConfiguration, + cache: mocks.initializeCache, + provider: mocks.initializeProvider, + }[stage] + operation.mockRejectedValueOnce(error) + await expect(manager.initialize(contextProxy)).rejects.toBe(error) + expect(mocks.startIndexing).not.toHaveBeenCalled() + manager.dispose() + }, + ) + + // Characterize unsupported ordering, not desired safety guarantees. Consumers must + // await initialization before disposal and must not initialize concurrently. + it.each(["configuration", "cache"] as const)( + "documents resources starting after disposal during %s initialization", + async (stage) => { + const entered = deferred() + const release = deferred() + if (stage === "configuration") { + mocks.loadConfiguration.mockImplementationOnce(async () => { + entered.resolve() + await release.promise + return { requiresRestart: false } + }) + } else { + mocks.initializeCache.mockImplementationOnce(() => { + entered.resolve() + return release.promise + }) + } + const initialization = manager.initialize(contextProxy) + await entered.promise + manager.dispose() + release.resolve() + await initialization + expect(mocks.disposeState).toHaveBeenCalledOnce() + expect(mocks.startIndexing).toHaveBeenCalledOnce() + expect(mocks.disposeState.mock.invocationCallOrder[0]).toBeLessThan( + mocks.startIndexing.mock.invocationCallOrder[0], + ) + expect(mocks.disposeProvider).not.toHaveBeenCalled() + }, + ) + + it("documents initialization reporting success after disposal clears a pending provider", async () => { + const entered = deferred() + const release = deferred() + mocks.initializeProvider.mockImplementationOnce(() => { + entered.resolve() + return release.promise + }) + const initialization = manager.initialize(contextProxy) + await entered.promise + manager.dispose() + release.resolve() + await expect(initialization).resolves.toEqual({ requiresRestart: false }) + expect(manager.isInitialized).toBe(false) + expect(mocks.disposeProvider).toHaveBeenCalledOnce() + expect(mocks.disposeState).toHaveBeenCalledOnce() + expect(mocks.startIndexing).not.toHaveBeenCalled() + }) + + it("documents concurrent initialization starting indexing before shared cache readiness", async () => { + const entered = deferred() + const release = deferred() + mocks.initializeCache.mockImplementationOnce(() => { + entered.resolve() + return release.promise + }) + const first = manager.initialize(contextProxy) + await entered.promise + await manager.initialize(contextProxy) + const startedBeforeCacheReady = mocks.startIndexing.mock.calls.length + release.resolve() + await first + manager.dispose() + expect(startedBeforeCacheReady).toBe(1) + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 33ba6b0cd1..a61ffd2632 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -1,5 +1,5 @@ import { CodeIndexManager } from "../manager" -import { CodeIndexManagerRegistry } from "../code-index-manager-registry" +import { codeIndexWorkspaceScopeRegistry } from "../code-index-workspace-scope-registry" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" @@ -127,7 +127,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { beforeEach(() => { // Clear all instances before each test - CodeIndexManagerRegistry.disposeAll() + codeIndexWorkspaceScopeRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -161,11 +161,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { languageModelAccessInformation: {} as any, } - manager = CodeIndexManagerRegistry.getInstance(mockContext)! + manager = codeIndexWorkspaceScopeRegistry.getScope(mockContext)!.codeIndexManager }) afterEach(() => { - CodeIndexManagerRegistry.disposeAll() + codeIndexWorkspaceScopeRegistry.disposeAll() }) describe("handleSettingsChange", () => { @@ -734,7 +734,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) it("should store enablement per folder URI, not per window", async () => { - CodeIndexManagerRegistry.disposeAll() + codeIndexWorkspaceScopeRegistry.disposeAll() const vscode = await import("vscode") @@ -765,8 +765,8 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { { uri: folderBUri, name: "folderB", index: 1 }, ] - const managerA = CodeIndexManagerRegistry.getInstance(sharedContext, folderAPath)! - const managerB = CodeIndexManagerRegistry.getInstance(sharedContext, 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) @@ -785,7 +785,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { expect(managerA.isWorkspaceEnabled).toBe(false) expect(managerB.isWorkspaceEnabled).toBe(true) - CodeIndexManagerRegistry.disposeAll() + codeIndexWorkspaceScopeRegistry.disposeAll() }) }) diff --git a/src/services/code-index/code-index-manager-registry.ts b/src/services/code-index/code-index-manager-registry.ts deleted file mode 100644 index 4610bd5d36..0000000000 --- a/src/services/code-index/code-index-manager-registry.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as vscode from "vscode" -import { CodeIndexManager } from "./manager" - -/** Resolves workspaces and owns their cached CodeIndexManager instances. */ -export class CodeIndexManagerRegistry { - private static instances = new Map() - - public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - const folder = this.resolveWorkspaceFolder(workspacePath) - const resolvedPath = workspacePath || folder?.uri.fsPath - if (!resolvedPath) { - return undefined - } - - const existing = this.instances.get(resolvedPath) - if (existing) { - return existing - } - - // Preserve real workspace URIs, including remote schemes and authorities. - const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) - const manager = new CodeIndexManager(resolvedPath, folderUri, context) - this.instances.set(resolvedPath, manager) - return manager - } - - public static getAllInstances(): CodeIndexManager[] { - return Array.from(this.instances.values()) - } - - public static disposeAll(): void { - for (const instance of this.instances.values()) { - instance.dispose() - } - this.instances.clear() - } - - private static 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] - } -} 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..6448ee3ca2 --- /dev/null +++ b/src/services/code-index/code-index-workspace-scope-registry.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode" + +import { CodeIndexWorkspaceScope } from "./code-index-workspace-scope" + +/** Resolves workspaces and owns their cached code-index scopes. */ +export class CodeIndexWorkspaceScopeRegistry { + public static readonly instance = new CodeIndexWorkspaceScopeRegistry() + + private readonly scopes = new Map() + private disposing = false + + private constructor() {} + + public getScope(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexWorkspaceScope | undefined { + if (this.disposing) { + return undefined + } + const folder = this.resolveWorkspaceFolder(workspacePath) + const resolvedPath = workspacePath || folder?.uri.fsPath + if (!resolvedPath) { + return undefined + } + + const existing = this.scopes.get(resolvedPath) + if (existing) { + return existing + } + + // Preserve real workspace URIs, including remote schemes and authorities. + const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) + const scope = new CodeIndexWorkspaceScope(resolvedPath, folderUri, context) + this.scopes.set(resolvedPath, scope) + return scope + } + + public getAllScopes(): CodeIndexWorkspaceScope[] { + return Array.from(this.scopes.values()) + } + + public disposeAll(): void { + if (this.disposing) { + return + } + this.disposing = true + const scopes = this.getAllScopes() + this.scopes.clear() + const errors: unknown[] = [] + try { + for (const scope of scopes) { + try { + scope.dispose() + } catch (error) { + errors.push(error) + } + } + } finally { + this.disposing = false + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to dispose code index workspace scopes") + } + } + + 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] + } +} + +/** Shared workspace scope registry used by the extension runtime. */ +export const codeIndexWorkspaceScopeRegistry = CodeIndexWorkspaceScopeRegistry.instance 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..3f5d7db603 --- /dev/null +++ b/src/services/code-index/code-index-workspace-scope.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode" + +import { ContextProxy } from "../../core/config/ContextProxy" +import { CodeIndexManager } from "./manager" + +/** + * Owns code-index services whose lifetime is bound to one workspace. + * The consumer owns initialization ordering and single disposal. Registry-owned + * scopes must be disposed through the registry, not independently by borrowers. + */ +export class CodeIndexWorkspaceScope implements vscode.Disposable { + public readonly codeIndexManager: CodeIndexManager + + public constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + this.codeIndexManager = new CodeIndexManager(workspacePath, folderUri, context) + } + + public initialize(contextProxy: ContextProxy): Promise<{ requiresRestart: boolean }> { + return this.codeIndexManager.initialize(contextProxy) + } + + public dispose(): void { + this.codeIndexManager.dispose() + } +}