From b3c3d026ad307c603e8d8ddc988a427be04623a3 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 11 Jan 2026 07:08:23 +0000 Subject: [PATCH] feat: add settings to disable auto-indexing and control concurrency for multi-root workspaces - Add codebaseIndexAutoStart setting to control automatic indexing on workspace open - Add codebaseIndexMaxConcurrent setting to limit concurrent indexing operations - Implement queue-based initialization in extension.ts that respects concurrency limits - Add UI controls in CodeIndexPopover.tsx (checkbox and slider) - Add translation strings for new settings - Add unit tests for new config-manager getters Fixes #10569 --- packages/types/src/codebase-index.ts | 12 ++ src/extension.ts | 84 +++++++++-- .../__tests__/config-manager.spec.ts | 142 ++++++++++++++++++ src/services/code-index/config-manager.ts | 25 ++- src/services/code-index/constants/index.ts | 4 + .../src/components/chat/CodeIndexPopover.tsx | 74 +++++++++ webview-ui/src/i18n/locales/en/settings.json | 12 +- 7 files changed, 340 insertions(+), 13 deletions(-) diff --git a/packages/types/src/codebase-index.ts b/packages/types/src/codebase-index.ts index 61009ba3011..18924d66e86 100644 --- a/packages/types/src/codebase-index.ts +++ b/packages/types/src/codebase-index.ts @@ -12,6 +12,11 @@ export const CODEBASE_INDEX_DEFAULTS = { MAX_SEARCH_SCORE: 1, DEFAULT_SEARCH_MIN_SCORE: 0.4, SEARCH_SCORE_STEP: 0.05, + // Auto-start and concurrency control settings + DEFAULT_AUTO_START: true, + DEFAULT_MAX_CONCURRENT: 1, + MIN_MAX_CONCURRENT: 1, + MAX_MAX_CONCURRENT: 10, } as const /** @@ -42,6 +47,13 @@ export const codebaseIndexConfigSchema = z.object({ .min(CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_RESULTS) .max(CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_RESULTS) .optional(), + // Auto-start and concurrency control settings + codebaseIndexAutoStart: z.boolean().optional(), + codebaseIndexMaxConcurrent: z + .number() + .min(CODEBASE_INDEX_DEFAULTS.MIN_MAX_CONCURRENT) + .max(CODEBASE_INDEX_DEFAULTS.MAX_MAX_CONCURRENT) + .optional(), // OpenAI Compatible specific fields codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(), codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(), diff --git a/src/extension.ts b/src/extension.ts index 76f02af6de2..4aa28992015 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" +import { CodeIndexConfigManager } from "./services/code-index/config-manager" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -114,26 +115,89 @@ export async function activate(context: vscode.ExtensionContext) { const contextProxy = await ContextProxy.getInstance(context) - // Initialize code index managers for all workspace folders. + // Initialize code index managers for all workspace folders with concurrency control. + // This prevents overwhelming local embedding providers (like Ollama) on multi-root workspaces. const codeIndexManagers: CodeIndexManager[] = [] if (vscode.workspace.workspaceFolders) { + // Create all managers first (without initializing them) for (const folder of vscode.workspace.workspaceFolders) { const manager = CodeIndexManager.getInstance(context, folder.uri.fsPath) - if (manager) { codeIndexManagers.push(manager) + context.subscriptions.push(manager) + } + } - // Initialize in background; do not block extension activation - void manager.initialize(contextProxy).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - outputChannel.appendLine( - `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for ${folder.uri.fsPath}: ${message}`, - ) - }) + // Get configuration to check auto-start and max concurrent settings + // We use a temporary config manager just to read the settings + const tempConfigManager = new CodeIndexConfigManager(contextProxy) + const shouldAutoStart = tempConfigManager.shouldAutoStart + const maxConcurrent = tempConfigManager.currentMaxConcurrent - context.subscriptions.push(manager) + if (shouldAutoStart && codeIndexManagers.length > 0) { + // Initialize managers with concurrency control + outputChannel.appendLine( + `[CodeIndexManager] Starting sequential indexing for ${codeIndexManagers.length} workspace folder(s) with max ${maxConcurrent} concurrent`, + ) + + // Create a queue to manage initialization with concurrency control + const initializeWithConcurrencyControl = async () => { + const pendingManagers = [...codeIndexManagers] + const activePromises: Promise[] = [] + + const startNext = async () => { + while (pendingManagers.length > 0 && activePromises.length < maxConcurrent) { + const manager = pendingManagers.shift() + if (!manager) break + + const workspacePath = (manager as any).workspacePath || "unknown" + outputChannel.appendLine(`[CodeIndexManager] Starting initialization for: ${workspacePath}`) + + const initPromise = manager + .initialize(contextProxy) + .then(() => { + outputChannel.appendLine( + `[CodeIndexManager] Completed initialization for: ${workspacePath}`, + ) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + outputChannel.appendLine( + `[CodeIndexManager] Error during initialization for ${workspacePath}: ${message}`, + ) + }) + .finally(() => { + // Remove this promise from active list + const index = activePromises.indexOf(initPromise) + if (index > -1) { + activePromises.splice(index, 1) + } + // Start next manager if there are more pending + if (pendingManagers.length > 0) { + startNext() + } + }) + + activePromises.push(initPromise) + } + } + + // Start initial batch up to maxConcurrent + await startNext() + + // Wait for all to complete + while (activePromises.length > 0) { + await Promise.race(activePromises) + } } + + // Run initialization in background; do not block extension activation + void initializeWithConcurrencyControl() + } else if (!shouldAutoStart) { + outputChannel.appendLine( + `[CodeIndexManager] Auto-start disabled. Skipping automatic indexing for ${codeIndexManagers.length} workspace folder(s).`, + ) } } diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 27815c0bef0..4a7222a4193 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -1928,4 +1928,146 @@ describe("CodeIndexConfigManager", () => { }) }) }) + + describe("auto-start and concurrency settings", () => { + describe("shouldAutoStart", () => { + it("should return true by default when codebaseIndexAutoStart is not set", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + // codebaseIndexAutoStart not set + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.shouldAutoStart).toBe(true) + }) + + it("should return true when codebaseIndexAutoStart is explicitly true", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexAutoStart: true, + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.shouldAutoStart).toBe(true) + }) + + it("should return false when codebaseIndexAutoStart is false", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexAutoStart: false, + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.shouldAutoStart).toBe(false) + }) + }) + + describe("currentMaxConcurrent", () => { + it("should return 1 by default when codebaseIndexMaxConcurrent is not set", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + // codebaseIndexMaxConcurrent not set + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.currentMaxConcurrent).toBe(1) + }) + + it("should return the configured value when codebaseIndexMaxConcurrent is set", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexMaxConcurrent: 3, + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.currentMaxConcurrent).toBe(3) + }) + + it("should handle boundary values correctly", async () => { + // Test minimum value (1) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexMaxConcurrent: 1, + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.currentMaxConcurrent).toBe(1) + + // Test maximum value (10) + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexMaxConcurrent: 10, + }) + + const maxManager = new CodeIndexConfigManager(mockContextProxy) + await maxManager.loadConfiguration() + expect(maxManager.currentMaxConcurrent).toBe(10) + }) + }) + + describe("combined auto-start and concurrency behavior", () => { + it("should respect both settings when both are configured", async () => { + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openai", + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexAutoStart: false, + codebaseIndexMaxConcurrent: 2, + }) + mockContextProxy.getSecret.mockImplementation((key: string) => { + if (key === "codeIndexOpenAiKey") return "test-key" + return undefined + }) + + configManager = new CodeIndexConfigManager(mockContextProxy) + await configManager.loadConfiguration() + expect(configManager.shouldAutoStart).toBe(false) + expect(configManager.currentMaxConcurrent).toBe(2) + }) + }) + }) }) diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index e7f239e621f..0c770dbbe1d 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -2,7 +2,7 @@ import { ApiHandlerOptions } from "../../shared/api" import { ContextProxy } from "../../core/config/ContextProxy" import { EmbedderProvider } from "./interfaces/manager" import { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" -import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants" +import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_AUTO_START, DEFAULT_MAX_CONCURRENT } from "./constants" import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../shared/embeddingModels" /** @@ -26,6 +26,9 @@ export class CodeIndexConfigManager { private qdrantApiKey?: string private searchMinScore?: number private searchMaxResults?: number + // Auto-start and concurrency control settings + private autoStart?: boolean + private maxConcurrent?: number constructor(private readonly contextProxy: ContextProxy) { // Initialize with current configuration to avoid false restart triggers @@ -148,6 +151,10 @@ export class CodeIndexConfigManager { this.bedrockOptions = bedrockRegion ? { region: bedrockRegion, profile: bedrockProfile || undefined } : undefined + + // Load auto-start and concurrency control settings + this.autoStart = codebaseIndexConfig.codebaseIndexAutoStart + this.maxConcurrent = codebaseIndexConfig.codebaseIndexMaxConcurrent } /** @@ -541,4 +548,20 @@ export class CodeIndexConfigManager { public get currentSearchMaxResults(): number { return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS } + + /** + * Gets whether indexing should auto-start on extension activation. + * Returns user setting if configured, otherwise returns default (true). + */ + public get shouldAutoStart(): boolean { + return this.autoStart ?? DEFAULT_AUTO_START + } + + /** + * Gets the maximum number of concurrent indexing operations. + * Returns user setting if configured, otherwise returns default (1). + */ + public get currentMaxConcurrent(): number { + return this.maxConcurrent ?? DEFAULT_MAX_CONCURRENT + } } diff --git a/src/services/code-index/constants/index.ts b/src/services/code-index/constants/index.ts index 6f0e0fe7e62..97537f4ed2a 100644 --- a/src/services/code-index/constants/index.ts +++ b/src/services/code-index/constants/index.ts @@ -10,6 +10,10 @@ export const MAX_CHARS_TOLERANCE_FACTOR = 1.15 // 15% tolerance for max chars export const DEFAULT_SEARCH_MIN_SCORE = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE export const DEFAULT_MAX_SEARCH_RESULTS = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS +/**Auto-start and concurrency control */ +export const DEFAULT_AUTO_START = CODEBASE_INDEX_DEFAULTS.DEFAULT_AUTO_START +export const DEFAULT_MAX_CONCURRENT = CODEBASE_INDEX_DEFAULTS.DEFAULT_MAX_CONCURRENT + /**File Watcher */ export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479" export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 4fcf6406e3b..9e7af95d3d8 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -66,6 +66,9 @@ interface LocalCodeIndexSettings { codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers codebaseIndexSearchMaxResults?: number codebaseIndexSearchMinScore?: number + // Auto-start and concurrency control settings + codebaseIndexAutoStart?: boolean + codebaseIndexMaxConcurrent?: number // Bedrock-specific settings codebaseIndexBedrockRegion?: string @@ -214,6 +217,8 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexEmbedderModelDimension: undefined, codebaseIndexSearchMaxResults: CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS, codebaseIndexSearchMinScore: CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE, + codebaseIndexAutoStart: CODEBASE_INDEX_DEFAULTS.DEFAULT_AUTO_START, + codebaseIndexMaxConcurrent: CODEBASE_INDEX_DEFAULTS.DEFAULT_MAX_CONCURRENT, codebaseIndexBedrockRegion: "", codebaseIndexBedrockProfile: "", codeIndexOpenAiKey: "", @@ -253,6 +258,10 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexConfig.codebaseIndexSearchMaxResults ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS, codebaseIndexSearchMinScore: codebaseIndexConfig.codebaseIndexSearchMinScore ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE, + codebaseIndexAutoStart: + codebaseIndexConfig.codebaseIndexAutoStart ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_AUTO_START, + codebaseIndexMaxConcurrent: + codebaseIndexConfig.codebaseIndexMaxConcurrent ?? CODEBASE_INDEX_DEFAULTS.DEFAULT_MAX_CONCURRENT, codebaseIndexBedrockRegion: codebaseIndexConfig.codebaseIndexBedrockRegion || "", codebaseIndexBedrockProfile: codebaseIndexConfig.codebaseIndexBedrockProfile || "", codeIndexOpenAiKey: "", @@ -1586,6 +1595,71 @@ export const CodeIndexPopover: React.FC = ({ + + {/* Auto-start Checkbox */} +
+
+ + updateSetting("codebaseIndexAutoStart", e.target.checked) + }> + + {t("settings:codeIndex.autoStart.label")} + + + + + +
+
+ + {/* Max Concurrent Indexing Slider */} +
+
+ + + + +
+
+ + updateSetting("codebaseIndexMaxConcurrent", values[0]) + } + className="flex-1" + data-testid="max-concurrent-slider" + /> + + {currentSettings.codebaseIndexMaxConcurrent ?? + CODEBASE_INDEX_DEFAULTS.DEFAULT_MAX_CONCURRENT} + + + updateSetting( + "codebaseIndexMaxConcurrent", + CODEBASE_INDEX_DEFAULTS.DEFAULT_MAX_CONCURRENT, + ) + }> + + +
+
)} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 17d14da8bdf..01e2a81a879 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -182,9 +182,17 @@ "ollamaBaseUrlRequired": "Ollama base URL is required", "baseUrlRequired": "Base URL is required", "modelDimensionMinValue": "Model dimension must be greater than 0" + }, + "optional": "optional", + "autoStart": { + "label": "Auto-start indexing on workspace open", + "description": "When enabled, codebase indexing will automatically start when you open a workspace. Disable this to manually control when indexing begins, which is useful for multi-root workspaces or when using local embedding providers with limited resources." + }, + "maxConcurrent": { + "label": "Maximum concurrent indexing", + "description": "Limits how many workspace folders can be indexed simultaneously. Lower values reduce resource usage but take longer to complete. Useful for local embedding providers like Ollama that may timeout under heavy load." + } }, - "optional": "optional" - }, "autoApprove": { "description": "Run these actions without asking for permission. Only enable for actions you fully trust and if you understand the security risks.", "toggleShortcut": "You can configure a keyboard shortcut for this setting in your IDE preferences.",