feat: code indexing support multiple folder similar with task history (#6204)

* feat: Implement code indexing support multi-folder workspaces similar to task history

* fix: add missing mock for onDidChangeActiveTextEditor in tests
This commit is contained in:
NaccOll 2025-08-05 10:39:37 +08:00 committed by GitHub
parent 8a35b64b9b
commit d90bab71ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 189 additions and 63 deletions

View file

@ -237,7 +237,7 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
mdmService = undefined
}
const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, codeIndexManager, mdmService)
const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService)
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
// Check if there are any visible text editors, otherwise open a new group

View file

@ -1,4 +1,22 @@
// Mocks must come first, before imports
vi.mock("vscode", () => ({
env: {
language: "en",
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/test/path" } }],
getWorkspaceFolder: vi.fn().mockReturnValue({ uri: { fsPath: "/test/path" } }),
},
window: {
activeTextEditor: undefined,
},
EventEmitter: vi.fn().mockImplementation(() => ({
event: vi.fn(),
fire: vi.fn(),
dispose: vi.fn(),
})),
}))
vi.mock("fs/promises", () => {
const mockReadFile = vi.fn()
const mockMkdir = vi.fn().mockResolvedValue(undefined)

View file

@ -85,7 +85,7 @@ async function generatePrompt(
: Promise.resolve(""),
])
const codeIndexManager = CodeIndexManager.getInstance(context)
const codeIndexManager = CodeIndexManager.getInstance(context, cwd)
const basePrompt = `${roleDefinition}

View file

@ -104,6 +104,7 @@ export class ClineProvider
private view?: vscode.WebviewView | vscode.WebviewPanel
private clineStack: Task[] = []
private codeIndexStatusSubscription?: vscode.Disposable
private currentWorkspaceManager?: CodeIndexManager
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
public get workspaceTracker(): WorkspaceTracker | undefined {
return this._workspaceTracker
@ -125,7 +126,6 @@ export class ClineProvider
private readonly outputChannel: vscode.OutputChannel,
private readonly renderContext: "sidebar" | "editor" = "sidebar",
public readonly contextProxy: ContextProxy,
public readonly codeIndexManager?: CodeIndexManager,
mdmService?: MdmService,
) {
super()
@ -133,7 +133,6 @@ export class ClineProvider
this.log("ClineProvider instantiated")
ClineProvider.activeInstances.add(this)
this.codeIndexManager = codeIndexManager
this.mdmService = mdmService
this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES)
@ -602,16 +601,15 @@ export class ClineProvider
// and executes code based on the message that is received
this.setWebviewMessageListener(webviewView.webview)
// Subscribe to code index status updates if the manager exists
if (this.codeIndexManager) {
this.codeIndexStatusSubscription = this.codeIndexManager.onProgressUpdate((update: IndexProgressUpdate) => {
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: update,
})
})
this.webviewDisposables.push(this.codeIndexStatusSubscription)
}
// Initialize code index status subscription for the current workspace
this.updateCodeIndexStatusSubscription()
// Listen for active editor changes to update code index status for the current workspace
const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => {
// Update subscription when workspace might have changed
this.updateCodeIndexStatusSubscription()
})
this.webviewDisposables.push(activeEditorSubscription)
// Logs show up in bottom panel > Debug Console
//console.log("registering listener")
@ -647,8 +645,8 @@ export class ClineProvider
} else {
this.log("Clearing webview resources for sidebar view")
this.clearWebviewResources()
this.codeIndexStatusSubscription?.dispose()
this.codeIndexStatusSubscription = undefined
// Reset current workspace manager reference when view is disposed
this.currentWorkspaceManager = undefined
}
},
null,
@ -2223,6 +2221,61 @@ export class ClineProvider
...gitInfo,
}
}
/**
* Gets the CodeIndexManager for the current active workspace
* @returns CodeIndexManager instance for the current workspace or the default one
*/
public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined {
return CodeIndexManager.getInstance(this.context)
}
/**
* Updates the code index status subscription to listen to the current workspace manager
*/
private updateCodeIndexStatusSubscription(): void {
// Get the current workspace manager
const currentManager = this.getCurrentWorkspaceCodeIndexManager()
// If the manager hasn't changed, no need to update subscription
if (currentManager === this.currentWorkspaceManager) {
return
}
// Dispose the old subscription if it exists
if (this.codeIndexStatusSubscription) {
this.codeIndexStatusSubscription.dispose()
this.codeIndexStatusSubscription = undefined
}
// Update the current workspace manager reference
this.currentWorkspaceManager = currentManager
// Subscribe to the new manager's progress updates if it exists
if (currentManager) {
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
// Only send updates if this manager is still the current one
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
// Get the full status from the manager to ensure we have all fields correctly formatted
const fullStatus = currentManager.getCurrentStatus()
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: fullStatus,
})
}
})
if (this.view) {
this.webviewDisposables.push(this.codeIndexStatusSubscription)
}
// Send initial status for the current workspace
this.postMessageToWebview({
type: "indexingStatusUpdate",
values: currentManager.getCurrentStatus(),
})
}
}
}
class OrganizationAllowListViolationError extends Error {

View file

@ -147,6 +147,7 @@ vi.mock("vscode", () => ({
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({

View file

@ -27,6 +27,7 @@ vi.mock("vscode", () => ({
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({

View file

@ -29,6 +29,7 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { CodeIndexManager } from "../../services/code-index/manager"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
@ -2062,13 +2063,14 @@ export const webviewMessageHandler = async (
// Update webview state
await provider.postStateToWebview()
// Then handle validation and initialization
if (provider.codeIndexManager) {
// Then handle validation and initialization for the current workspace
const currentCodeIndexManager = provider.getCurrentWorkspaceCodeIndexManager()
if (currentCodeIndexManager) {
// If embedder provider changed, perform proactive validation
if (embedderProviderChanged) {
try {
// Force handleSettingsChange which will trigger validation
await provider.codeIndexManager.handleSettingsChange()
await currentCodeIndexManager.handleSettingsChange()
} catch (error) {
// Validation failed - the error state is already set by handleSettingsChange
provider.log(
@ -2077,7 +2079,7 @@ export const webviewMessageHandler = async (
// Send validation error to webview
await provider.postMessageToWebview({
type: "indexingStatusUpdate",
values: provider.codeIndexManager.getCurrentStatus(),
values: currentCodeIndexManager.getCurrentStatus(),
})
// Exit early - don't try to start indexing with invalid configuration
break
@ -2085,7 +2087,7 @@ export const webviewMessageHandler = async (
} else {
// No provider change, just handle settings normally
try {
await provider.codeIndexManager.handleSettingsChange()
await currentCodeIndexManager.handleSettingsChange()
} catch (error) {
// Log but don't fail - settings are saved
provider.log(
@ -2098,10 +2100,10 @@ export const webviewMessageHandler = async (
await new Promise((resolve) => setTimeout(resolve, 200))
// Auto-start indexing if now enabled and configured
if (provider.codeIndexManager.isFeatureEnabled && provider.codeIndexManager.isFeatureConfigured) {
if (!provider.codeIndexManager.isInitialized) {
if (currentCodeIndexManager.isFeatureEnabled && currentCodeIndexManager.isFeatureConfigured) {
if (!currentCodeIndexManager.isInitialized) {
try {
await provider.codeIndexManager.initialize(provider.contextProxy)
await currentCodeIndexManager.initialize(provider.contextProxy)
provider.log(`Code index manager initialized after settings save`)
} catch (error) {
provider.log(
@ -2110,7 +2112,7 @@ export const webviewMessageHandler = async (
// Send error status to webview
await provider.postMessageToWebview({
type: "indexingStatusUpdate",
values: provider.codeIndexManager.getCurrentStatus(),
values: currentCodeIndexManager.getCurrentStatus(),
})
}
}
@ -2141,7 +2143,7 @@ export const webviewMessageHandler = async (
}
case "requestIndexingStatus": {
const manager = provider.codeIndexManager
const manager = provider.getCurrentWorkspaceCodeIndexManager()
if (!manager) {
// No workspace open - send error status
provider.postMessageToWebview({
@ -2152,11 +2154,23 @@ export const webviewMessageHandler = async (
processedItems: 0,
totalItems: 0,
currentItemUnit: "items",
workerspacePath: undefined,
},
})
return
}
const status = manager.getCurrentStatus()
const status = manager
? manager.getCurrentStatus()
: {
systemStatus: "Standby",
message: "No workspace folder open",
processedItems: 0,
totalItems: 0,
currentItemUnit: "items",
workspacePath: undefined,
}
provider.postMessageToWebview({
type: "indexingStatusUpdate",
values: status,
@ -2187,7 +2201,7 @@ export const webviewMessageHandler = async (
}
case "startIndexing": {
try {
const manager = provider.codeIndexManager
const manager = provider.getCurrentWorkspaceCodeIndexManager()
if (!manager) {
// No workspace open - send error status
provider.postMessageToWebview({
@ -2217,7 +2231,7 @@ export const webviewMessageHandler = async (
}
case "clearIndexData": {
try {
const manager = provider.codeIndexManager
const manager = provider.getCurrentWorkspaceCodeIndexManager()
if (!manager) {
provider.log("Cannot clear index data: No workspace folder open")
provider.postMessageToWebview({

View file

@ -93,14 +93,24 @@ export async function activate(context: vscode.ExtensionContext) {
}
const contextProxy = await ContextProxy.getInstance(context)
const codeIndexManager = CodeIndexManager.getInstance(context)
try {
await codeIndexManager?.initialize(contextProxy)
} catch (error) {
outputChannel.appendLine(
`[CodeIndexManager] Error during background CodeIndexManager configuration/indexing: ${error.message || error}`,
)
// Initialize code index managers for all workspace folders
const codeIndexManagers: CodeIndexManager[] = []
if (vscode.workspace.workspaceFolders) {
for (const folder of vscode.workspace.workspaceFolders) {
const manager = CodeIndexManager.getInstance(context, folder.uri.fsPath)
if (manager) {
codeIndexManagers.push(manager)
try {
await manager.initialize(contextProxy)
} catch (error) {
outputChannel.appendLine(
`[CodeIndexManager] Error during background CodeIndexManager configuration/indexing for ${folder.uri.fsPath}: ${error.message || error}`,
)
}
context.subscriptions.push(manager)
}
}
}
// Initialize Roo Code Cloud service.
@ -126,13 +136,9 @@ export async function activate(context: vscode.ExtensionContext) {
// Add to subscriptions for proper cleanup on deactivate.
context.subscriptions.push(cloudService)
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, codeIndexManager, mdmService)
const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService)
TelemetryService.instance.setProvider(provider)
if (codeIndexManager) {
context.subscriptions.push(codeIndexManager)
}
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, provider, {
webviewOptions: { retainContextWhenHidden: true },

View file

@ -4,6 +4,9 @@ import type { MockedClass } from "vitest"
// Mock vscode module
vi.mock("vscode", () => ({
window: {
activeTextEditor: null,
},
workspace: {
workspaceFolders: [
{

View file

@ -28,17 +28,24 @@ export class CodeIndexManager {
private _searchService: CodeIndexSearchService | undefined
private _cacheManager: CacheManager | undefined
public static getInstance(context: vscode.ExtensionContext): CodeIndexManager | undefined {
// Use first workspace folder consistently
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return undefined
}
public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined {
// If workspacePath is not provided, try to get it from the active editor or first workspace folder
if (!workspacePath) {
const activeEditor = vscode.window.activeTextEditor
if (activeEditor) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri)
workspacePath = workspaceFolder?.uri.fsPath
}
// Always use the first workspace folder for consistency across all indexing operations.
// This ensures that the same workspace context is used throughout the indexing pipeline,
// preventing path resolution errors in multi-workspace scenarios.
const workspacePath = workspaceFolders[0].uri.fsPath
if (!workspacePath) {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return undefined
}
// Use the first workspace folder as fallback
workspacePath = workspaceFolders[0].uri.fsPath
}
}
if (!CodeIndexManager.instances.has(workspacePath)) {
CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, context))
@ -205,7 +212,11 @@ export class CodeIndexManager {
// --- Private Helpers ---
public getCurrentStatus() {
return this._stateManager.getCurrentStatus()
const status = this._stateManager.getCurrentStatus()
return {
...status,
workspacePath: this.workspacePath,
}
}
public async searchIndex(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {

View file

@ -41,6 +41,7 @@ export interface IndexingStatus {
processedItems: number
totalItems: number
currentItemUnit?: string
workspacePath?: string
}
export interface IndexingStatusUpdateMessage {

View file

@ -147,7 +147,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
}) => {
const SECRET_PLACEHOLDER = "••••••••••••••••"
const { t } = useAppTranslation()
const { codebaseIndexConfig, codebaseIndexModels } = useExtensionState()
const { codebaseIndexConfig, codebaseIndexModels, cwd } = useExtensionState()
const [open, setOpen] = useState(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false)
const [isSetupSettingsOpen, setIsSetupSettingsOpen] = useState(false)
@ -229,6 +229,18 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
vscode.postMessage({ type: "requestIndexingStatus" })
vscode.postMessage({ type: "requestCodeIndexSecretStatus" })
}
const handleMessage = (event: MessageEvent) => {
if (event.data.type === "workspaceUpdated") {
// When workspace changes, request updated indexing status
if (open) {
vscode.postMessage({ type: "requestIndexingStatus" })
vscode.postMessage({ type: "requestCodeIndexSecretStatus" })
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [open])
// Use a ref to capture current settings for the save handler
@ -239,13 +251,15 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
useEffect(() => {
const handleMessage = (event: MessageEvent<any>) => {
if (event.data.type === "indexingStatusUpdate") {
setIndexingStatus({
systemStatus: event.data.values.systemStatus,
message: event.data.values.message || "",
processedItems: event.data.values.processedItems,
totalItems: event.data.values.totalItems,
currentItemUnit: event.data.values.currentItemUnit || "items",
})
if (!event.data.values.workspacePath || event.data.values.workspacePath === cwd) {
setIndexingStatus({
systemStatus: event.data.values.systemStatus,
message: event.data.values.message || "",
processedItems: event.data.values.processedItems,
totalItems: event.data.values.totalItems,
currentItemUnit: event.data.values.currentItemUnit || "items",
})
}
} else if (event.data.type === "codeIndexSettingsSaved") {
if (event.data.success) {
setSaveStatus("saved")
@ -273,7 +287,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [t])
}, [t, cwd])
// Listen for secret status
useEffect(() => {

View file

@ -4,6 +4,7 @@ import { cn } from "@src/lib/utils"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useTooltip } from "@/hooks/useTooltip"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { CodeIndexPopover } from "./CodeIndexPopover"
import type { IndexingStatus, IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
@ -13,6 +14,7 @@ interface IndexingStatusBadgeProps {
export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ className }) => {
const { t } = useAppTranslation()
const { cwd } = useExtensionState()
const { showTooltip, handleMouseEnter, handleMouseLeave, cleanup } = useTooltip({ delay: 300 })
const [isHovered, setIsHovered] = useState(false)
@ -31,7 +33,9 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
const handleMessage = (event: MessageEvent<IndexingStatusUpdateMessage>) => {
if (event.data.type === "indexingStatusUpdate") {
const status = event.data.values
setIndexingStatus(status)
if (!status.workspacePath || status.workspacePath === cwd) {
setIndexingStatus(status)
}
}
}
@ -41,7 +45,7 @@ export const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({ classN
window.removeEventListener("message", handleMessage)
cleanup()
}
}, [cleanup])
}, [cleanup, cwd])
// Calculate progress percentage with memoization
const progressPercentage = useMemo(