diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..cd98659fff 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -139,9 +139,10 @@ vi.mock("../services/mcp/McpServerManager", () => ({ }, })) -vi.mock("../services/code-index/manager", () => ({ - CodeIndexManager: { - getInstance: vi.fn().mockReturnValue(null), +vi.mock("../services/code-index/manager-registry", () => ({ + CodeIndexManagerRegistry: { + getInstance: vi.fn().mockReturnValue(undefined), + disposeAll: vi.fn(), }, })) @@ -459,6 +460,31 @@ describe("extension.ts", () => { vi.resetModules() }) + test("disposes the code index registry on deactivation", async () => { + const { CodeIndexManagerRegistry } = await import("../services/code-index/manager-registry") + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + await deactivate() + expect(CodeIndexManagerRegistry.disposeAll).toHaveBeenCalledTimes(1) + }) + + test("continues cleanup when disposing the code index registry fails", async () => { + const vscode = await import("vscode") + const { CodeIndexManagerRegistry } = await import("../services/code-index/manager-registry") + const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + vi.mocked(CodeIndexManagerRegistry.disposeAll).mockImplementationOnce(() => { + throw new Error("index cleanup failed") + }) + await expect(deactivate()).resolves.toBeUndefined() + const channel = vi.mocked(vscode.window.createOutputChannel).mock.results.at(-1)?.value + expect(channel?.appendLine).toHaveBeenCalledWith( + "Failed to dispose code index managers: index cleanup failed", + ) + expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + }) + test("still runs terminal cleanup when telemetry shutdown rejects", async () => { const { TelemetryService } = await import("@roo-code/telemetry") const { Terminal } = await import("../integrations/terminal/Terminal") diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..2bf6ffc5da 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/manager-registry", () => ({ + CodeIndexManagerRegistry: { getInstance: vi.fn(), }, })) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..2f44a103df 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/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 ({ default: vi.fn().mockImplementation(async () => Promise.resolve()), })) -vi.mock("vscode", () => { +vi.mock("vscode", async () => { + const { makeUri } = await import("../../../test-utils/vscode") const mockDisposable = { dispose: vi.fn() } const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } @@ -139,6 +140,7 @@ vi.mock("vscode", () => { const mockTabGroup = { tabs: [mockTab] } return { + Uri: { file: vi.fn((filePath: string) => makeUri(filePath)) }, TabInputTextDiff: vi.fn(), CodeActionKind: { QuickFix: { value: "quickfix" }, diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..fbd72664ae 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/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..f808ad457d 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/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 87a899344c..2411e96a70 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -85,7 +85,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/manager-registry" +import type { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -3289,7 +3290,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 1a6a82a5b0..db9678abff 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3204,7 +3204,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/manager-registry") let workspaceEnabled = false const manager = createIndexManager({ setAutoEnableDefault: vi.fn().mockImplementation(async () => { @@ -3214,8 +3214,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..60546ee6a4 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/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 381cf0c1e0..04e5a25b7c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1301,7 +1301,7 @@ }, "services/code-index/__tests__/manager.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 89 + "count": 81 } }, "services/code-index/__tests__/orchestrator.spec.ts": { diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..2acd8d6f8c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,7 +34,8 @@ 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/manager-registry" +import type { CodeIndexManager } from "./services/code-index/manager" 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) @@ -212,8 +213,6 @@ export async function activate(context: vscode.ExtensionContext) { `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for ${folder.uri.fsPath}: ${message}`, ) }) - - context.subscriptions.push(manager) } } } @@ -384,6 +383,14 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { outputChannel.appendLine(`${Package.name} extension deactivated`) + try { + CodeIndexManagerRegistry.disposeAll() + } catch (error) { + outputChannel.appendLine( + `Failed to dispose code index managers: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (cloudService && CloudService.hasInstance()) { try { if (settingsUpdatedHandler) { diff --git a/src/services/code-index/__tests__/manager-registry.spec.ts b/src/services/code-index/__tests__/manager-registry.spec.ts new file mode 100644 index 0000000000..be96db86e6 --- /dev/null +++ b/src/services/code-index/__tests__/manager-registry.spec.ts @@ -0,0 +1,147 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../manager-registry" + +vi.mock("vscode", () => ({ + window: { activeTextEditor: undefined }, + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, + Uri: { file: vi.fn() }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +describe("CodeIndexManagerRegistry", () => { + let context: vscode.ExtensionContext + const first: vscode.WorkspaceFolder = { uri: makeUri("/first"), name: "first", index: 0 } + const second: vscode.WorkspaceFolder = { + uri: makeUri("/second", { scheme: "vscode-remote", authority: "ssh-remote+host" }), + name: "second", + index: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + context = makeExtensionContext() + vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) + Object.defineProperty(vscode.window, "activeTextEditor", { value: undefined, configurable: true }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + value: [first, second], + configurable: true, + }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + }) + + afterEach(() => CodeIndexManagerRegistry.disposeAll()) + + it("returns no manager without a workspace or explicit path", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { value: undefined }) + expect(CodeIndexManagerRegistry.getInstance(context)).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("defaults to the first workspace and reuses its manager", () => { + const manager = CodeIndexManagerRegistry.getInstance(context) + expect(CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)).toBe(manager) + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith(first.uri.fsPath, first.uri, context) + }) + + it("uses the active editor workspace and preserves its remote URI", () => { + const editor = makeTextEditor() + Object.defineProperty(vscode.window, "activeTextEditor", { value: editor }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) + CodeIndexManagerRegistry.getInstance(context) + expect(vscode.workspace.getWorkspaceFolder).toHaveBeenCalledWith(editor.document.uri) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context) + }) + + it("falls back to the first workspace when the active editor is outside it", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + CodeIndexManagerRegistry.getInstance(context) + expect(CodeIndexManager).toHaveBeenCalledWith(first.uri.fsPath, first.uri, context) + }) + + it("prefers an explicit workspace over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context) + expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() + }) + + it("creates a file URI for an explicit path outside workspace folders", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { value: undefined }) + const uri = makeUri("/outside") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + CodeIndexManagerRegistry.getInstance(context, "/outside") + expect(vscode.Uri.file).toHaveBeenCalledWith("/outside") + expect(CodeIndexManager).toHaveBeenCalledWith("/outside", uri, context) + }) + + it("creates distinct managers for different workspaces", () => { + const a = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + const b = CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath)! + expect(a).not.toBe(b) + }) + + it("lists all registered managers", () => { + const a = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + const b = CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath)! + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([a, b]) + }) + + it("disposes every registered manager", () => { + const a = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + const b = CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath)! + CodeIndexManagerRegistry.disposeAll() + expect(a.dispose).toHaveBeenCalledTimes(1) + expect(b.dispose).toHaveBeenCalledTimes(1) + }) + + it("removes all managers from the registry on disposal", () => { + CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath) + CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath) + CodeIndexManagerRegistry.disposeAll() + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + }) + + it("does not dispose managers again when cleanup is repeated", () => { + const manager = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + CodeIndexManagerRegistry.disposeAll() + CodeIndexManagerRegistry.disposeAll() + expect(manager.dispose).toHaveBeenCalledTimes(1) + }) + + it("creates a new manager for the same workspace after disposal", () => { + const manager = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + CodeIndexManagerRegistry.disposeAll() + expect(CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)).not.toBe(manager) + }) + + it("attempts every disposal and rethrows the first error", () => { + const a = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + const b = CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath)! + const firstError = new Error("first cleanup failed") + vi.mocked(a.dispose).mockImplementation(() => { + throw firstError + }) + vi.mocked(b.dispose).mockImplementation(() => { + throw new Error("second cleanup failed") + }) + expect(() => CodeIndexManagerRegistry.disposeAll()).toThrow(firstError) + expect(b.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + }) + + it("clears the registry before disposal callbacks run", () => { + const manager = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + vi.mocked(manager.dispose).mockImplementation(() => { + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + }) + CodeIndexManagerRegistry.disposeAll() + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index ce52593ed5..112bb8bbf3 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -1,8 +1,10 @@ -import { CodeIndexManager } from "../manager" +import type { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../manager-registry" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { makeExtensionContext } from "../../../test-utils/vscode" // Helper: create a mock vscode.Uri from an fsPath function mockUri(fsPath: string, scheme = "file") { @@ -126,7 +128,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { beforeEach(() => { // Clear all instances before each test - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -160,11 +162,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 +735,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") @@ -743,20 +745,24 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { const folderBUri = mockUri(folderBPath) // Both folders share the same workspaceState (same window) - const sharedStore: Record = {} - const sharedContext = { - ...mockContext, + const sharedStore: Record = {} + const sharedContext = makeExtensionContext({ workspaceState: { - get: vi.fn((key: string, defaultValue?: any) => sharedStore[key] ?? defaultValue), - update: vi.fn(async (key: string, value: any) => { + get: vi.fn( + (key: string, defaultValue?: T) => (sharedStore[key] as T | undefined) ?? defaultValue, + ), + update: vi.fn(async (key: string, value: unknown) => { sharedStore[key] = value }), - } as any, + keys: () => Object.keys(sharedStore), + }, globalState: { - get: vi.fn((_key: string, _defaultValue?: any) => false), + get: vi.fn((_key: string, _defaultValue?: T) => false as T), update: vi.fn(), - } as any, - } + keys: () => [], + setKeysForSync: vi.fn(), + }, + }) // Patch workspaceFolders to include both folders ;(vscode.workspace as any).workspaceFolders = [ @@ -764,8 +770,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 +790,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/manager-registry.ts b/src/services/code-index/manager-registry.ts new file mode 100644 index 0000000000..a224a3f6db --- /dev/null +++ b/src/services/code-index/manager-registry.ts @@ -0,0 +1,68 @@ +import * as vscode from "vscode" +import { CodeIndexManager } from "./manager" + +/** Creates and retains one code index manager per workspace path. */ +export class CodeIndexManagerRegistry { + private static managersByWorkspacePath = new Map() + + public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { + const folder = this.resolveWorkspaceFolder(workspacePath) + workspacePath = workspacePath || folder?.uri.fsPath + if (!workspacePath) { + return undefined + } + + const managersByWorkspacePath = CodeIndexManagerRegistry.managersByWorkspacePath + const existingManager = managersByWorkspacePath.get(workspacePath) + if (existingManager) { + return existingManager + } + + // folder may be undefined when workspacePath was provided but doesn't match + // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. + const folderUri = folder?.uri ?? vscode.Uri.file(workspacePath) + const manager = new CodeIndexManager(workspacePath, folderUri, context) + managersByWorkspacePath.set(workspacePath, manager) + return manager + } + + 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] + } + + private static get instances() { + return CodeIndexManagerRegistry.managersByWorkspacePath.values() + } + + public static getAllInstances(): CodeIndexManager[] { + return Array.from(this.instances) + } + + public static disposeAll(): void { + const instances = this.getAllInstances() + CodeIndexManagerRegistry.managersByWorkspacePath.clear() + const errors: unknown[] = [] + for (const instance of instances) { + try { + instance.dispose() + } catch (error) { + errors.push(error) + } + } + if (errors.length > 0) { + throw errors[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