From 68383e76eb84c290ec5f94ed42953c8cd8797137 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Fri, 25 Sep 2026 17:21:34 +0300 Subject: [PATCH] refactor(code-index): route workspace actions through scopes Extract workspace enablement coordination and status publication, expose full workspace scopes, and replace direct manager access in webview handlers. Add toggle guard and default coverage, sync upstream main, and align the CodeRabbit test with upstream chat access policy. Refs Zoo-Code-Org/Zoo-Code#1594 --- src/core/webview/ClineProvider.ts | 10 +- .../webview/__tests__/ClineProvider.spec.ts | 133 ++++++++++++++++-- src/core/webview/webviewMessageHandler.ts | 43 +++--- .../code-index-manager-registry.spec.ts | 12 ++ .../code-index-workspace-scope.spec.ts | 14 ++ ...kspace-indexing-enablement-manager.spec.ts | 103 ++++++++++++++ .../code-index/code-index-manager-registry.ts | 12 +- .../code-index/code-index-workspace-scope.ts | 8 ++ .../workspace-indexing-enablement-manager.ts | 35 +++++ 9 files changed, 326 insertions(+), 44 deletions(-) create mode 100644 src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts create mode 100644 src/services/code-index/workspace-indexing-enablement-manager.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6fac386383..718d6430c1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -90,8 +90,8 @@ import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" -import type { CodeIndexManager } from "../../services/code-index/manager" import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" +import type { CodeIndexWorkspaceScope } from "../../services/code-index/code-index-workspace-scope" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -3219,12 +3219,8 @@ export class ClineProvider return true } - /** - * Gets the CodeIndexManager for the current active workspace - * @returns CodeIndexManager instance for the current workspace or the default one - */ - public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManagerRegistry.getOrCreate(this.context) + public getCurrentWorkspaceCodeIndexScope(): CodeIndexWorkspaceScope | undefined { + return CodeIndexManagerRegistry.getOrCreateScope(this.context) } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index feef95d870..29a8ed53f6 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -27,6 +27,7 @@ import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" +import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -2966,7 +2967,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { postMessageToWebview: vi.fn().mockResolvedValue(true), postStateToWebview: vi.fn().mockResolvedValue(undefined), getCurrentTask: vi.fn(), - getCurrentWorkspaceCodeIndexManager: vi.fn(), + getCurrentWorkspaceCodeIndexScope: vi.fn(), getMcpHub: vi.fn().mockReturnValue({ getMcpSettingsFilePath: vi.fn().mockResolvedValue("/test/mcp.json"), }), @@ -3022,10 +3023,11 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockReturnValue(indexingPromise), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), }) await expect(webviewMessageHandler(provider, { type: "startIndexing" })).resolves.toBeUndefined() + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() expect(manager.startIndexing).toHaveBeenCalledOnce() rejectIndexing(new Error("boom")) @@ -3179,14 +3181,29 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { it("covers changed indexing status, secret, and missing-manager responses", async () => { const manager = createIndexManager() - const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) - const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + const getScope = vi.fn().mockReturnValueOnce(undefined).mockReturnValue({ codeIndexManager: manager }) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexScope: getScope, + }) await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) + expect(manager.getCurrentStatus).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: expect.objectContaining({ systemStatus: "Error", message: expect.any(String) }), + }) await webviewMessageHandler(provider, { type: "requestIndexingStatus" }) + expect(getScope).toHaveBeenCalledTimes(2) + expect(manager.getCurrentStatus).toHaveBeenCalledOnce() + expect(provider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus.mock.results[0].value, + }) await webviewMessageHandler(provider, { type: "requestCodeIndexSecretStatus" }) - getManager.mockReturnValueOnce(undefined) + getScope.mockReturnValueOnce(undefined) await webviewMessageHandler(provider, { type: "startIndexing" }) + expect(getScope).toHaveBeenCalledTimes(3) + expect(manager.setWorkspaceEnabled).not.toHaveBeenCalled() expect(provider.postMessageToWebview).toHaveBeenCalledWith( expect.objectContaining({ type: "codeIndexSecretStatus" }), @@ -3203,7 +3220,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { .mockRejectedValueOnce(new Error("second failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), }) await webviewMessageHandler(provider, { type: "startIndexing" }) @@ -3219,10 +3236,18 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { startIndexing: vi.fn().mockRejectedValue(new Error("toggle failure")), }) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ + codeIndexManager: manager, + workspaceIndexingEnablementManager: new WorkspaceIndexingEnablementManager(manager), + }), }) await webviewMessageHandler(provider, { type: "stopIndexing" }) + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) await webviewMessageHandler(provider, { type: "toggleWorkspaceIndexing", bool: true }) await Promise.resolve() @@ -3233,6 +3258,85 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { ) }) + it("does not toggle indexing or publish status without a workspace scope", async () => { + const provider = createProvider() + await webviewMessageHandler(provider, { type: "toggleWorkspaceIndexing", bool: true }) + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledExactlyOnceWith( + "Cannot toggle workspace indexing: No workspace folder open", + ) + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each([ + { bool: true, expected: true }, + { bool: false, expected: false }, + { bool: undefined, expected: false }, + ])("delegates workspace enablement with bool=$bool as $expected", async ({ bool, expected }) => { + const setEnabled = vi.fn().mockResolvedValue(undefined) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ + workspaceIndexingEnablementManager: { setEnabled }, + }), + }) + const message: WebviewMessage = { type: "toggleWorkspaceIndexing" } + if (bool !== undefined) { + message.bool = bool + } + await webviewMessageHandler(provider, message) + expect(setEnabled).toHaveBeenCalledExactlyOnceWith(expected, provider) + // Status publication belongs to the enablement manager, not the handler. + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + expect(provider.log).not.toHaveBeenCalled() + }) + + it("does not stop indexing or publish status without a workspace scope", async () => { + const provider = createProvider() + await webviewMessageHandler(provider, { type: "stopIndexing" }) + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledWith("Cannot stop indexing: No workspace folder open") + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each([true, false])("resolves the scope after saving index settings (workspace=%s)", async (hasWorkspace) => { + const handleSettingsChange = vi.fn().mockResolvedValue(undefined) + const manager = createIndexManager({ handleSettingsChange, isFeatureEnabled: false }) + const provider = createProvider({ + getCurrentWorkspaceCodeIndexScope: vi + .fn() + .mockReturnValue(hasWorkspace ? { codeIndexManager: manager } : undefined), + }) + + await webviewMessageHandler(provider, { + type: "saveCodeIndexSettingsAtomic", + codeIndexSettings: { + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, + codebaseIndexEmbedderModelId: "text-embedding-3-small", + }, + }) + + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "codeIndexSettingsSaved", success: true }), + ) + if (hasWorkspace) { + expect(handleSettingsChange).toHaveBeenCalledOnce() + } else { + expect(handleSettingsChange).not.toHaveBeenCalled() + expect(provider.log).toHaveBeenCalledWith("Cannot save code index settings: No workspace folder open") + } + }) + + it("does not update auto-enable defaults without a workspace scope", async () => { + const provider = createProvider() + await webviewMessageHandler(provider, { type: "setAutoEnableDefault", bool: true }) + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() + expect(provider.log).toHaveBeenCalledWith("Cannot set auto-enable default: No workspace folder open") + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + it("catches auto-enabled indexing failures and posts the resulting status", async () => { const { CodeIndexManagerRegistry } = await import("../../../services/code-index/code-index-manager-registry") let workspaceEnabled = false @@ -3247,13 +3351,14 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { .spyOn(CodeIndexManagerRegistry, "getAllInstances") .mockReturnValue([manager] as unknown as ReturnType) const provider = createProvider({ - getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), + getCurrentWorkspaceCodeIndexScope: vi.fn().mockReturnValue({ codeIndexManager: manager }), }) try { await webviewMessageHandler(provider, { type: "setAutoEnableDefault", bool: true }) await Promise.resolve() + expect(provider.getCurrentWorkspaceCodeIndexScope).toHaveBeenCalledOnce() expect(manager.startIndexing).toHaveBeenCalledOnce() expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: auto-enable failure") expect(provider.postMessageToWebview).toHaveBeenCalledWith( @@ -3266,14 +3371,22 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { it("covers changed clear-index response paths", async () => { const manager = createIndexManager() - const getManager = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(manager) - const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: getManager }) + const getScope = vi.fn().mockReturnValueOnce(undefined).mockReturnValue({ codeIndexManager: manager }) + const provider = createProvider({ getCurrentWorkspaceCodeIndexScope: getScope }) await webviewMessageHandler(provider, { type: "clearIndexData" }) + expect(manager.clearIndexData).not.toHaveBeenCalled() + expect(provider.log).toHaveBeenCalledWith("Cannot clear index data: No workspace folder open") + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "indexCleared", + values: { success: false, error: expect.any(String) }, + }) await webviewMessageHandler(provider, { type: "clearIndexData" }) manager.clearIndexData.mockRejectedValueOnce(new Error("clear failed")) await webviewMessageHandler(provider, { type: "clearIndexData" }) + expect(getScope).toHaveBeenCalledTimes(3) + expect(manager.clearIndexData).toHaveBeenCalledTimes(2) expect(provider.postMessageToWebview).toHaveBeenCalledWith({ type: "indexCleared", values: { success: true }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a41fda1ce1..4ba94d454c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3135,7 +3135,8 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() // Then handle validation and initialization for the current workspace - const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexManager() + const scope = provider.getCurrentWorkspaceCodeIndexScope() + const currentCodeIndexManager = scope?.codeIndexManager if (currentCodeIndexManager) { // If embedder provider changed, perform proactive validation if (embedderProviderChanged) { @@ -3214,7 +3215,8 @@ export const webviewMessageHandler = async ( } case "requestIndexingStatus": { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const scope = provider.getCurrentWorkspaceCodeIndexScope() + const manager = scope?.codeIndexManager if (!manager) { // No workspace open - send error status await provider.postMessageToWebview({ @@ -3278,7 +3280,8 @@ export const webviewMessageHandler = async ( } case "startIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const scope = provider.getCurrentWorkspaceCodeIndexScope() + const manager = scope?.codeIndexManager if (!manager) { await provider.postMessageToWebview({ type: "indexingStatusUpdate", @@ -3319,15 +3322,15 @@ export const webviewMessageHandler = async ( } case "stopIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { + const scope = provider.getCurrentWorkspaceCodeIndexScope() + if (!scope) { provider.log("Cannot stop indexing: No workspace folder open") return } - manager.stopIndexing() + scope.codeIndexManager.stopIndexing() await provider.postMessageToWebview({ type: "indexingStatusUpdate", - values: manager.getCurrentStatus(), + values: scope.codeIndexManager.getCurrentStatus(), }) } catch (error) { provider.log(`Error stopping indexing: ${error instanceof Error ? error.message : String(error)}`) @@ -3336,23 +3339,12 @@ export const webviewMessageHandler = async ( } case "toggleWorkspaceIndexing": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { + const scope = provider.getCurrentWorkspaceCodeIndexScope() + if (!scope) { 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(), - }) + await scope.workspaceIndexingEnablementManager.setEnabled(message.bool ?? false, provider) } catch (error) { provider.log( `Error toggling workspace indexing: ${error instanceof Error ? error.message : String(error)}`, @@ -3362,7 +3354,8 @@ export const webviewMessageHandler = async ( } case "setAutoEnableDefault": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() + const scope = provider.getCurrentWorkspaceCodeIndexScope() + const manager = scope?.codeIndexManager if (!manager) { provider.log("Cannot set auto-enable default: No workspace folder open") return @@ -3395,8 +3388,8 @@ export const webviewMessageHandler = async ( } case "clearIndexData": { try { - const manager = provider.getCurrentWorkspaceCodeIndexManager() - if (!manager) { + const scope = provider.getCurrentWorkspaceCodeIndexScope() + if (!scope) { provider.log("Cannot clear index data: No workspace folder open") await provider.postMessageToWebview({ type: "indexCleared", @@ -3407,7 +3400,7 @@ export const webviewMessageHandler = async ( }) return } - await manager.clearIndexData() + await scope.codeIndexManager.clearIndexData() await provider.postMessageToWebview({ type: "indexCleared", values: { success: true } }) } catch (error) { provider.log(`Error clearing index data: ${error instanceof Error ? error.message : String(error)}`) diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts index bfbf58da8f..0d5012a641 100644 --- a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts +++ b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts @@ -43,9 +43,21 @@ describe("CodeIndexManagerRegistry", () => { it.each([{ folders: undefined }, { folders: [] }])("returns no manager with folders=$folders", ({ folders }) => { Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) expect(CodeIndexManagerRegistry.getOrCreate(context)).toBeUndefined() + expect(CodeIndexManagerRegistry.getOrCreateScope(context)).toBeUndefined() expect(CodeIndexManager).not.toHaveBeenCalled() }) + it("returns the complete cached scope shared with the manager API", () => { + const scope = CodeIndexManagerRegistry.getOrCreateScope(context)! + expect(scope).toBeInstanceOf(CodeIndexWorkspaceScope) + expect(scope.workspaceIndexingEnablementManager).toBeDefined() + expect(CodeIndexManagerRegistry.getOrCreateScope(context, "/first")).toBe(scope) + expect(CodeIndexManagerRegistry.getOrCreate(context)).toBe(scope.codeIndexManager) + expect(CodeIndexManagerRegistry.getOrCreateScope(context, "/second")).not.toBe(scope) + CodeIndexManagerRegistry.disposeAll() + expect(CodeIndexManagerRegistry.getOrCreateScope(context)).not.toBe(scope) + }) + it("uses the first workspace when there is no active editor", () => { CodeIndexManagerRegistry.getOrCreate(context) expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context, expect.any(CodeIndexStateManager)) diff --git a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts index 0379df7feb..417d7284c1 100644 --- a/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts +++ b/src/services/code-index/__tests__/code-index-workspace-scope.spec.ts @@ -2,6 +2,7 @@ import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { CodeIndexManager } from "../manager" import { CodeIndexWorkspaceScope } from "../code-index-workspace-scope" import { CodeIndexStateManager } from "../state-manager" +import { WorkspaceIndexingEnablementManager } from "../workspace-indexing-enablement-manager" vi.mock("../state-manager") @@ -14,6 +15,19 @@ vi.mock("../manager", () => ({ describe("CodeIndexWorkspaceScope", () => { beforeEach(() => vi.clearAllMocks()) + it("owns a guarded workspace indexing manager and recreates it after disposal", () => { + const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) + expect(() => scope.workspaceIndexingEnablementManager).toThrow("not initialized") + scope.init() + const indexing = scope.workspaceIndexingEnablementManager + expect(indexing).toBeInstanceOf(WorkspaceIndexingEnablementManager) + expect(indexing["manager"]).toBe(scope.codeIndexManager) + scope.dispose() + expect(() => scope.workspaceIndexingEnablementManager).toThrow("not initialized") + scope.init() + expect(scope.workspaceIndexingEnablementManager).not.toBe(indexing) + }) + it("guards generic values and preserves defined falsy values", () => { const scope = new CodeIndexWorkspaceScope("/workspace", makeUri("/workspace"), makeExtensionContext()) expect(() => scope["ensureInitialized"](42)).toThrow("Code index workspace scope is not initialized") diff --git a/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts b/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts new file mode 100644 index 0000000000..838fffdc6e --- /dev/null +++ b/src/services/code-index/__tests__/workspace-indexing-enablement-manager.spec.ts @@ -0,0 +1,103 @@ +import type { CodeIndexManager } from "../manager" +import { ContextProxy } from "../../../core/config/ContextProxy" +import { makeExtensionContext } from "../../../test-utils/vscode" +import { WorkspaceIndexingEnablementManager } from "../workspace-indexing-enablement-manager" + +describe("WorkspaceIndexingEnablementManager", () => { + const setup = () => { + const manager = { + setWorkspaceEnabled: vi.fn().mockResolvedValue(undefined), + isFeatureEnabled: true, + isFeatureConfigured: true, + initialize: vi.fn().mockResolvedValue({ requiresRestart: false }), + startIndexing: vi.fn().mockResolvedValue(undefined), + stopIndexing: vi.fn(), + getCurrentStatus: vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "Ready", + processedItems: 0, + totalItems: 0, + currentItemUnit: "blocks", + workspacePath: "/workspace", + workspaceEnabled: true, + autoEnableDefault: false, + }), + } + return { + manager, + indexing: new WorkspaceIndexingEnablementManager(manager), + provider: { + contextProxy: new ContextProxy(makeExtensionContext()), + log: vi.fn(), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }, + } + } + + it("persists enablement, initializes and starts in order without awaiting indexing", async () => { + const { manager, indexing, provider } = setup() + manager.startIndexing.mockReturnValue(new Promise(() => {})) + await indexing.setEnabled(true, provider) + expect(manager.setWorkspaceEnabled).toHaveBeenCalledWith(true) + expect(manager.initialize).toHaveBeenCalledWith(provider.contextProxy) + expect(manager.setWorkspaceEnabled).toHaveBeenCalledBefore(manager.initialize) + expect(manager.initialize).toHaveBeenCalledBefore(manager.startIndexing) + expect(manager.stopIndexing).not.toHaveBeenCalled() + }) + + it("persists disablement before stopping", async () => { + const { manager, indexing, provider } = setup() + await indexing.setEnabled(false, provider) + expect(manager.setWorkspaceEnabled).toHaveBeenCalledWith(false) + expect(manager.setWorkspaceEnabled).toHaveBeenCalledBefore(manager.stopIndexing) + expect(manager.stopIndexing).toHaveBeenCalledOnce() + expect(manager.initialize).not.toHaveBeenCalled() + expect(manager.startIndexing).not.toHaveBeenCalled() + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured"] as const)("does not start when %s is false", async (flag) => { + const { manager, indexing, provider } = setup() + manager[flag] = false + await indexing.setEnabled(true, provider) + expect(manager.setWorkspaceEnabled).toHaveBeenCalledWith(true) + expect(manager.initialize).not.toHaveBeenCalled() + expect(manager.startIndexing).not.toHaveBeenCalled() + expect(manager.stopIndexing).not.toHaveBeenCalled() + }) + + it("logs background indexing failures", async () => { + const { manager, indexing, provider } = setup() + manager.startIndexing.mockRejectedValue(new Error("failed")) + await expect(indexing.setEnabled(true, provider)).resolves.toBeUndefined() + expect(provider.log).toHaveBeenCalledWith("Indexing error: Error: failed") + }) + + it.each([true, false])("publishes the current status after setting enabled=%s", async (enabled) => { + const { manager, indexing, provider } = setup() + await indexing.setEnabled(enabled, provider) + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus.mock.results[0].value, + }) + expect(enabled ? manager.startIndexing : manager.stopIndexing).toHaveBeenCalledBefore(manager.getCurrentStatus) + }) + + it("propagates status publication failures", async () => { + const { indexing, provider } = setup() + const error = new Error("post failed") + provider.postMessageToWebview.mockRejectedValue(error) + await expect(indexing.setEnabled(false, provider)).rejects.toBe(error) + }) + + it.each(["setWorkspaceEnabled", "initialize"] as const)( + "propagates %s failures without starting", + async (method) => { + const { manager, indexing, provider } = setup() + const error = new Error("failed") + manager[method].mockRejectedValue(error) + await expect(indexing.setEnabled(true, provider)).rejects.toBe(error) + expect(manager.startIndexing).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) +}) diff --git a/src/services/code-index/code-index-manager-registry.ts b/src/services/code-index/code-index-manager-registry.ts index 057745e1fd..4a7ecc1f53 100644 --- a/src/services/code-index/code-index-manager-registry.ts +++ b/src/services/code-index/code-index-manager-registry.ts @@ -7,6 +7,14 @@ export class CodeIndexManagerRegistry { private static codeIndexWorkspaceScopes = new Map() public static getOrCreate(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { + return this.getOrCreateScope(context, workspacePath)?.codeIndexManager + } + + /** Returns the complete, initialized service scope for the resolved workspace. */ + public static getOrCreateScope( + context: vscode.ExtensionContext, + workspacePath?: string, + ): CodeIndexWorkspaceScope | undefined { const folder = this.resolveWorkspaceFolder(workspacePath) const resolvedPath = workspacePath || folder?.uri.fsPath if (!resolvedPath) { @@ -15,7 +23,7 @@ export class CodeIndexManagerRegistry { const existing = this.codeIndexWorkspaceScopes.get(resolvedPath) if (existing) { - return existing.codeIndexManager + return existing } // Preserve real workspace URIs, including remote schemes and authorities. @@ -23,7 +31,7 @@ export class CodeIndexManagerRegistry { const codeIndexWorkspaceScope = new CodeIndexWorkspaceScope(resolvedPath, folderUri, context) codeIndexWorkspaceScope.init() this.codeIndexWorkspaceScopes.set(resolvedPath, codeIndexWorkspaceScope) - return codeIndexWorkspaceScope.codeIndexManager + return codeIndexWorkspaceScope } public static getAllInstances(): CodeIndexManager[] { diff --git a/src/services/code-index/code-index-workspace-scope.ts b/src/services/code-index/code-index-workspace-scope.ts index 0f187fd749..c8481f6595 100644 --- a/src/services/code-index/code-index-workspace-scope.ts +++ b/src/services/code-index/code-index-workspace-scope.ts @@ -2,11 +2,13 @@ import type * as vscode from "vscode" import { CodeIndexManager } from "./manager" import { CodeIndexStateManager } from "./state-manager" +import { WorkspaceIndexingEnablementManager } from "./workspace-indexing-enablement-manager" /** Owns code-index services for one workspace; initialization remains with existing callers. */ export class CodeIndexWorkspaceScope implements vscode.Disposable { private _codeIndexManager?: CodeIndexManager private _stateManager?: CodeIndexStateManager + private _workspaceIndexingEnablementManager?: WorkspaceIndexingEnablementManager private _isInitialized = false public constructor( @@ -19,6 +21,10 @@ export class CodeIndexWorkspaceScope implements vscode.Disposable { return this.ensureInitialized(this._codeIndexManager) } + public get workspaceIndexingEnablementManager(): WorkspaceIndexingEnablementManager { + return this.ensureInitialized(this._workspaceIndexingEnablementManager) + } + private ensureInitialized(value: T | undefined): T { if (!this._isInitialized || value === undefined) { throw new Error("Code index workspace scope is not initialized") @@ -38,12 +44,14 @@ export class CodeIndexWorkspaceScope implements vscode.Disposable { this.context, this._stateManager, ) + this._workspaceIndexingEnablementManager = new WorkspaceIndexingEnablementManager(this._codeIndexManager) this._isInitialized = true } public dispose(): void { const manager = this._codeIndexManager this._codeIndexManager = undefined + this._workspaceIndexingEnablementManager = undefined this._stateManager = undefined this._isInitialized = false manager?.dispose() diff --git a/src/services/code-index/workspace-indexing-enablement-manager.ts b/src/services/code-index/workspace-indexing-enablement-manager.ts new file mode 100644 index 0000000000..78f179ad4a --- /dev/null +++ b/src/services/code-index/workspace-indexing-enablement-manager.ts @@ -0,0 +1,35 @@ +import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { CodeIndexManager } from "./manager" + +/** Coordinates workspace enablement without owning the underlying indexing services. */ +export class WorkspaceIndexingEnablementManager { + public constructor( + private readonly manager: Pick< + CodeIndexManager, + | "setWorkspaceEnabled" + | "isFeatureEnabled" + | "isFeatureConfigured" + | "initialize" + | "startIndexing" + | "stopIndexing" + | "getCurrentStatus" + >, + ) {} + + public async setEnabled( + enabled: boolean, + provider: Pick, + ): Promise { + await this.manager.setWorkspaceEnabled(enabled) + if (enabled && this.manager.isFeatureEnabled && this.manager.isFeatureConfigured) { + await this.manager.initialize(provider.contextProxy) + void this.manager.startIndexing().catch((error) => provider.log(`Indexing error: ${error}`)) + } else if (!enabled) { + this.manager.stopIndexing() + } + await provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: this.manager.getCurrentStatus(), + }) + } +}