diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..9e39bef5cb 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/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/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/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..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 ({ 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..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 87a899344c..5cf20da6b8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -85,8 +85,10 @@ import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" -import { CodeIndexManager } from "../../services/code-index/manager" -import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" +import { CodeIndexWebviewMessageHandler } from "../../services/code-index/code-index-webview-message-handler" +import type { CodeIndexScope } from "../../services/code-index/code-index-scope" +import type { CodeIndexManager } from "../../services/code-index/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -109,6 +111,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" +import { WebviewMessageHandlerRegistry } from "./WebviewMessageHandlerRegistry" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -312,6 +315,7 @@ export class ClineProvider public readonly latestAnnouncementId = "sep-2026-v3.82.0-gateway-portability-free-models" // v3.82.0 portable Zoo Gateway keys, free MiniMax-M3, and new models public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager + private readonly webviewMessageHandlerRegistry: WebviewMessageHandlerRegistry constructor( readonly context: vscode.ExtensionContext, @@ -326,6 +330,9 @@ export class ClineProvider ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) + this.webviewMessageHandlerRegistry = new WebviewMessageHandlerRegistry([ + new CodeIndexWebviewMessageHandler(this), + ]) ClineProvider.activeInstances.add(this) @@ -1701,7 +1708,7 @@ export class ClineProvider */ private setWebviewMessageListener(webview: vscode.Webview) { const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + webviewMessageHandler(this, message, this.marketplaceManager, this.webviewMessageHandlerRegistry) const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -3289,15 +3296,21 @@ export class ClineProvider * @returns CodeIndexManager instance for the current workspace or the default one */ public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManager.getInstance(this.context) + return this.getCurrentWorkspaceCodeIndexScope()?.codeIndexManager + } + + public getCurrentWorkspaceCodeIndexScope(): CodeIndexScope | undefined { + return CodeIndexManagerRegistry.getCodeIndexScope(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() + // Get the current workspace manager and its interface controller + const currentScope = this.getCurrentWorkspaceCodeIndexScope() + const currentManager = currentScope?.codeIndexManager + const currentController = currentScope?.codeIndexController // If the manager hasn't changed, no need to update subscription if (currentManager === this.codeIndexManager) { @@ -3313,16 +3326,14 @@ export class ClineProvider // Update the current workspace manager reference this.codeIndexManager = currentManager - // Subscribe to the new manager's progress updates if it exists - if (currentManager) { - this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { + // Subscribe to the complete interface state exposed by the controller. + if (currentManager && currentController) { + this.codeIndexStatusSubscription = currentController.onDidChangeCodeIndexState((codeIndexState) => { // Only send updates if this manager is still the current one if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { - // Get the full status from the manager to ensure we have all fields correctly formatted - const fullStatus = currentManager.getCurrentStatus() void this.postMessageToWebview({ type: "indexingStatusUpdate", - values: fullStatus, + values: codeIndexState, }) } }) @@ -3334,7 +3345,7 @@ export class ClineProvider // Send initial status for the current workspace void this.postMessageToWebview({ type: "indexingStatusUpdate", - values: currentManager.getCurrentStatus(), + values: currentController.codeIndexState, }) } } diff --git a/src/core/webview/WebviewMessageHandlerRegistry.ts b/src/core/webview/WebviewMessageHandlerRegistry.ts new file mode 100644 index 0000000000..68ebf08a74 --- /dev/null +++ b/src/core/webview/WebviewMessageHandlerRegistry.ts @@ -0,0 +1,20 @@ +import type { WebviewMessage } from "@roo-code/types" + +export interface WebviewMessageFeatureHandler { + canHandle(message: WebviewMessage): boolean + handle(message: WebviewMessage): Promise +} + +export class WebviewMessageHandlerRegistry { + public constructor(private readonly webviewMessageFeatureHandlers: readonly WebviewMessageFeatureHandler[]) {} + + public async handle(message: WebviewMessage): Promise { + const webviewMessageFeatureHandler = this.webviewMessageFeatureHandlers.find((candidate) => + candidate.canHandle(message), + ) + if (!webviewMessageFeatureHandler) return false + + await webviewMessageFeatureHandler.handle(message) + return true + } +} diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..a9069b5a2e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -30,6 +30,10 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" +import { CodeIndexController } from "../../../services/code-index/code-index-controller" +import { CodeIndexWebviewMessageHandler } from "../../../services/code-index/code-index-webview-message-handler" +import { CodeIndexStateManager } from "../../../services/code-index/state-manager" +import { WebviewMessageHandlerRegistry } from "../WebviewMessageHandlerRegistry" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -2937,6 +2941,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { 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"), }), @@ -2963,12 +2968,15 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { const createIndexManager = (overrides: Record = {}) => Object.assign( { + workspacePath: "/test/workspace", setWorkspaceEnabled: vi.fn().mockResolvedValue(undefined), setAutoEnableDefault: vi.fn().mockResolvedValue(undefined), + autoEnableDefault: true, isFeatureEnabled: true, isFeatureConfigured: true, isWorkspaceEnabled: true, initialize: vi.fn().mockResolvedValue(undefined), + handleSettingsChange: vi.fn().mockResolvedValue(undefined), state: "Standby", isInitialized: true, startIndexing: vi.fn().mockResolvedValue(undefined), @@ -2979,6 +2987,23 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { overrides, ) + const createCodeIndexScope = (manager: ReturnType) => { + const stateManager = new CodeIndexStateManager() + vi.spyOn(stateManager, "getCurrentStatus").mockImplementation(manager.getCurrentStatus) + return { + codeIndexManager: manager, + codeIndexController: new CodeIndexController(manager, stateManager), + } + } + + const handleWebviewMessage = (provider: ClineProvider, message: WebviewMessage) => + webviewMessageHandler( + provider, + message, + undefined, + new WebviewMessageHandlerRegistry([new CodeIndexWebviewMessageHandler(provider)]), + ) + beforeEach(() => { vi.clearAllMocks() }) @@ -2992,10 +3017,10 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockReturnValue(indexingPromise), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createCodeIndexScope(manager)), }) - await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() + await expect(handleWebviewMessage(provider, { type: "startIndexing" })).resolves.toBeUndefined() expect(manager.startIndexing).toHaveBeenCalledOnce() rejectIndexing(new Error("boom")) @@ -3150,13 +3175,17 @@ 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 getCodeIndexScope = vi.fn().mockReturnValueOnce(undefined) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexManager: getManager, + getCurrentWorkspaceCodeIndexScope: getCodeIndexScope, + }) - await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) - await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) - await webviewMessageHandler(provider, { type: "requestCodeIndexSecretStatus" }) - getManager.mockReturnValueOnce(undefined) - await webviewMessageHandler(provider, { type: "startIndexing" }) + await handleWebviewMessage(provider, { type: "requestIndexingStatus" }) + await handleWebviewMessage(provider, { type: "requestIndexingStatus" }) + await handleWebviewMessage(provider, { type: "requestCodeIndexSecretStatus" }) + getCodeIndexScope.mockReturnValueOnce(undefined) + await handleWebviewMessage(provider, { type: "startIndexing" }) expect(provider.postMessageToWebview).toHaveBeenCalledWith( expect.objectContaining({ type: "codeIndexSecretStatus" }), @@ -3173,10 +3202,10 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { .mockRejectedValueOnce(new Error("second failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(createCodeIndexScope(manager)), }) - await webviewMessageHandler(provider, { type: "startIndexing" }) + await handleWebviewMessage(provider, { type: "startIndexing" }) await Promise.resolve() expect(manager.startIndexing).toHaveBeenCalledTimes(2) @@ -3189,11 +3218,11 @@ 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(createCodeIndexScope(manager)), }) - await webviewMessageHandler(provider, { type: "stopIndexing" }) - await webviewMessageHandler(provider, { type: "toggleWorkspaceIndexing", bool: true }) + await handleWebviewMessage(provider, { type: "stopIndexing" }) + await handleWebviewMessage(provider, { type: "toggleWorkspaceIndexing", bool: true }) await Promise.resolve() expect(manager.stopIndexing).toHaveBeenCalledOnce() @@ -3204,7 +3233,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 () => { @@ -3213,15 +3242,18 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockRejectedValue(new Error("auto-enable failure")), }) Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => workspaceEnabled }) - const getAllInstances = vi - .spyOn(CodeIndexManager, "getAllInstances") - .mockReturnValue([manager] as unknown as ReturnType) + const codeIndexScope = createCodeIndexScope(manager) + const getAllCodeIndexScopes = vi + .spyOn(CodeIndexManagerRegistry, "getAllCodeIndexScopes") + .mockReturnValue([codeIndexScope] as unknown as ReturnType< + typeof CodeIndexManagerRegistry.getAllCodeIndexScopes + >) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(codeIndexScope), }) try { - await webviewMessageHandler(provider, { type: "setAutoEnableDefault", bool: true }) + await handleWebviewMessage(provider, { type: "setAutoEnableDefault", bool: true }) await Promise.resolve() expect(manager.startIndexing).toHaveBeenCalledOnce() @@ -3230,19 +3262,20 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { expect.objectContaining({ type: "indexingStatusUpdate" }), ) } finally { - getAllInstances.mockRestore() + getAllCodeIndexScopes.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 codeIndexScope = createCodeIndexScope(manager) + const getCodeIndexScope = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(codeIndexScope) + const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: getCodeIndexScope }) - await webviewMessageHandler(provider, { type: "clearIndexData" }) - await webviewMessageHandler(provider, { type: "clearIndexData" }) + await handleWebviewMessage(provider, { type: "clearIndexData" }) + await handleWebviewMessage(provider, { type: "clearIndexData" }) manager.clearIndexData.mockRejectedValueOnce(new Error("clear failed")) - await webviewMessageHandler(provider, { type: "clearIndexData" }) + await handleWebviewMessage(provider, { type: "clearIndexData" }) expect(provider.postMessageToWebview).toHaveBeenCalledWith({ type: "indexCleared", diff --git a/src/core/webview/__tests__/WebviewMessageHandlerRegistry.spec.ts b/src/core/webview/__tests__/WebviewMessageHandlerRegistry.spec.ts new file mode 100644 index 0000000000..9294c6b3f6 --- /dev/null +++ b/src/core/webview/__tests__/WebviewMessageHandlerRegistry.spec.ts @@ -0,0 +1,26 @@ +import { WebviewMessageHandlerRegistry, type WebviewMessageFeatureHandler } from "../WebviewMessageHandlerRegistry" + +describe("WebviewMessageHandlerRegistry", () => { + it("routes a message to the first matching feature handler", async () => { + const unmatchedHandler: WebviewMessageFeatureHandler = { + canHandle: vi.fn().mockReturnValue(false), + handle: vi.fn(), + } + const matchingHandler: WebviewMessageFeatureHandler = { + canHandle: vi.fn().mockReturnValue(true), + handle: vi.fn().mockResolvedValue(undefined), + } + const registry = new WebviewMessageHandlerRegistry([unmatchedHandler, matchingHandler]) + const message = { type: "requestIndexingStatus" } as const + + await expect(registry.handle(message)).resolves.toBe(true) + expect(unmatchedHandler.handle).not.toHaveBeenCalled() + expect(matchingHandler.handle).toHaveBeenCalledWith(message) + }) + + it("reports when no feature handler accepts the message", async () => { + const registry = new WebviewMessageHandlerRegistry([]) + + await expect(registry.handle({ type: "clearTask" })).resolves.toBe(false) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..d7316ab719 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 type { WebviewMessageHandlerRegistry } from "./WebviewMessageHandlerRegistry" import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" @@ -122,6 +122,7 @@ export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, marketplaceManager?: MarketplaceManager, + webviewMessageHandlerRegistry?: WebviewMessageHandlerRegistry, ) => { // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) @@ -2996,372 +2997,18 @@ export const webviewMessageHandler = async ( break } - case "saveCodeIndexSettingsAtomic": { - if (!message.codeIndexSettings) { - break - } - - const settings = message.codeIndexSettings - - try { - // Check if embedder provider has changed - const currentConfig = getGlobalState("codebaseIndexConfig") || {} - const embedderProviderChanged = - currentConfig.codebaseIndexEmbedderProvider !== settings.codebaseIndexEmbedderProvider - - // Save global state settings atomically - const globalStateConfig = { - ...currentConfig, - codebaseIndexEnabled: settings.codebaseIndexEnabled, - codebaseIndexQdrantUrl: settings.codebaseIndexQdrantUrl, - codebaseIndexEmbedderProvider: settings.codebaseIndexEmbedderProvider, - codebaseIndexEmbedderBaseUrl: settings.codebaseIndexEmbedderBaseUrl, - codebaseIndexEmbedderModelId: settings.codebaseIndexEmbedderModelId, - codebaseIndexEmbedderModelDimension: settings.codebaseIndexEmbedderModelDimension, // Generic dimension - codebaseIndexOpenAiCompatibleBaseUrl: settings.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexBedrockRegion: settings.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: settings.codebaseIndexBedrockProfile, - codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore, - codebaseIndexOpenRouterSpecificProvider: settings.codebaseIndexOpenRouterSpecificProvider, - } - - // Save global state first - await updateGlobalState("codebaseIndexConfig", globalStateConfig) - - // Save secrets directly using context proxy - if (settings.codeIndexOpenAiKey !== undefined) { - await provider.contextProxy.storeSecret("codeIndexOpenAiKey", settings.codeIndexOpenAiKey) - } - if (settings.codeIndexQdrantApiKey !== undefined) { - await provider.contextProxy.storeSecret("codeIndexQdrantApiKey", settings.codeIndexQdrantApiKey) - } - if (settings.codebaseIndexOpenAiCompatibleApiKey !== undefined) { - await provider.contextProxy.storeSecret( - "codebaseIndexOpenAiCompatibleApiKey", - settings.codebaseIndexOpenAiCompatibleApiKey, - ) - } - if (settings.codebaseIndexGeminiApiKey !== undefined) { - await provider.contextProxy.storeSecret( - "codebaseIndexGeminiApiKey", - settings.codebaseIndexGeminiApiKey, - ) - } - if (settings.codebaseIndexMistralApiKey !== undefined) { - await provider.contextProxy.storeSecret( - "codebaseIndexMistralApiKey", - settings.codebaseIndexMistralApiKey, - ) - } - if (settings.codebaseIndexVercelAiGatewayApiKey !== undefined) { - await provider.contextProxy.storeSecret( - "codebaseIndexVercelAiGatewayApiKey", - settings.codebaseIndexVercelAiGatewayApiKey, - ) - } - if (settings.codebaseIndexOpenRouterApiKey !== undefined) { - await provider.contextProxy.storeSecret( - "codebaseIndexOpenRouterApiKey", - settings.codebaseIndexOpenRouterApiKey, - ) - } - - // Send success response first - settings are saved regardless of validation - await provider.postMessageToWebview({ - type: "codeIndexSettingsSaved", - success: true, - settings: globalStateConfig, - }) - - // Update webview state - await provider.postStateToWebview() - - // Then handle validation and initialization for the current workspace - const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexManager() - if (currentCodeIndexManager) { - // If embedder provider changed, perform proactive validation - if (embedderProviderChanged) { - try { - // Force handleSettingsChange which will trigger validation - await currentCodeIndexManager.handleSettingsChange() - } catch (error) { - // Validation failed - the error state is already set by handleSettingsChange - provider.log( - `Embedder validation failed after provider change: ${error instanceof Error ? error.message : String(error)}`, - ) - // Send validation error to webview - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentCodeIndexManager.getCurrentStatus(), - }) - // Exit early - don't try to start indexing with invalid configuration - break - } - } else { - // No provider change, just handle settings normally - try { - await currentCodeIndexManager.handleSettingsChange() - } catch (error) { - // Log but don't fail - settings are saved - provider.log( - `Settings change handling error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - - // Wait a bit more to ensure everything is ready - await new Promise((resolve) => setTimeout(resolve, 200)) - - // Auto-start indexing if now enabled and configured - if (currentCodeIndexManager.isFeatureEnabled && currentCodeIndexManager.isFeatureConfigured) { - if (!currentCodeIndexManager.isInitialized) { - try { - await currentCodeIndexManager.initialize(provider.contextProxy) - provider.log(`Code index manager initialized after settings save`) - } catch (error) { - provider.log( - `Code index initialization failed: ${error instanceof Error ? error.message : String(error)}`, - ) - // Send error status to webview - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentCodeIndexManager.getCurrentStatus(), - }) - } - } - } - } else { - // No workspace open - send error status - provider.log("Cannot save code index settings: No workspace folder open") - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: { - systemStatus: "Error", - message: t("embeddings:orchestrator.indexingRequiresWorkspace"), - processedItems: 0, - totalItems: 0, - currentItemUnit: "items", - }, - }) - } - } catch (error) { - provider.log(`Error saving code index settings: ${error.message || error}`) - await provider.postMessageToWebview({ - type: "codeIndexSettingsSaved", - success: false, - error: error.message || "Failed to save settings", - }) - } - break - } - - case "requestIndexingStatus": { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - // No workspace open - send error status - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: { - systemStatus: "Error", - message: t("embeddings:orchestrator.indexingRequiresWorkspace"), - processedItems: 0, - totalItems: 0, - currentItemUnit: "items", - workerspacePath: undefined, - }, - }) - return - } - - const status = manager - ? manager.getCurrentStatus() - : { - systemStatus: "Standby", - message: "No workspace folder open", - processedItems: 0, - totalItems: 0, - currentItemUnit: "items", - workspacePath: undefined, - } - - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: status, - }) - break - } - case "requestCodeIndexSecretStatus": { - // Check if secrets are set using the VSCode context directly for async access - const hasOpenAiKey = !!(await provider.context.secrets.get("codeIndexOpenAiKey")) - const hasQdrantApiKey = !!(await provider.context.secrets.get("codeIndexQdrantApiKey")) - const hasOpenAiCompatibleApiKey = !!(await provider.context.secrets.get( - "codebaseIndexOpenAiCompatibleApiKey", - )) - const hasGeminiApiKey = !!(await provider.context.secrets.get("codebaseIndexGeminiApiKey")) - const hasMistralApiKey = !!(await provider.context.secrets.get("codebaseIndexMistralApiKey")) - const hasVercelAiGatewayApiKey = !!(await provider.context.secrets.get( - "codebaseIndexVercelAiGatewayApiKey", - )) - const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey")) - - await provider.postMessageToWebview({ - type: "codeIndexSecretStatus", - values: { - hasOpenAiKey, - hasQdrantApiKey, - hasOpenAiCompatibleApiKey, - hasGeminiApiKey, - hasMistralApiKey, - hasVercelAiGatewayApiKey, - hasOpenRouterApiKey, - }, - }) - break - } - case "startIndexing": { - try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: { - systemStatus: "Error", - message: t("embeddings:orchestrator.indexingRequiresWorkspace"), - processedItems: 0, - totalItems: 0, - currentItemUnit: "items", - }, - }) - provider.log("Cannot start indexing: No workspace folder open") - return - } - - // "Start Indexing" implicitly enables the workspace - await manager.setWorkspaceEnabled(true) - - if (manager.isFeatureEnabled && manager.isFeatureConfigured) { - await manager.initialize(provider.contextProxy) - - const currentState = manager.state - if (currentState === "Standby" || currentState === "Error") { - void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - - if (!manager.isInitialized) { - await manager.initialize(provider.contextProxy) - if (manager.state === "Standby" || manager.state === "Error") { - void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - } - } - } - } - } catch (error) { - provider.log(`Error starting indexing: ${error instanceof Error ? error.message : String(error)}`) - } - break - } - case "stopIndexing": { - try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - provider.log("Cannot stop indexing: No workspace folder open") - return - } - manager.stopIndexing() - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: manager.getCurrentStatus(), - }) - } catch (error) { - provider.log(`Error stopping indexing: ${error instanceof Error ? error.message : String(error)}`) - } - break - } - case "toggleWorkspaceIndexing": { - try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - provider.log("Cannot toggle workspace indexing: No workspace folder open") - return - } - const enabled = message.bool ?? false - await manager.setWorkspaceEnabled(enabled) - if (enabled && manager.isFeatureEnabled && manager.isFeatureConfigured) { - await manager.initialize(provider.contextProxy) - void manager.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - } else if (!enabled) { - manager.stopIndexing() - } - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: manager.getCurrentStatus(), - }) - } catch (error) { - provider.log( - `Error toggling workspace indexing: ${error instanceof Error ? error.message : String(error)}`, - ) - } - break - } - case "setAutoEnableDefault": { - try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - provider.log("Cannot set auto-enable default: No workspace folder open") - return - } - // Capture prior state for every manager before persisting the global change - const allManagers = CodeIndexManager.getAllInstances() - const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) - await manager.setAutoEnableDefault(message.bool ?? true) - // Apply stop/start to every affected manager - for (const m of allManagers) { - const wasEnabled = priorStates.get(m)! - const isNowEnabled = m.isWorkspaceEnabled - if (wasEnabled && !isNowEnabled) { - m.stopIndexing() - } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { - await m.initialize(provider.contextProxy) - void m.startIndexing().catch((err) => provider.log(`Indexing error: ${err}`)) - } - } - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: manager.getCurrentStatus(), - }) - } catch (error) { - provider.log( - `Error setting auto-enable default: ${error instanceof Error ? error.message : String(error)}`, - ) - } - break - } + case "saveCodeIndexSettingsAtomic": + case "requestIndexingStatus": + case "requestCodeIndexSecretStatus": + case "startIndexing": + case "stopIndexing": + case "toggleWorkspaceIndexing": + case "setAutoEnableDefault": case "clearIndexData": { - try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { - provider.log("Cannot clear index data: No workspace folder open") - await provider.postMessageToWebview({ - type: "indexCleared", - values: { - success: false, - error: t("embeddings:orchestrator.indexingRequiresWorkspace"), - }, - }) - return - } - await manager.clearIndexData() - await provider.postMessageToWebview({ type: "indexCleared", values: { success: true } }) - } catch (error) { - provider.log(`Error clearing index data: ${error instanceof Error ? error.message : String(error)}`) - await provider.postMessageToWebview({ - type: "indexCleared", - values: { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - }) + if (!webviewMessageHandlerRegistry) { + throw new Error(`No feature handler registry configured for webview message: ${message.type}`) } + await webviewMessageHandlerRegistry.handle(message) break } case "focusPanelRequest": { 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..05652b10bc 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/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__/code-index-controller.spec.ts b/src/services/code-index/__tests__/code-index-controller.spec.ts new file mode 100644 index 0000000000..616f2bf8ab --- /dev/null +++ b/src/services/code-index/__tests__/code-index-controller.spec.ts @@ -0,0 +1,133 @@ +import { ContextProxy } from "../../../core/config/ContextProxy" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" + +import { CodeIndexController } from "../code-index-controller" +import { CodeIndexManager } from "../manager" +import { CodeIndexStateManager } from "../state-manager" + +vi.mock("vscode", () => ({ + EventEmitter: class { + private readonly listeners = new Set<(value: unknown) => void>() + + public readonly event = (listener: (value: unknown) => void) => { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + public fire(value: unknown) { + this.listeners.forEach((listener) => listener(value)) + } + + public dispose() { + this.listeners.clear() + } + }, +})) + +function createCodeIndexDependencies(): { + codeIndexManager: CodeIndexManager + codeIndexStateManager: CodeIndexStateManager +} { + const workspacePath = "/workspace" + const codeIndexStateManager = new CodeIndexStateManager() + const codeIndexManager = new CodeIndexManager( + workspacePath, + makeUri(workspacePath), + makeExtensionContext(), + codeIndexStateManager, + ) + + vi.spyOn(codeIndexManager, "initialize").mockResolvedValue({ requiresRestart: false }) + vi.spyOn(codeIndexManager, "handleSettingsChange").mockResolvedValue(undefined) + vi.spyOn(codeIndexManager, "startIndexing").mockResolvedValue(undefined) + vi.spyOn(codeIndexManager, "stopIndexing").mockImplementation(() => undefined) + vi.spyOn(codeIndexManager, "setWorkspaceEnabled").mockResolvedValue(undefined) + vi.spyOn(codeIndexManager, "setAutoEnableDefault").mockResolvedValue(undefined) + vi.spyOn(codeIndexManager, "clearIndexData").mockResolvedValue(undefined) + + return { codeIndexManager, codeIndexStateManager } +} + +describe("CodeIndexController", () => { + it("delegates interface commands directly to the code index manager", async () => { + const { codeIndexManager, codeIndexStateManager } = createCodeIndexDependencies() + const contextProxy = {} as ContextProxy + const codeIndexController = new CodeIndexController(codeIndexManager, codeIndexStateManager) + + await codeIndexController.initialize(contextProxy) + await codeIndexController.handleSettingsChange() + await codeIndexController.startIndexing() + codeIndexController.stopIndexing() + await codeIndexController.setWorkspaceEnabled(true) + await codeIndexController.setAutoEnableDefault(false) + await codeIndexController.clearIndexData() + + expect(codeIndexManager.initialize).toHaveBeenCalledWith(contextProxy) + expect(codeIndexManager.handleSettingsChange).toHaveBeenCalledTimes(1) + expect(codeIndexManager.startIndexing).toHaveBeenCalledTimes(1) + expect(codeIndexManager.stopIndexing).toHaveBeenCalledTimes(1) + expect(codeIndexManager.setWorkspaceEnabled).toHaveBeenCalledWith(true) + expect(codeIndexManager.setAutoEnableDefault).toHaveBeenCalledWith(false) + expect(codeIndexManager.clearIndexData).toHaveBeenCalledTimes(1) + }) + + it("combines indexing state with workspace settings", () => { + const { codeIndexManager, codeIndexStateManager } = createCodeIndexDependencies() + vi.spyOn(codeIndexManager, "autoEnableDefault", "get").mockReturnValue(false) + vi.spyOn(codeIndexManager, "isWorkspaceEnabled", "get").mockReturnValue(false) + vi.spyOn(codeIndexStateManager, "getCurrentStatus").mockReturnValue({ + systemStatus: "Standby", + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + }) + const codeIndexController = new CodeIndexController(codeIndexManager, codeIndexStateManager) + + expect(codeIndexController.codeIndexState).toEqual({ + systemStatus: "Standby", + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath: "/workspace", + workspaceEnabled: false, + autoEnableDefault: false, + }) + expect(codeIndexController.getCurrentStatus()).toEqual(codeIndexController.codeIndexState) + }) + + it("emits complete view state when indexing progress changes", () => { + const { codeIndexManager, codeIndexStateManager } = createCodeIndexDependencies() + vi.spyOn(codeIndexManager, "autoEnableDefault", "get").mockReturnValue(true) + vi.spyOn(codeIndexManager, "isWorkspaceEnabled", "get").mockReturnValue(true) + const codeIndexController = new CodeIndexController(codeIndexManager, codeIndexStateManager) + const listener = vi.fn() + codeIndexController.onDidChangeCodeIndexState(listener) + + codeIndexStateManager.setSystemState("Indexing", "Scanning") + + expect(listener).toHaveBeenCalledWith({ + systemStatus: "Indexing", + message: "Scanning", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: true, + }) + }) + + it("emits state after workspace settings change", async () => { + const { codeIndexManager, codeIndexStateManager } = createCodeIndexDependencies() + const codeIndexController = new CodeIndexController(codeIndexManager, codeIndexStateManager) + const listener = vi.fn() + codeIndexController.onDidChangeCodeIndexState(listener) + + await codeIndexController.setWorkspaceEnabled(false) + await codeIndexController.setAutoEnableDefault(false) + + expect(listener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/services/code-index/__tests__/code-index-scope.spec.ts b/src/services/code-index/__tests__/code-index-scope.spec.ts new file mode 100644 index 0000000000..f4d81e54ee --- /dev/null +++ b/src/services/code-index/__tests__/code-index-scope.spec.ts @@ -0,0 +1,48 @@ +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" + +import { CodeIndexController } from "../code-index-controller" +import { CodeIndexManager } from "../manager" +import { CodeIndexScope } from "../code-index-scope" +import { CodeIndexStateManager } from "../state-manager" + +vi.mock("vscode", () => ({ + EventEmitter: class { + public readonly event = vi.fn().mockReturnValue({ dispose: vi.fn() }) + public fire = vi.fn() + public dispose = vi.fn() + }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +describe("CodeIndexScope", () => { + it("owns one code index manager for the workspace lifetime", () => { + const workspacePath = "/workspace" + const folderUri = makeUri(workspacePath) + const context = makeExtensionContext() + + const codeIndexScope = new CodeIndexScope(workspacePath, folderUri, context) + + expect(codeIndexScope.codeIndexStateManager).toBeInstanceOf(CodeIndexStateManager) + expect(CodeIndexManager).toHaveBeenCalledExactlyOnceWith( + workspacePath, + folderUri, + context, + codeIndexScope.codeIndexStateManager, + ) + expect(codeIndexScope.codeIndexManager).toBeInstanceOf(Object) + expect(codeIndexScope.codeIndexController).toBeInstanceOf(CodeIndexController) + expect(codeIndexScope.codeIndexController["codeIndexManager"]).toBe(codeIndexScope.codeIndexManager) + expect(codeIndexScope.codeIndexController["codeIndexStateManager"]).toBe(codeIndexScope.codeIndexStateManager) + const controllerDispose = vi.spyOn(codeIndexScope.codeIndexController, "dispose") + + codeIndexScope.dispose() + + expect(controllerDispose).toHaveBeenCalledTimes(1) + expect(codeIndexScope.codeIndexManager.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/services/code-index/__tests__/code-index-webview-message-handler.spec.ts b/src/services/code-index/__tests__/code-index-webview-message-handler.spec.ts new file mode 100644 index 0000000000..8ec4bbe1f1 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-webview-message-handler.spec.ts @@ -0,0 +1,123 @@ +import { makeExtensionContext } from "../../../test-utils/vscode" +import { ContextProxy } from "../../../core/config/ContextProxy" + +import { CodeIndexWebviewMessageHandler } from "../code-index-webview-message-handler" + +vi.mock("vscode", () => ({})) + +function createProvider(codeIndexScope?: ReturnType) { + const context = makeExtensionContext() + const contextProxy = new ContextProxy(context) + vi.spyOn(contextProxy, "getValue").mockReturnValue({}) + vi.spyOn(contextProxy, "setValue").mockResolvedValue(undefined) + vi.spyOn(contextProxy, "storeSecret").mockResolvedValue(undefined) + return { + context, + contextProxy, + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue(codeIndexScope), + log: vi.fn(), + postMessageToWebview: vi.fn().mockResolvedValue(true), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } +} + +function createCodeIndexScope() { + const codeIndexState = { + systemStatus: "Standby" as const, + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: true, + } + return { + codeIndexManager: { + isWorkspaceEnabled: true, + isFeatureEnabled: true, + isFeatureConfigured: true, + isInitialized: true, + state: "Standby" as const, + }, + codeIndexController: { + codeIndexState, + handleSettingsChange: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), + startIndexing: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn(), + setWorkspaceEnabled: vi.fn().mockResolvedValue(undefined), + setAutoEnableDefault: vi.fn().mockResolvedValue(undefined), + clearIndexData: vi.fn().mockResolvedValue(undefined), + }, + } +} + +describe("CodeIndexWebviewMessageHandler", () => { + it("accepts only code-index messages", () => { + const handler = new CodeIndexWebviewMessageHandler(createProvider()) + + expect(handler.canHandle({ type: "requestIndexingStatus" })).toBe(true) + expect(handler.canHandle({ type: "clearTask" })).toBe(false) + }) + + it("sends the complete code index state", async () => { + const codeIndexScope = createCodeIndexScope() + const provider = createProvider(codeIndexScope) + const handler = new CodeIndexWebviewMessageHandler(provider) + + await handler.handle({ type: "requestIndexingStatus" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: codeIndexScope.codeIndexController.codeIndexState, + }) + }) + + it("reports that indexing requires a workspace", async () => { + const provider = createProvider() + const handler = new CodeIndexWebviewMessageHandler(provider) + + await handler.handle({ type: "startIndexing" }) + + expect(provider.log).toHaveBeenCalledWith("Cannot start indexing: No workspace folder open") + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: expect.objectContaining({ systemStatus: "Error" }), + }) + }) + + it("delegates workspace indexing changes and returns the updated state", async () => { + const codeIndexScope = createCodeIndexScope() + const provider = createProvider(codeIndexScope) + const handler = new CodeIndexWebviewMessageHandler(provider) + + await handler.handle({ type: "toggleWorkspaceIndexing", bool: false }) + + expect(codeIndexScope.codeIndexController.setWorkspaceEnabled).toHaveBeenCalledWith(false) + expect(codeIndexScope.codeIndexController.stopIndexing).toHaveBeenCalledTimes(1) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: codeIndexScope.codeIndexController.codeIndexState, + }) + }) + + it("returns successful and failed clear-index results", async () => { + const codeIndexScope = createCodeIndexScope() + const provider = createProvider(codeIndexScope) + const handler = new CodeIndexWebviewMessageHandler(provider) + + await handler.handle({ type: "clearIndexData" }) + codeIndexScope.codeIndexController.clearIndexData.mockRejectedValueOnce(new Error("clear failed")) + await handler.handle({ type: "clearIndexData" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexCleared", + values: { success: true }, + }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexCleared", + values: { success: false, error: "clear failed" }, + }) + }) +}) 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..36dc3227f5 --- /dev/null +++ b/src/services/code-index/__tests__/manager-registry.spec.ts @@ -0,0 +1,198 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" +import { CodeIndexDisposalError } from "../errors/code-index-disposal-error" + +vi.mock("vscode", () => ({ + EventEmitter: class { + public readonly event = vi.fn().mockReturnValue({ dispose: vi.fn() }) + public fire = vi.fn() + public dispose = vi.fn() + }, + 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, + expect.anything(), + ) + }) + + it("returns the workspace scope alongside the manager accessor", () => { + const codeIndexScope = CodeIndexManagerRegistry.getCodeIndexScope(context, first.uri.fsPath) + + expect(codeIndexScope).toBeDefined() + expect(CodeIndexManagerRegistry.getCodeIndexScope(context, first.uri.fsPath)).toBe(codeIndexScope) + expect(CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)).toBe(codeIndexScope?.codeIndexManager) + }) + + 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, expect.anything()) + }) + + 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, expect.anything()) + }) + + it("prefers an explicit workspace over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(second.uri.fsPath, second.uri, context, expect.anything()) + expect(vscode.workspace.getWorkspaceFolder).not.toHaveBeenCalled() + }) + + it("creates a file URI for an explicit path outside workspace folders", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { value: undefined }) + const uri = makeUri("/outside") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + CodeIndexManagerRegistry.getInstance(context, "/outside") + expect(vscode.Uri.file).toHaveBeenCalledWith("/outside") + expect(CodeIndexManager).toHaveBeenCalledWith("/outside", uri, context, expect.anything()) + }) + + 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("lists all registered scopes", () => { + const firstScope = CodeIndexManagerRegistry.getCodeIndexScope(context, first.uri.fsPath)! + const secondScope = CodeIndexManagerRegistry.getCodeIndexScope(context, second.uri.fsPath)! + + expect(CodeIndexManagerRegistry.getAllCodeIndexScopes()).toEqual([firstScope, secondScope]) + }) + + 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 reports all errors", () => { + const a = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + const b = CodeIndexManagerRegistry.getInstance(context, second.uri.fsPath)! + const firstError = new Error("first cleanup failed") + const secondError = new Error("second cleanup failed") + vi.mocked(a.dispose).mockImplementation(() => { + throw firstError + }) + vi.mocked(b.dispose).mockImplementation(() => { + throw secondError + }) + let caught: unknown + try { + CodeIndexManagerRegistry.disposeAll() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(CodeIndexDisposalError) + if (!(caught instanceof CodeIndexDisposalError)) throw new Error("Expected disposal error") + expect(caught.name).toBe("CodeIndexDisposalError") + expect(caught.errors).toEqual([firstError, secondError]) + expect(caught.errors[0]).toBe(firstError) + expect(caught.errors[1]).toBe(secondError) + expect(caught.message).toBe( + "Failed to dispose code index managers (2 errors):\n1. first cleanup failed\n2. second cleanup failed", + ) + expect(b.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + }) + + it("preserves non-Error thrown values in disposal diagnostics", () => { + const manager = CodeIndexManagerRegistry.getInstance(context, first.uri.fsPath)! + vi.mocked(manager.dispose).mockImplementation(() => { + throw "cleanup rejected" + }) + expect(() => CodeIndexManagerRegistry.disposeAll()).toThrow( + "Failed to dispose code index managers (1 errors):\n1. cleanup rejected", + ) + }) + + 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..cf9e3d77f9 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 "../code-index-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") { @@ -20,6 +22,11 @@ vi.mock("vscode", () => { const testPath = require("path") const testWorkspacePath = testPath.join(testPath.sep, "test", "workspace") return { + EventEmitter: class { + public readonly event = vi.fn().mockReturnValue({ dispose: vi.fn() }) + public fire = vi.fn() + public dispose = vi.fn() + }, Uri: { file: (p: string) => ({ fsPath: p, @@ -93,7 +100,7 @@ vi.mock("ignore", () => ({ vi.mock("../state-manager", () => ({ CodeIndexStateManager: vi.fn().mockImplementation(function () { return { - onProgressUpdate: vi.fn(), + onProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), getCurrentStatus: vi.fn(), dispose: vi.fn(), setSystemState: vi.fn(), @@ -126,7 +133,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { beforeEach(() => { // Clear all instances before each test - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -160,11 +167,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { languageModelAccessInformation: {} as any, } - manager = CodeIndexManager.getInstance(mockContext)! + manager = CodeIndexManagerRegistry.getInstance(mockContext)! }) afterEach(() => { - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() }) describe("handleSettingsChange", () => { @@ -634,10 +641,6 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { currentItemUnit: "items", }) - // Verify initial state is not error - const initialStatus = manager.getCurrentStatus() - expect(initialStatus.systemStatus).not.toBe("Error") - // Act - call recoverFromError when not in error state await expect(manager.recoverFromError()).resolves.not.toThrow() @@ -705,22 +708,6 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { expect(mockStateManager.setSystemState).not.toHaveBeenCalledWith("Indexing", expect.any(String)) }) - it("should include workspaceEnabled in getCurrentStatus", async () => { - await manager.setAutoEnableDefault(false) - - const mockStateManager = (manager as any)._stateManager - mockStateManager.getCurrentStatus = vi.fn().mockReturnValue({ - systemStatus: "Standby", - message: "", - processedItems: 0, - totalItems: 0, - currentItemUnit: "items", - }) - - const status = manager.getCurrentStatus() - expect(status.workspaceEnabled).toBe(false) - }) - it("should persist workspace enabled state", async () => { await manager.setAutoEnableDefault(false) expect(manager.isWorkspaceEnabled).toBe(false) @@ -733,7 +720,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 +730,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 +755,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 +775,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-controller.ts b/src/services/code-index/code-index-controller.ts new file mode 100644 index 0000000000..1145a1d005 --- /dev/null +++ b/src/services/code-index/code-index-controller.ts @@ -0,0 +1,94 @@ +import * as vscode from "vscode" + +import { ContextProxy } from "../../core/config/ContextProxy" + +import { CodeIndexManager } from "./manager" +import { CodeIndexState } from "./models/code-index-state" +import { CodeIndexStateManager } from "./state-manager" + +type CodeIndexManagerControllerPort = Pick< + CodeIndexManager, + | "workspacePath" + | "isWorkspaceEnabled" + | "autoEnableDefault" + | "initialize" + | "handleSettingsChange" + | "startIndexing" + | "stopIndexing" + | "setWorkspaceEnabled" + | "setAutoEnableDefault" + | "clearIndexData" +> + +/** + * Exposes code-index commands to interface adapters without owning interface workflow. + */ +export class CodeIndexController { + private readonly codeIndexStateEmitter = new vscode.EventEmitter() + private readonly progressSubscription: vscode.Disposable + + public readonly onDidChangeCodeIndexState = this.codeIndexStateEmitter.event + + public constructor( + private readonly codeIndexManager: CodeIndexManagerControllerPort, + private readonly codeIndexStateManager: CodeIndexStateManager, + ) { + this.progressSubscription = this.codeIndexStateManager.onProgressUpdate(() => { + this.codeIndexStateEmitter.fire(this.codeIndexState) + }) + } + + public get codeIndexState(): CodeIndexState { + const status = this.codeIndexStateManager.getCurrentStatus() + return { + systemStatus: status.systemStatus, + message: status.message, + processedItems: status.processedItems, + totalItems: status.totalItems, + currentItemUnit: status.currentItemUnit, + workspacePath: this.codeIndexManager.workspacePath, + workspaceEnabled: this.codeIndexManager.isWorkspaceEnabled, + autoEnableDefault: this.codeIndexManager.autoEnableDefault, + } + } + + /** @deprecated Use codeIndexState. */ + public getCurrentStatus(): CodeIndexState { + return this.codeIndexState + } + + public initialize(contextProxy: ContextProxy): Promise<{ requiresRestart: boolean }> { + return this.codeIndexManager.initialize(contextProxy) + } + + public handleSettingsChange(): Promise { + return this.codeIndexManager.handleSettingsChange() + } + + public startIndexing(): Promise { + return this.codeIndexManager.startIndexing() + } + + public stopIndexing(): void { + this.codeIndexManager.stopIndexing() + } + + public async setWorkspaceEnabled(enabled: boolean): Promise { + await this.codeIndexManager.setWorkspaceEnabled(enabled) + this.codeIndexStateEmitter.fire(this.codeIndexState) + } + + public async setAutoEnableDefault(enabled: boolean): Promise { + await this.codeIndexManager.setAutoEnableDefault(enabled) + this.codeIndexStateEmitter.fire(this.codeIndexState) + } + + public clearIndexData(): Promise { + return this.codeIndexManager.clearIndexData() + } + + public dispose(): void { + this.progressSubscription.dispose() + this.codeIndexStateEmitter.dispose() + } +} 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..353ea16872 --- /dev/null +++ b/src/services/code-index/code-index-manager-registry.ts @@ -0,0 +1,77 @@ +import * as vscode from "vscode" +import { CodeIndexManager } from "./manager" +import { CodeIndexScope } from "./code-index-scope" +import { CodeIndexDisposalError } from "./errors/code-index-disposal-error" + +/** Creates and retains one code index scope per workspace path. */ +export class CodeIndexManagerRegistry { + private static scopesByWorkspacePath = new Map() + + public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { + return this.getCodeIndexScope(context, workspacePath)?.codeIndexManager + } + + public static getCodeIndexScope( + context: vscode.ExtensionContext, + workspacePath?: string, + ): CodeIndexScope | undefined { + const folder = this.resolveWorkspaceFolder(workspacePath) + workspacePath = workspacePath || folder?.uri.fsPath + if (!workspacePath) { + return undefined + } + + const scopesByWorkspacePath = CodeIndexManagerRegistry.scopesByWorkspacePath + const existingScope = scopesByWorkspacePath.get(workspacePath) + if (existingScope) { + return existingScope + } + + // folder may be undefined when workspacePath was provided but doesn't match + // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. + const folderUri = folder?.uri ?? vscode.Uri.file(workspacePath) + const codeIndexScope = new CodeIndexScope(workspacePath, folderUri, context) + scopesByWorkspacePath.set(workspacePath, codeIndexScope) + return codeIndexScope + } + + 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] + } + + public static getAllInstances(): CodeIndexManager[] { + return Array.from(CodeIndexManagerRegistry.scopesByWorkspacePath.values(), (scope) => scope.codeIndexManager) + } + + public static getAllCodeIndexScopes(): CodeIndexScope[] { + return Array.from(CodeIndexManagerRegistry.scopesByWorkspacePath.values()) + } + + public static disposeAll(): void { + const scopes = Array.from(CodeIndexManagerRegistry.scopesByWorkspacePath.values()) + CodeIndexManagerRegistry.scopesByWorkspacePath.clear() + const errors: unknown[] = [] + for (const scope of scopes) { + try { + scope.dispose() + } catch (error) { + errors.push(error) + } + } + if (errors.length > 0) { + throw new CodeIndexDisposalError(errors) + } + } +} diff --git a/src/services/code-index/code-index-scope.ts b/src/services/code-index/code-index-scope.ts new file mode 100644 index 0000000000..49afae3c41 --- /dev/null +++ b/src/services/code-index/code-index-scope.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode" + +import { CodeIndexController } from "./code-index-controller" +import { CodeIndexManager } from "./manager" +import { CodeIndexStateManager } from "./state-manager" + +/** Owns code-index dependencies whose lifetime is bound to one workspace. */ +export class CodeIndexScope { + public readonly codeIndexStateManager: CodeIndexStateManager + public readonly codeIndexManager: CodeIndexManager + public readonly codeIndexController: CodeIndexController + + public constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + this.codeIndexStateManager = new CodeIndexStateManager() + this.codeIndexManager = new CodeIndexManager(workspacePath, folderUri, context, this.codeIndexStateManager) + this.codeIndexController = new CodeIndexController(this.codeIndexManager, this.codeIndexStateManager) + } + + public dispose(): void { + this.codeIndexController.dispose() + this.codeIndexManager.dispose() + } +} diff --git a/src/services/code-index/code-index-webview-message-handler.ts b/src/services/code-index/code-index-webview-message-handler.ts new file mode 100644 index 0000000000..f4189f9746 --- /dev/null +++ b/src/services/code-index/code-index-webview-message-handler.ts @@ -0,0 +1,356 @@ +import type { GlobalState, WebviewMessage } from "@roo-code/types" +import type * as vscode from "vscode" + +import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { ContextProxy } from "../../core/config/ContextProxy" +import type { WebviewMessageFeatureHandler } from "../../core/webview/WebviewMessageHandlerRegistry" +import { t } from "../../i18n" +import { CodeIndexManagerRegistry } from "./code-index-manager-registry" +import type { CodeIndexState } from "./models/code-index-state" + +type CodeIndexWebviewProvider = Pick< + ClineProvider, + "getCurrentWorkspaceCodeIndexScope" | "log" | "postMessageToWebview" | "postStateToWebview" +> & { + context: { secrets: Pick } + contextProxy: ContextProxy +} + +const codeIndexWebviewMessageTypes: ReadonlySet = new Set([ + "saveCodeIndexSettingsAtomic", + "requestIndexingStatus", + "requestCodeIndexSecretStatus", + "startIndexing", + "stopIndexing", + "toggleWorkspaceIndexing", + "setAutoEnableDefault", + "clearIndexData", +]) + +export class CodeIndexWebviewMessageHandler implements WebviewMessageFeatureHandler { + public constructor(private readonly provider: CodeIndexWebviewProvider) {} + + public canHandle(message: WebviewMessage): boolean { + return codeIndexWebviewMessageTypes.has(message.type) + } + + public async handle(message: WebviewMessage): Promise { + switch (message.type) { + case "saveCodeIndexSettingsAtomic": + await this.saveCodeIndexSettings(message) + break + case "requestIndexingStatus": + await this.sendIndexingStatus() + break + case "requestCodeIndexSecretStatus": + await this.sendCodeIndexSecretStatus() + break + case "startIndexing": + await this.startIndexing() + break + case "stopIndexing": + await this.stopIndexing() + break + case "toggleWorkspaceIndexing": + await this.toggleWorkspaceIndexing(message.bool ?? false) + break + case "setAutoEnableDefault": + await this.setAutoEnableDefault(message.bool ?? true) + break + case "clearIndexData": + await this.clearIndexData() + break + } + } + + private async saveCodeIndexSettings(message: WebviewMessage): Promise { + if (!message.codeIndexSettings) return + + const settings = message.codeIndexSettings + try { + const currentConfig = this.provider.contextProxy.getValue("codebaseIndexConfig") || {} + const embedderProviderChanged = + currentConfig.codebaseIndexEmbedderProvider !== settings.codebaseIndexEmbedderProvider + const globalStateConfig: NonNullable = { + ...currentConfig, + codebaseIndexEnabled: settings.codebaseIndexEnabled, + codebaseIndexQdrantUrl: settings.codebaseIndexQdrantUrl, + codebaseIndexEmbedderProvider: settings.codebaseIndexEmbedderProvider, + codebaseIndexEmbedderBaseUrl: settings.codebaseIndexEmbedderBaseUrl, + codebaseIndexEmbedderModelId: settings.codebaseIndexEmbedderModelId, + codebaseIndexEmbedderModelDimension: settings.codebaseIndexEmbedderModelDimension, + codebaseIndexOpenAiCompatibleBaseUrl: settings.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexBedrockRegion: settings.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: settings.codebaseIndexBedrockProfile, + codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore, + codebaseIndexOpenRouterSpecificProvider: settings.codebaseIndexOpenRouterSpecificProvider, + } + + await this.provider.contextProxy.setValue("codebaseIndexConfig", globalStateConfig) + await this.storeCodeIndexSecrets(settings) + await this.provider.postMessageToWebview({ + type: "codeIndexSettingsSaved", + success: true, + settings: globalStateConfig, + }) + await this.provider.postStateToWebview() + await this.applySavedSettings(embedderProviderChanged) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.provider.log(`Error saving code index settings: ${errorMessage}`) + await this.provider.postMessageToWebview({ + type: "codeIndexSettingsSaved", + success: false, + error: errorMessage || "Failed to save settings", + }) + } + } + + private async storeCodeIndexSecrets(settings: NonNullable): Promise { + const secrets = [ + ["codeIndexOpenAiKey", settings.codeIndexOpenAiKey], + ["codeIndexQdrantApiKey", settings.codeIndexQdrantApiKey], + ["codebaseIndexOpenAiCompatibleApiKey", settings.codebaseIndexOpenAiCompatibleApiKey], + ["codebaseIndexGeminiApiKey", settings.codebaseIndexGeminiApiKey], + ["codebaseIndexMistralApiKey", settings.codebaseIndexMistralApiKey], + ["codebaseIndexVercelAiGatewayApiKey", settings.codebaseIndexVercelAiGatewayApiKey], + ["codebaseIndexOpenRouterApiKey", settings.codebaseIndexOpenRouterApiKey], + ] as const + + for (const [key, value] of secrets) { + if (value !== undefined) await this.provider.contextProxy.storeSecret(key, value) + } + } + + private async applySavedSettings(embedderProviderChanged: boolean): Promise { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + this.provider.log("Cannot save code index settings: No workspace folder open") + await this.sendWorkspaceRequiredStatus() + return + } + + const { codeIndexManager, codeIndexController } = codeIndexScope + try { + await codeIndexController.handleSettingsChange() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.provider.log( + embedderProviderChanged + ? `Embedder validation failed after provider change: ${errorMessage}` + : `Settings change handling error: ${errorMessage}`, + ) + if (embedderProviderChanged) { + await this.postCodeIndexState(codeIndexController.codeIndexState) + return + } + } + + await new Promise((resolve) => setTimeout(resolve, 200)) + if ( + codeIndexManager.isFeatureEnabled && + codeIndexManager.isFeatureConfigured && + !codeIndexManager.isInitialized + ) { + try { + await codeIndexController.initialize(this.provider.contextProxy) + this.provider.log("Code index manager initialized after settings save") + } catch (error) { + this.provider.log( + `Code index initialization failed: ${error instanceof Error ? error.message : String(error)}`, + ) + await this.postCodeIndexState(codeIndexController.codeIndexState) + } + } + } + + private async sendIndexingStatus(): Promise { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + await this.sendWorkspaceRequiredStatus() + return + } + await this.postCodeIndexState(codeIndexScope.codeIndexController.codeIndexState) + } + + private async sendCodeIndexSecretStatus(): Promise { + const getSecret = (key: string) => this.provider.context.secrets.get(key) + const [ + openAiKey, + qdrantApiKey, + openAiCompatibleApiKey, + geminiApiKey, + mistralApiKey, + vercelApiKey, + openRouterKey, + ] = await Promise.all([ + getSecret("codeIndexOpenAiKey"), + getSecret("codeIndexQdrantApiKey"), + getSecret("codebaseIndexOpenAiCompatibleApiKey"), + getSecret("codebaseIndexGeminiApiKey"), + getSecret("codebaseIndexMistralApiKey"), + getSecret("codebaseIndexVercelAiGatewayApiKey"), + getSecret("codebaseIndexOpenRouterApiKey"), + ]) + await this.provider.postMessageToWebview({ + type: "codeIndexSecretStatus", + values: { + hasOpenAiKey: !!openAiKey, + hasQdrantApiKey: !!qdrantApiKey, + hasOpenAiCompatibleApiKey: !!openAiCompatibleApiKey, + hasGeminiApiKey: !!geminiApiKey, + hasMistralApiKey: !!mistralApiKey, + hasVercelAiGatewayApiKey: !!vercelApiKey, + hasOpenRouterApiKey: !!openRouterKey, + }, + }) + } + + private async startIndexing(): Promise { + try { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + await this.sendWorkspaceRequiredStatus() + this.provider.log("Cannot start indexing: No workspace folder open") + return + } + const { codeIndexManager, codeIndexController } = codeIndexScope + await codeIndexController.setWorkspaceEnabled(true) + if (!codeIndexManager.isFeatureEnabled || !codeIndexManager.isFeatureConfigured) return + + await codeIndexController.initialize(this.provider.contextProxy) + if (codeIndexManager.state === "Standby" || codeIndexManager.state === "Error") { + this.startIndexingDetached(codeIndexController) + if (!codeIndexManager.isInitialized) { + await codeIndexController.initialize(this.provider.contextProxy) + if (codeIndexManager.state === "Standby" || codeIndexManager.state === "Error") { + this.startIndexingDetached(codeIndexController) + } + } + } + } catch (error) { + this.provider.log(`Error starting indexing: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async stopIndexing(): Promise { + try { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + this.provider.log("Cannot stop indexing: No workspace folder open") + return + } + codeIndexScope.codeIndexController.stopIndexing() + await this.postCodeIndexState(codeIndexScope.codeIndexController.codeIndexState) + } catch (error) { + this.provider.log(`Error stopping indexing: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async toggleWorkspaceIndexing(enabled: boolean): Promise { + try { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + this.provider.log("Cannot toggle workspace indexing: No workspace folder open") + return + } + const { codeIndexManager, codeIndexController } = codeIndexScope + await codeIndexController.setWorkspaceEnabled(enabled) + if (enabled && codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured) { + await codeIndexController.initialize(this.provider.contextProxy) + this.startIndexingDetached(codeIndexController) + } else if (!enabled) { + codeIndexController.stopIndexing() + } + await this.postCodeIndexState(codeIndexController.codeIndexState) + } catch (error) { + this.provider.log( + `Error toggling workspace indexing: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async setAutoEnableDefault(enabled: boolean): Promise { + try { + const currentCodeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!currentCodeIndexScope) { + this.provider.log("Cannot set auto-enable default: No workspace folder open") + return + } + const allCodeIndexScopes = CodeIndexManagerRegistry.getAllCodeIndexScopes() + const priorStates = new Map( + allCodeIndexScopes.map((codeIndexScope) => [ + codeIndexScope, + codeIndexScope.codeIndexManager.isWorkspaceEnabled, + ]), + ) + await currentCodeIndexScope.codeIndexController.setAutoEnableDefault(enabled) + for (const codeIndexScope of allCodeIndexScopes) { + const { codeIndexManager, codeIndexController } = codeIndexScope + const wasEnabled = priorStates.get(codeIndexScope)! + const isNowEnabled = codeIndexManager.isWorkspaceEnabled + if (wasEnabled && !isNowEnabled) { + codeIndexController.stopIndexing() + } else if ( + !wasEnabled && + isNowEnabled && + codeIndexManager.isFeatureEnabled && + codeIndexManager.isFeatureConfigured + ) { + await codeIndexController.initialize(this.provider.contextProxy) + this.startIndexingDetached(codeIndexController) + } + } + await this.postCodeIndexState(currentCodeIndexScope.codeIndexController.codeIndexState) + } catch (error) { + this.provider.log( + `Error setting auto-enable default: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async clearIndexData(): Promise { + try { + const codeIndexScope = this.provider.getCurrentWorkspaceCodeIndexScope() + if (!codeIndexScope) { + this.provider.log("Cannot clear index data: No workspace folder open") + await this.provider.postMessageToWebview({ + type: "indexCleared", + values: { success: false, error: t("embeddings:orchestrator.indexingRequiresWorkspace") }, + }) + return + } + await codeIndexScope.codeIndexController.clearIndexData() + await this.provider.postMessageToWebview({ type: "indexCleared", values: { success: true } }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.provider.log(`Error clearing index data: ${errorMessage}`) + await this.provider.postMessageToWebview({ + type: "indexCleared", + values: { success: false, error: errorMessage }, + }) + } + } + + private startIndexingDetached(codeIndexController: { startIndexing(): Promise }): void { + void codeIndexController.startIndexing().catch((error) => this.provider.log(`Indexing error: ${error}`)) + } + + private async postCodeIndexState(values: CodeIndexState): Promise { + await this.provider.postMessageToWebview({ type: "indexingStatusUpdate", values }) + } + + private async sendWorkspaceRequiredStatus(): Promise { + await this.provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: { + systemStatus: "Error", + message: t("embeddings:orchestrator.indexingRequiresWorkspace"), + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + }, + }) + } +} diff --git a/src/services/code-index/errors/code-index-disposal-error.ts b/src/services/code-index/errors/code-index-disposal-error.ts new file mode 100644 index 0000000000..88454ef665 --- /dev/null +++ b/src/services/code-index/errors/code-index-disposal-error.ts @@ -0,0 +1,12 @@ +/** Reports every failure encountered while disposing code index managers. */ +export class CodeIndexDisposalError extends AggregateError { + declare errors: unknown[] + + constructor(errors: readonly unknown[]) { + const details = errors.map( + (error, index) => `${index + 1}. ${error instanceof Error ? error.message : String(error)}`, + ) + super(errors, `Failed to dispose code index managers (${errors.length} errors):\n${details.join("\n")}`) + this.name = "CodeIndexDisposalError" + } +} diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd36a32d88..5d688b4767 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,65 +30,20 @@ export class CodeIndexManager { // Flag to prevent race conditions during error recovery private _isRecoveringFromError = false - public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - // Resolve the workspace folder to get both fsPath and the real URI - let folder: vscode.WorkspaceFolder | undefined - - if (workspacePath) { - folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === workspacePath) - } else { - const activeEditor = vscode.window.activeTextEditor - if (activeEditor) { - folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) - } - if (!folder) { - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - return undefined - } - folder = workspaceFolders[0] - } - workspacePath = folder.uri.fsPath - } - - if (!CodeIndexManager.instances.has(workspacePath)) { - // folder may be undefined when workspacePath was provided but doesn't match - // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. - const folderUri = - folder?.uri ?? - ({ - fsPath: workspacePath, - scheme: "file", - authority: "", - path: workspacePath, - toString: () => `file://${workspacePath}`, - } as unknown as vscode.Uri) - CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, folderUri, context)) - } - return CodeIndexManager.instances.get(workspacePath)! - } - - public static getAllInstances(): CodeIndexManager[] { - return Array.from(CodeIndexManager.instances.values()) - } - - public static disposeAll(): void { - for (const instance of CodeIndexManager.instances.values()) { - instance.dispose() - } - CodeIndexManager.instances.clear() - } - - private readonly workspacePath: string + public readonly workspacePath: string private readonly _folderUri: vscode.Uri private readonly context: vscode.ExtensionContext - // Private constructor for singleton pattern - private constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + public constructor( + workspacePath: string, + folderUri: vscode.Uri, + context: vscode.ExtensionContext, + stateManager: CodeIndexStateManager, + ) { this.workspacePath = workspacePath this._folderUri = folderUri this.context = context - this._stateManager = new CodeIndexStateManager() + this._stateManager = stateManager } // --- Public API --- @@ -259,7 +211,7 @@ export class CodeIndexManager { } // Check if we're in error state and recover if needed - const currentStatus = this.getCurrentStatus() + const currentStatus = this._stateManager.getCurrentStatus() if (currentStatus.systemStatus === "Error") { await this.recoverFromError() @@ -369,16 +321,6 @@ export class CodeIndexManager { // --- Private Helpers --- - public getCurrentStatus() { - const status = this._stateManager.getCurrentStatus() - return { - ...status, - workspacePath: this.workspacePath, - workspaceEnabled: this.isWorkspaceEnabled, - autoEnableDefault: this.autoEnableDefault, - } - } - public async searchIndex(query: string, directoryPrefix?: string): Promise { if (!this.isFeatureEnabled) { return [] diff --git a/src/services/code-index/models/code-index-state.ts b/src/services/code-index/models/code-index-state.ts new file mode 100644 index 0000000000..d862869fdf --- /dev/null +++ b/src/services/code-index/models/code-index-state.ts @@ -0,0 +1,15 @@ +import type { IndexingState } from "../state-manager" + +/** + * Complete code-index state exposed to interface adapters. + */ +export interface CodeIndexState { + systemStatus: IndexingState + message: string + processedItems: number + totalItems: number + currentItemUnit: string + workspacePath: string + workspaceEnabled: boolean + autoEnableDefault: boolean +}