feat: add workspace-level toggle for codebase indexing

- Add workspace-specific indexing settings that override global settings
- Implement UI toggle in CodeIndexPopover for workspace-level control
- Store workspace settings using VSCode workspace state API
- Support multi-root workspaces with per-folder settings
- Add comprehensive unit tests for new functionality
- Update translations for new UI elements

Fixes #7926
This commit is contained in:
Roo Code 2025-09-12 05:54:29 +00:00
parent 08d7f80e22
commit ead391f906
7 changed files with 337 additions and 10 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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)
})
})
})

View file

@ -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<string, boolean> = 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<boolean>(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<void> {
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
}
}

View file

@ -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()

View file

@ -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<CodeIndexPopoverProps> = ({
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<CodeIndexPopoverProps> = ({
// 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<CodeIndexPopoverProps> = ({
</div>
</div>
{/* Workspace-level Toggle */}
{currentSettings.codebaseIndexEnabled && cwd && (
<div className="mb-4 ml-6">
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={currentSettings.workspaceIndexEnabled !== false}
onChange={(e: any) => updateSetting("workspaceIndexEnabled", e.target.checked)}>
<span className="font-medium">
{t("settings:codeIndex.workspaceEnableLabel")}
</span>
</VSCodeCheckbox>
<StandardTooltip content={t("settings:codeIndex.workspaceEnableDescription")}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
</StandardTooltip>
</div>
{currentSettings.workspaceIndexEnabled === undefined && (
<p className="text-xs text-vscode-descriptionForeground mt-1 ml-6">
{t("settings:codeIndex.inheritingGlobalSetting")}
</p>
)}
</div>
)}
{/* Status Section */}
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("settings:codeIndex.statusTitle")}</h4>
<div className="text-sm text-vscode-descriptionForeground">
<span
className={cn("inline-block w-3 h-3 rounded-full mr-2", {
"bg-gray-400": indexingStatus.systemStatus === "Standby",
"bg-yellow-500 animate-pulse": indexingStatus.systemStatus === "Indexing",
"bg-green-500": indexingStatus.systemStatus === "Indexed",
"bg-red-500": indexingStatus.systemStatus === "Error",
"bg-gray-400":
indexingStatus.systemStatus === "Standby" ||
currentSettings.workspaceIndexEnabled === false,
"bg-yellow-500 animate-pulse":
indexingStatus.systemStatus === "Indexing" &&
currentSettings.workspaceIndexEnabled !== false,
"bg-green-500":
indexingStatus.systemStatus === "Indexed" &&
currentSettings.workspaceIndexEnabled !== false,
"bg-red-500":
indexingStatus.systemStatus === "Error" &&
currentSettings.workspaceIndexEnabled !== false,
})}
/>
{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}`
: ""}
</div>
{indexingStatus.systemStatus === "Indexing" && (
@ -1295,6 +1339,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
<div className="flex items-center justify-between gap-2 pt-6">
<div className="flex gap-2">
{currentSettings.codebaseIndexEnabled &&
currentSettings.workspaceIndexEnabled !== false &&
(indexingStatus.systemStatus === "Error" ||
indexingStatus.systemStatus === "Standby") && (
<VSCodeButton
@ -1305,6 +1350,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
)}
{currentSettings.codebaseIndexEnabled &&
currentSettings.workspaceIndexEnabled !== false &&
(indexingStatus.systemStatus === "Indexed" ||
indexingStatus.systemStatus === "Error") && (
<AlertDialog>

View file

@ -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",