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(), + }) + } +}