From 67662b0f5b6df46eebe44dbc48171bd82de50891 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 4 Dec 2025 09:22:39 +0000 Subject: [PATCH] feat: add per-workspace control for codebase indexing - Add workspace-level configuration property enableCodebaseIndexing - Update CodeIndexConfigManager to check workspace settings first - Workspace setting overrides global configuration when set - Add comprehensive tests for per-workspace functionality Fixes #8131 --- src/package.json | 6 + src/package.nls.json | 1 + .../__tests__/per-workspace-indexing.spec.ts | 260 ++++++++++++++++++ src/services/code-index/config-manager.ts | 22 +- src/services/code-index/manager.ts | 2 +- 5 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/services/code-index/__tests__/per-workspace-indexing.spec.ts diff --git a/src/package.json b/src/package.json index 2c8e1be337..78d70ef11f 100644 --- a/src/package.json +++ b/src/package.json @@ -407,6 +407,12 @@ "maximum": 200, "description": "%settings.codeIndex.embeddingBatchSize.description%" }, + "roo-cline.enableCodebaseIndexing": { + "type": "boolean", + "default": false, + "description": "%settings.enableCodebaseIndexing.description%", + "scope": "resource" + }, "roo-cline.debug": { "type": "boolean", "default": false, diff --git a/src/package.nls.json b/src/package.nls.json index 0030d1ce7b..fc169766f9 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -42,5 +42,6 @@ "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", + "settings.enableCodebaseIndexing.description": "Enable codebase indexing for this workspace. When enabled, the extension will index your code for semantic search capabilities. This setting overrides any global configuration.", "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files." } diff --git a/src/services/code-index/__tests__/per-workspace-indexing.spec.ts b/src/services/code-index/__tests__/per-workspace-indexing.spec.ts new file mode 100644 index 0000000000..9dfc449640 --- /dev/null +++ b/src/services/code-index/__tests__/per-workspace-indexing.spec.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" +import * as vscode from "vscode" +import { CodeIndexConfigManager } from "../config-manager" +import { ContextProxy } from "../../../core/config/ContextProxy" + +// Mock vscode module +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: undefined as any, + getConfiguration: vi.fn(), + }, +})) + +describe("Per-workspace codebase indexing", () => { + let mockContextProxy: any + let configManager: CodeIndexConfigManager + const testWorkspacePath = "/test/workspace" + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Setup mock context proxy + mockContextProxy = { + getGlobalState: vi.fn(), + getSecret: vi.fn(), + refreshSecrets: vi.fn().mockResolvedValue(undefined), + } + + // Default mock implementations + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: false, // Global setting disabled by default + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + }) + + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + if (key === "codeIndexQdrantApiKey") return "test-key" + return undefined + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("Workspace setting overrides global setting", () => { + it("should enable indexing when workspace setting is true and global is false", () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: testWorkspacePath }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Mock workspace configuration to return true for the workspace setting + const mockConfig = { + get: vi.fn().mockReturnValue(true), // Workspace setting enabled + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + // Global setting is false (set in beforeEach) + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath) + + // Workspace setting should override global setting + expect(configManager.isFeatureEnabled).toBe(true) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline", mockWorkspaceFolder.uri) + expect(mockConfig.get).toHaveBeenCalledWith("enableCodebaseIndexing") + }) + + it("should disable indexing when workspace setting is false and global is true", () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: testWorkspacePath }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Mock workspace configuration to return false for the workspace setting + const mockConfig = { + get: vi.fn().mockReturnValue(false), // Workspace setting disabled + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + // Set global setting to true + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, // Global setting enabled + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + }) + + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath) + + // Workspace setting should override global setting + expect(configManager.isFeatureEnabled).toBe(false) + }) + + it("should use global setting when workspace setting is undefined", () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: testWorkspacePath }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Mock workspace configuration to return undefined (not set) + const mockConfig = { + get: vi.fn().mockReturnValue(undefined), // Workspace setting not configured + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + // Set global setting to true + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, // Global setting enabled + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + }) + + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath) + + // Should fall back to global setting + expect(configManager.isFeatureEnabled).toBe(true) + }) + }) + + describe("Multiple workspaces", () => { + it("should only check setting for the specific workspace", () => { + // Setup multiple workspace folders + const workspace1 = { + uri: { fsPath: "/workspace1" }, + } + const workspace2 = { + uri: { fsPath: "/workspace2" }, + } + ;(vscode.workspace as any).workspaceFolders = [workspace1, workspace2] + + // Mock different configurations for each workspace + const mockConfig1 = { + get: vi.fn().mockReturnValue(true), // Workspace 1 enabled + } + const mockConfig2 = { + get: vi.fn().mockReturnValue(false), // Workspace 2 disabled + } + + ;(vscode.workspace.getConfiguration as any).mockImplementation((section: string, uri: any) => { + if (uri.fsPath === "/workspace1") return mockConfig1 + if (uri.fsPath === "/workspace2") return mockConfig2 + return { get: vi.fn().mockReturnValue(undefined) } + }) + + // Test workspace 1 + const configManager1 = new CodeIndexConfigManager(mockContextProxy, "/workspace1") + expect(configManager1.isFeatureEnabled).toBe(true) + + // Test workspace 2 + const configManager2 = new CodeIndexConfigManager(mockContextProxy, "/workspace2") + expect(configManager2.isFeatureEnabled).toBe(false) + + // Verify correct workspace was queried + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline", workspace1.uri) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline", workspace2.uri) + }) + }) + + describe("No workspace folder", () => { + it("should use global setting when no workspace folder is found", () => { + // No workspace folders + ;(vscode.workspace as any).workspaceFolders = [] + + // Set global setting to true + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + }) + + // Create config manager without workspace path + configManager = new CodeIndexConfigManager(mockContextProxy, undefined) + + // Should use global setting + expect(configManager.isFeatureEnabled).toBe(true) + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalled() + }) + + it("should use global setting when workspace path doesn't match any folder", () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: "/different/workspace" }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Set global setting to true + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + }) + + // Create config manager with non-matching workspace path + configManager = new CodeIndexConfigManager(mockContextProxy, "/non/matching/path") + + // Should use global setting since workspace doesn't match + expect(configManager.isFeatureEnabled).toBe(true) + expect(vscode.workspace.getConfiguration).not.toHaveBeenCalled() + }) + }) + + describe("Configuration reload", () => { + it("should pick up workspace setting changes on reload", async () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: testWorkspacePath }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Initially workspace setting is false + const mockConfig = { + get: vi.fn().mockReturnValue(false), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath) + expect(configManager.isFeatureEnabled).toBe(false) + + // Change workspace setting to true + mockConfig.get.mockReturnValue(true) + + // Reload configuration + await configManager.loadConfiguration() + + // Should pick up the new workspace setting + expect(configManager.isFeatureEnabled).toBe(true) + }) + + it("should detect restart requirement when workspace setting changes", async () => { + // Setup workspace folders + const mockWorkspaceFolder = { + uri: { fsPath: testWorkspacePath }, + } + ;(vscode.workspace as any).workspaceFolders = [mockWorkspaceFolder] + + // Initially workspace setting is false + const mockConfig = { + get: vi.fn().mockReturnValue(false), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + configManager = new CodeIndexConfigManager(mockContextProxy, testWorkspacePath) + expect(configManager.isFeatureEnabled).toBe(false) + + // Change workspace setting to true (and ensure it's configured) + mockConfig.get.mockReturnValue(true) + + // Reload configuration + const result = await configManager.loadConfiguration() + + // Should require restart when enabling + expect(result.requiresRestart).toBe(true) + expect(configManager.isFeatureEnabled).toBe(true) + }) + }) +}) diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index e7f239e621..4fb924bb31 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" @@ -27,7 +28,10 @@ export class CodeIndexConfigManager { private searchMinScore?: number private searchMaxResults?: number - constructor(private readonly contextProxy: ContextProxy) { + constructor( + private readonly contextProxy: ContextProxy, + private readonly workspacePath?: string, + ) { // Initialize with current configuration to avoid false restart triggers this._loadAndSetConfiguration() } @@ -44,6 +48,19 @@ export class CodeIndexConfigManager { * This eliminates code duplication between initializeWithCurrentConfig() and loadConfiguration(). */ private _loadAndSetConfiguration(): void { + // Check workspace-level setting first + let workspaceIndexingEnabled: boolean | undefined + if (this.workspacePath) { + const workspaceFolder = vscode.workspace.workspaceFolders?.find( + (folder) => folder.uri.fsPath === this.workspacePath, + ) + if (workspaceFolder) { + workspaceIndexingEnabled = vscode.workspace + .getConfiguration("roo-cline", workspaceFolder.uri) + .get("enableCodebaseIndexing") + } + } + // Load configuration from storage const codebaseIndexConfig = this.contextProxy?.getGlobalState("codebaseIndexConfig") ?? { codebaseIndexEnabled: false, @@ -81,7 +98,8 @@ export class CodeIndexConfigManager { const openRouterSpecificProvider = codebaseIndexConfig.codebaseIndexOpenRouterSpecificProvider ?? "" // Update instance variables with configuration - this.codebaseIndexEnabled = codebaseIndexEnabled ?? false + // Use workspace setting if available, otherwise fall back to global setting + this.codebaseIndexEnabled = workspaceIndexingEnabled ?? codebaseIndexEnabled ?? false this.qdrantUrl = codebaseIndexQdrantUrl this.qdrantApiKey = qdrantApiKey ?? "" this.searchMinScore = codebaseIndexSearchMinScore diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd79a3f161..b8083e8594 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -118,7 +118,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) } // Load configuration once to get current state and restart requirements const { requiresRestart } = await this._configManager.loadConfiguration()