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 + +function makeManager(flags: Readiness): CodeIndexManager { + // These filters only read the three public readiness getters; no manager services are needed. + return flags as CodeIndexManager +} + +const ready = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } +const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + "codebase_search", + "read_file", + "list_files", + "search_files", +].map((name) => ({ type: "function", function: { name, parameters: { type: "object", properties: {} } } })) +const noReadMode: ModeConfig = { slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] } + +function checkAvailability( + manager: CodeIndexManager | undefined, + expected: boolean, + mode = "code", + settings: { disabledTools?: string[] } = {}, +) { + const names = filterNativeToolsForMode(nativeTools, mode, [noReadMode], {}, manager, settings).flatMap((tool) => + "function" in tool ? [tool.function.name] : [], + ) + const group = getAvailableToolsInGroup("read", mode, [noReadMode], {}, manager, settings) + expect(names.includes("codebase_search")).toBe(expected) + expect(isToolAllowedInMode("codebase_search", mode, [noReadMode], {}, manager, settings)).toBe(expected) + expect(group.includes("codebase_search")).toBe(expected) + for (const tool of ["read_file", "list_files", "search_files"] as const) { + expect(names.includes(tool)).toBe(mode === "code") + expect(isToolAllowedInMode(tool, mode, [noReadMode], {}, manager, settings)).toBe(mode === "code") + expect(group.includes(tool)).toBe(mode === "code") + } +} + +describe("codebase_search readiness across mode filtering APIs", () => { + it("excludes search without a manager while retaining ordinary read tools", () => { + checkAvailability(undefined, false) + }) + + for (const isFeatureEnabled of [false, true]) { + for (const isFeatureConfigured of [false, true]) { + for (const isInitialized of [false, true]) { + it(`agrees for enabled=${isFeatureEnabled}, configured=${isFeatureConfigured}, initialized=${isInitialized}`, () => { + checkAvailability( + makeManager({ isFeatureEnabled, isFeatureConfigured, isInitialized }), + isFeatureEnabled && isFeatureConfigured && isInitialized, + ) + }) + } + } + } + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads live %s changes", + (flag) => { + const flags = { ...ready } + const manager = makeManager(flags) + checkAvailability(manager, true) + flags[flag] = false + checkAvailability(manager, false) + flags[flag] = true + checkAvailability(manager, true) + }, + ) + + it("keeps alternating managers isolated", () => { + const enabled = makeManager({ ...ready }) + const disabled = makeManager({ ...ready, isFeatureEnabled: false }) + checkAvailability(enabled, true) + checkAvailability(disabled, false) + checkAvailability(undefined, false) + checkAvailability(enabled, true) + }) + + it("does not bypass a mode without the read group", () => { + checkAvailability(makeManager({ ...ready }), false, "no-read") + }) + + it("does not bypass disabledTools with a ready manager", () => { + checkAvailability(makeManager({ ...ready }), false, "code", { disabledTools: ["codebase_search"] }) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..828f9b877a 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -376,17 +376,22 @@ export function isToolAllowedInMode( ): boolean { const modeSlug = mode ?? defaultModeSlug + // codebase_search belongs to the read group, not ALWAYS_AVAILABLE_TOOLS. + // Readiness can deny access, but must not bypass mode or disabled-tool restrictions. + if ( + toolName === "codebase_search" && + (!codeIndexManager || + !codeIndexManager.isFeatureEnabled || + !codeIndexManager.isFeatureConfigured || + !codeIndexManager.isInitialized || + settings?.disabledTools?.includes(toolName)) + ) { + 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 } 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() } diff --git a/src/core/task/__tests__/build-tools-readiness.integration.spec.ts b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts new file mode 100644 index 0000000000..32ad7c02ed --- /dev/null +++ b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts @@ -0,0 +1,74 @@ +import type OpenAI from "openai" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../../services/code-index/code-index-manager-registry" +import { makeExtensionContext } from "../../../test-utils/vscode" +import type { ClineProvider } from "../../webview/ClineProvider" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getInstance: vi.fn() }, +})) + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]) { + return tools.flatMap((tool) => ("function" in tool ? [tool.function.name] : [])) +} + +describe("task tool building with real readiness filtering", () => { + beforeEach(() => vi.clearAllMocks()) + + it.each([false, true])("uses task cwd/context and live manager readiness (restrictions=%s)", async (restricted) => { + const context = makeExtensionContext() + // The builder only consumes context and getMcpHub; avoid constructing the webview provider. + const provider = { context, getMcpHub: () => undefined } as ClineProvider + const flags = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + // Only the public readiness getters are consumed by the real filter. + const readyManager = flags as CodeIndexManager + const unreadyManager = { ...flags, isInitialized: false } as CodeIndexManager + const managers = new Map([ + ["/tasks/ready", readyManager], + ["/tasks/unready", unreadyManager], + ]) + vi.mocked(CodeIndexManagerRegistry.getInstance).mockImplementation((receivedContext, cwd) => { + expect(receivedContext).toBe(context) + return managers.get(cwd ?? "") + }) + + async function check(cwd: string, expected: boolean, mode = "code", disabledTools: string[] = []) { + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd, + mode, + customModes: [{ slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] }], + experiments: {}, + apiConfiguration: {}, + disabledTools, + includeAllToolsWithRestrictions: restricted, + }) + expect(CodeIndexManagerRegistry.getInstance).toHaveBeenLastCalledWith(context, cwd) + const definitions = toolNames(result.tools) + const callable = restricted ? result.allowedFunctionNames : definitions + expect(callable).toBeDefined() + expect(callable?.includes("codebase_search")).toBe(expected) + expect(callable?.includes("read_file")).toBe(mode === "code") + if (restricted) { + // Historical definitions remain present; only allowedFunctionNames controls calls. + expect(definitions).toContain("codebase_search") + } else { + expect(result.allowedFunctionNames).toBeUndefined() + } + } + + await check("/tasks/ready", true) + await check("/tasks/unready", false) + await check("/tasks/missing", false) + await check("/tasks/ready", true) + for (const flag of ["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const) { + flags[flag] = false + await check("/tasks/ready", false) + flags[flag] = true + await check("/tasks/ready", true) + } + await check("/tasks/ready", false, "no-read") + await check("/tasks/ready", false, "code", ["codebase_search"]) + }) +}) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..998e14de97 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -96,8 +96,8 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const mcpHub = provider.getMcpHub() // Get CodeIndexManager for feature checking. - const { CodeIndexManager } = await import("../../services/code-index/manager") - const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd) + const { CodeIndexManagerRegistry } = await import("../../services/code-index/code-index-manager-registry") + const codeIndexManager = CodeIndexManagerRegistry.getInstance(provider.context, cwd) // Build settings object for tool filtering. const filterSettings = { diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd..afc5ee0dd0 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 { CodeIndexManager } from "../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-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 = 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/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/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..33ba6b0cd1 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, folderAPath)! + const managerB = CodeIndexManagerRegistry.getInstance(sharedContext, 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