diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 551810625c..33843de532 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2392,6 +2392,19 @@ export const webviewMessageHandler = async ( codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore, } + // Handle workspace-specific indexing setting + if (settings.workspaceIndexEnabled !== undefined) { + const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexManager() + if (currentCodeIndexManager && provider.cwd) { + await currentCodeIndexManager.configManager?.setWorkspaceIndexEnabled( + provider.cwd, + settings.workspaceIndexEnabled, + ) + // Also store in global config for UI state + globalStateConfig.workspaceIndexEnabled = settings.workspaceIndexEnabled + } + } + // Save global state first await updateGlobalState("codebaseIndexConfig", globalStateConfig) @@ -2528,7 +2541,7 @@ export const webviewMessageHandler = async ( processedItems: 0, totalItems: 0, currentItemUnit: "items", - workerspacePath: undefined, + workspacePath: undefined, }, }) return @@ -2545,6 +2558,14 @@ export const webviewMessageHandler = async ( workspacePath: undefined, } + // Add workspace-specific indexing enabled state + if (manager && provider.cwd) { + const workspaceEnabled = manager.configManager?.getWorkspaceIndexEnabled(provider.cwd) + if (workspaceEnabled !== undefined) { + status.workspaceIndexEnabled = workspaceEnabled + } + } + provider.postMessageToWebview({ type: "indexingStatusUpdate", values: status, diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 929f6f93c8..23cec8c5ab 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -97,7 +97,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { mockContext = { subscriptions: [], - workspaceState: {} as any, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + } as any, globalState: {} as any, extensionUri: {} as any, extensionPath: testExtensionPath, diff --git a/src/services/code-index/__tests__/workspace-indexing-toggle.spec.ts b/src/services/code-index/__tests__/workspace-indexing-toggle.spec.ts new file mode 100644 index 0000000000..9371a2d553 --- /dev/null +++ b/src/services/code-index/__tests__/workspace-indexing-toggle.spec.ts @@ -0,0 +1,174 @@ +import * as vscode from "vscode" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { CodeIndexConfigManager } from "../config-manager" +import { ContextProxy } from "../../../core/config/ContextProxy" + +describe("Workspace-level Indexing Toggle", () => { + let configManager: CodeIndexConfigManager + let mockContextProxy: ContextProxy + let mockContext: vscode.ExtensionContext + const testWorkspacePath = "/test/workspace" + + beforeEach(() => { + // Mock ContextProxy + mockContextProxy = { + getValue: vi.fn(), + setValue: vi.fn(), + getGlobalState: vi.fn(), + updateGlobalState: vi.fn(), + getSecret: vi.fn(), + storeSecret: vi.fn(), + } as any + + // Mock VSCode Extension Context + mockContext = { + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + }, + } as any + + // Initialize config manager with mocks + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath, mockContext) + }) + + describe("Workspace-specific settings", () => { + it("should inherit global setting when workspace setting is not set", () => { + // Mock global setting enabled + vi.spyOn(mockContextProxy, "getGlobalState").mockReturnValue({ + codebaseIndexEnabled: true, + }) + + // Mock no workspace-specific setting + vi.spyOn(mockContext.workspaceState, "get").mockReturnValue(undefined) + + // Should inherit global setting (true) + expect(configManager.isFeatureEnabled).toBe(true) + }) + + it("should use workspace setting when explicitly set to false", () => { + // Mock global setting enabled + vi.spyOn(mockContextProxy, "getGlobalState").mockReturnValue({ + codebaseIndexEnabled: true, + }) + + // Mock workspace-specific setting disabled + const workspaceKey = `codebaseIndexEnabled_${Buffer.from(testWorkspacePath).toString("base64")}` + vi.spyOn(mockContext.workspaceState, "get").mockImplementation((key) => { + if (key === workspaceKey) return false + return undefined + }) + + // Create a new instance to trigger loadWorkspaceSettings + const newConfigManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath, mockContext) + + // Should use workspace setting (false) instead of global (true) + expect(newConfigManager.getWorkspaceIndexEnabled(testWorkspacePath)).toBe(false) + }) + + it("should use workspace setting when explicitly set to true", () => { + // Mock global setting disabled + vi.spyOn(mockContextProxy, "getGlobalState").mockReturnValue({ + codebaseIndexEnabled: false, + }) + + // Mock workspace-specific setting enabled + const workspaceKey = `codebaseIndexEnabled_${Buffer.from(testWorkspacePath).toString("base64")}` + vi.spyOn(mockContext.workspaceState, "get").mockImplementation((key) => { + if (key === workspaceKey) return true + return undefined + }) + + // Create a new instance to trigger loadWorkspaceSettings + const newConfigManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath, mockContext) + + // Workspace setting should be true + expect(newConfigManager.getWorkspaceIndexEnabled(testWorkspacePath)).toBe(true) + // But overall feature should still be disabled due to global setting + expect(newConfigManager.isFeatureEnabled).toBe(false) + }) + + it("should persist workspace setting when changed", async () => { + const updateSpy = vi.spyOn(mockContext.workspaceState, "update") + + await configManager.setWorkspaceIndexEnabled(testWorkspacePath, false) + + const expectedKey = `codebaseIndexEnabled_${Buffer.from(testWorkspacePath).toString("base64")}` + expect(updateSpy).toHaveBeenCalledWith(expectedKey, false) + }) + + it("should correctly identify when workspace has specific setting", () => { + // No workspace-specific setting + vi.spyOn(mockContext.workspaceState, "get").mockReturnValue(undefined) + expect(configManager.hasWorkspaceSpecificSetting()).toBe(false) + + // With workspace-specific setting + const workspaceKey = `codebaseIndexEnabled_${Buffer.from(testWorkspacePath).toString("base64")}` + vi.spyOn(mockContext.workspaceState, "get").mockImplementation((key) => { + if (key === workspaceKey) return true + return undefined + }) + + const newConfigManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath, mockContext) + newConfigManager.loadWorkspaceSettings() + + expect(newConfigManager.hasWorkspaceSpecificSetting()).toBe(true) + }) + }) + + describe("Multi-root workspace handling", () => { + it("should handle different settings for different workspace folders", () => { + const workspace1 = "/workspace1" + const workspace2 = "/workspace2" + + // Mock different settings for each workspace + vi.spyOn(mockContext.workspaceState, "get").mockImplementation((key) => { + const key1 = `codebaseIndexEnabled_${Buffer.from(workspace1).toString("base64")}` + const key2 = `codebaseIndexEnabled_${Buffer.from(workspace2).toString("base64")}` + + if (key === key1) return true + if (key === key2) return false + return undefined + }) + + // Create managers for each workspace + const manager1 = new CodeIndexConfigManager(mockContextProxy, workspace1, mockContext) + const manager2 = new CodeIndexConfigManager(mockContextProxy, workspace2, mockContext) + + manager1.loadWorkspaceSettings() + manager2.loadWorkspaceSettings() + + expect(manager1.getWorkspaceIndexEnabled(workspace1)).toBe(true) + expect(manager2.getWorkspaceIndexEnabled(workspace2)).toBe(false) + }) + }) + + describe("Global setting disabled", () => { + it("should always return false when global setting is disabled", () => { + // Mock global setting disabled + vi.spyOn(mockContextProxy, "getGlobalState").mockReturnValue({ + codebaseIndexEnabled: false, + }) + + // Even with workspace setting enabled + const workspaceKey = `codebaseIndexEnabled_${Buffer.from(testWorkspacePath).toString("base64")}` + vi.spyOn(mockContext.workspaceState, "get").mockImplementation((key) => { + if (key === workspaceKey) return true + return undefined + }) + + const newConfigManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath, mockContext) + + // Feature should be disabled + expect(newConfigManager.isFeatureEnabled).toBe(false) + }) + }) +}) diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 2c0e8bb5c9..2c37c86fed 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -1,3 +1,4 @@ +import * as vscode from "vscode" import { ApiHandlerOptions } from "../../shared/api" import { ContextProxy } from "../../core/config/ContextProxy" import { EmbedderProvider } from "./interfaces/manager" @@ -11,6 +12,7 @@ import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from ".. */ export class CodeIndexConfigManager { private codebaseIndexEnabled: boolean = true + private workspaceIndexEnabled: Map = new Map() private embedderProvider: EmbedderProvider = "openai" private modelId?: string private modelDimension?: number @@ -25,9 +27,15 @@ export class CodeIndexConfigManager { private searchMinScore?: number private searchMaxResults?: number - constructor(private readonly contextProxy: ContextProxy) { + constructor( + private readonly contextProxy: ContextProxy, + private readonly workspacePath?: string, + private readonly context?: vscode.ExtensionContext, + ) { // Initialize with current configuration to avoid false restart triggers this._loadAndSetConfiguration() + // Load workspace-specific settings if available + this.loadWorkspaceSettings() } /** @@ -404,8 +412,21 @@ export class CodeIndexConfigManager { /** * Gets whether the code indexing feature is enabled + * Takes into account both global and workspace-level settings */ public get isFeatureEnabled(): boolean { + // First check global setting + if (!this.codebaseIndexEnabled) { + return false + } + + // Then check workspace-specific setting if workspace path is available + if (this.workspacePath) { + const workspaceEnabled = this.getWorkspaceIndexEnabled(this.workspacePath) + // If workspace setting exists, use it; otherwise inherit global setting + return workspaceEnabled !== undefined ? workspaceEnabled : this.codebaseIndexEnabled + } + return this.codebaseIndexEnabled } @@ -480,4 +501,57 @@ export class CodeIndexConfigManager { public get currentSearchMaxResults(): number { return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS } + + /** + * Gets the workspace-specific indexing enabled state + * @param workspacePath The workspace path to check + * @returns The workspace-specific setting, or undefined if not set + */ + public getWorkspaceIndexEnabled(workspacePath: string): boolean | undefined { + if (!this.context) { + // If no context, check in-memory cache + return this.workspaceIndexEnabled.get(workspacePath) + } + // Use a hash of the workspace path as the key to avoid issues with special characters + const key = `codebaseIndexEnabled_${Buffer.from(workspacePath).toString("base64")}` + const value = this.context.workspaceState.get(key) + return value + } + + /** + * Sets the workspace-specific indexing enabled state + * @param workspacePath The workspace path to set + * @param enabled Whether indexing should be enabled for this workspace + */ + public async setWorkspaceIndexEnabled(workspacePath: string, enabled: boolean): Promise { + this.workspaceIndexEnabled.set(workspacePath, enabled) + if (this.context) { + // Use a hash of the workspace path as the key to avoid issues with special characters + const key = `codebaseIndexEnabled_${Buffer.from(workspacePath).toString("base64")}` + await this.context.workspaceState.update(key, enabled) + } + } + + /** + * Loads workspace-specific settings + */ + public loadWorkspaceSettings(): void { + if (this.workspacePath) { + const workspaceEnabled = this.getWorkspaceIndexEnabled(this.workspacePath) + if (workspaceEnabled !== undefined) { + this.workspaceIndexEnabled.set(this.workspacePath, workspaceEnabled) + } + } + } + + /** + * Gets whether the workspace has a specific indexing setting + * (as opposed to inheriting the global setting) + */ + public hasWorkspaceSpecificSetting(): boolean { + if (!this.workspacePath) { + return false + } + return this.getWorkspaceIndexEnabled(this.workspacePath) !== undefined + } } diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd79a3f161..b1a2f86739 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -101,6 +101,10 @@ export class CodeIndexManager { return this._configManager?.isFeatureConfigured ?? false } + public get configManager(): CodeIndexConfigManager | undefined { + return this._configManager + } + public get isInitialized(): boolean { try { this.assertInitialized() @@ -118,7 +122,7 @@ export class CodeIndexManager { public async initialize(contextProxy: ContextProxy): Promise<{ requiresRestart: boolean }> { // 1. ConfigManager Initialization and Configuration Loading if (!this._configManager) { - this._configManager = new CodeIndexConfigManager(contextProxy) + this._configManager = new CodeIndexConfigManager(contextProxy, this.workspacePath, this.context) } // Load configuration once to get current state and restart requirements const { requiresRestart } = await this._configManager.loadConfiguration() diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 45bf4224a1..333d6624d6 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -57,6 +57,7 @@ interface CodeIndexPopoverProps { interface LocalCodeIndexSettings { // Global state settings codebaseIndexEnabled: boolean + workspaceIndexEnabled?: boolean // Workspace-specific setting codebaseIndexQdrantUrl: string codebaseIndexEmbedderProvider: EmbedderProvider codebaseIndexEmbedderBaseUrl?: string @@ -212,6 +213,7 @@ export const CodeIndexPopover: React.FC = ({ if (codebaseIndexConfig) { const settings = { codebaseIndexEnabled: codebaseIndexConfig.codebaseIndexEnabled ?? true, + workspaceIndexEnabled: codebaseIndexConfig.workspaceIndexEnabled, codebaseIndexQdrantUrl: codebaseIndexConfig.codebaseIndexQdrantUrl || "", codebaseIndexEmbedderProvider: codebaseIndexConfig.codebaseIndexEmbedderProvider || "openai", codebaseIndexEmbedderBaseUrl: codebaseIndexConfig.codebaseIndexEmbedderBaseUrl || "", @@ -511,6 +513,11 @@ export const CodeIndexPopover: React.FC = ({ // Always include codebaseIndexEnabled to ensure it's persisted settingsToSave.codebaseIndexEnabled = currentSettings.codebaseIndexEnabled + // Include workspace-specific setting if it's been set + if (currentSettings.workspaceIndexEnabled !== undefined) { + settingsToSave.workspaceIndexEnabled = currentSettings.workspaceIndexEnabled + } + // Save settings to backend vscode.postMessage({ type: "saveCodeIndexSettingsAtomic", @@ -588,20 +595,57 @@ export const CodeIndexPopover: React.FC = ({ + {/* Workspace-level Toggle */} + {currentSettings.codebaseIndexEnabled && cwd && ( +
+
+ updateSetting("workspaceIndexEnabled", e.target.checked)}> + + {t("settings:codeIndex.workspaceEnableLabel")} + + + + + +
+ {currentSettings.workspaceIndexEnabled === undefined && ( +

+ {t("settings:codeIndex.inheritingGlobalSetting")} +

+ )} +
+ )} + {/* Status Section */}

{t("settings:codeIndex.statusTitle")}

- {t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} - {indexingStatus.message ? ` - ${indexingStatus.message}` : ""} + {currentSettings.workspaceIndexEnabled === false + ? t("settings:codeIndex.workspaceIndexingDisabled") + : t( + `settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`, + )} + {indexingStatus.message && currentSettings.workspaceIndexEnabled !== false + ? ` - ${indexingStatus.message}` + : ""}
{indexingStatus.systemStatus === "Indexing" && ( @@ -1295,6 +1339,7 @@ export const CodeIndexPopover: React.FC = ({
{currentSettings.codebaseIndexEnabled && + currentSettings.workspaceIndexEnabled !== false && (indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && ( = ({ )} {currentSettings.codebaseIndexEnabled && + currentSettings.workspaceIndexEnabled !== false && (indexingStatus.systemStatus === "Indexed" || indexingStatus.systemStatus === "Error") && ( diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 3c71e237b1..6837ab6b0d 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -42,6 +42,10 @@ "statusTitle": "Status", "enableLabel": "Enable Codebase Indexing", "enableDescription": "Enable code indexing for improved search and context understanding", + "workspaceEnableLabel": "Enable code indexing for this workspace", + "workspaceEnableDescription": "Override the global setting for this workspace folder. When disabled, indexing actions remain visible but are disabled.", + "inheritingGlobalSetting": "Currently inheriting the global setting", + "workspaceIndexingDisabled": "Indexing disabled for this workspace", "settingsTitle": "Indexing Settings", "disabledMessage": "Codebase indexing is currently disabled. Enable it in the global settings to configure indexing options.", "providerLabel": "Embeddings Provider",