diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0b4e9fc85..6e0e4681012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26884,7 +26884,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@2.0.5': dependencies: diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index acc1b15500e..4236ee9f103 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -135,6 +135,7 @@ export function getToolDescriptionsForMode( // Conditionally exclude codebase_search if feature is disabled or not configured if ( !codeIndexManager || + !codeIndexManager?.isManagedIndexingAvailable || // kilocode_change !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) ) { tools.delete("codebase_search") diff --git a/src/core/tools/codebaseSearchTool.ts b/src/core/tools/codebaseSearchTool.ts index b310d1d79a4..9fe9f175af4 100644 --- a/src/core/tools/codebaseSearchTool.ts +++ b/src/core/tools/codebaseSearchTool.ts @@ -185,8 +185,7 @@ ${jsonResult.results (result) => `File path: ${result.filePath} Score: ${result.score} Lines: ${result.startLine}-${result.endLine} -Code Chunk: ${result.codeChunk} -`, +${result.codeChunk ? `Code Chunk: ${result.codeChunk}\n` : ""}`, // kilocode_change - don't include code chunk managed indexing ) .join("\n")}` diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 077c7ccc685..58bf8aac9a2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1358,6 +1358,10 @@ ${prompt} } await TelemetryService.instance.updateIdentity(providerSettings.kilocodeToken ?? "") // kilocode_change + + // kilocode_change start Update code index with new Kilo org props + await this.updateCodeIndexWithKiloProps() + // kilocode_change end } else { await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) } @@ -1423,6 +1427,10 @@ ${prompt} await this.postStateToWebview() await TelemetryService.instance.updateIdentity(providerSettings.kilocodeToken ?? "") // kilocode_change + // kilocode_change start Update code index with new Kilo org props + await this.updateCodeIndexWithKiloProps() + // kilocode_change end + if (providerSettings.apiProvider) { this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) } @@ -3350,4 +3358,80 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont return vscode.Uri.file(filePath).toString() } } + + // kilocode_change start + /** + * Updates the code index manager with current Kilo org credentials + * This should be called whenever the API configuration changes + */ + private async updateCodeIndexWithKiloProps(): Promise { + try { + const { apiConfiguration } = await this.getState() + + // Only proceed if we have both required credentials + if (!apiConfiguration.kilocodeToken || !apiConfiguration.kilocodeOrganizationId) { + return + } + + // Get kilocodeTesterWarningsDisabledUntil from context + const kilocodeTesterWarningsDisabledUntil = this.contextProxy.getValue( + "kilocodeTesterWarningsDisabledUntil", + ) + + // Fetch organization settings to check if code indexing is enabled + const { OrganizationService } = await import("../../services/kilocode/OrganizationService") + const organization = await OrganizationService.fetchOrganization( + apiConfiguration.kilocodeToken, + apiConfiguration.kilocodeOrganizationId, + kilocodeTesterWarningsDisabledUntil, + ) + + // Check if code indexing is enabled for this organization + const codeIndexingEnabled = OrganizationService.isCodeIndexingEnabled(organization) + + if (!codeIndexingEnabled) { + this.log("[updateCodeIndexWithKiloProps] Code indexing is disabled for this organization") + return + } + + // Get project ID from Kilo config + const kiloConfig = await this.getKiloConfig() + const projectId = kiloConfig?.project?.id + + if (!projectId) { + this.log("[updateCodeIndexWithKiloProps] No projectId found in Kilo config, skipping code index update") + return + } + + // Get or create the code index manager for the current workspace + let codeIndexManager = this.getCurrentWorkspaceCodeIndexManager() + + // If manager doesn't exist yet, it will be created on first access + // We need to ensure it's initialized with the context proxy + if (!codeIndexManager) { + // Try to get the manager again, which will create it if workspace exists + const workspacePath = this.cwd + if (workspacePath) { + codeIndexManager = CodeIndexManager.getInstance(this.context, workspacePath) + } + } + + if (codeIndexManager) { + // Set the Kilo org props - code indexing is enabled + codeIndexManager.setKiloOrgCodeIndexProps({ + kilocodeToken: apiConfiguration.kilocodeToken, + organizationId: apiConfiguration.kilocodeOrganizationId, + projectId, + }) + + // Initialize the manager with context proxy if not already initialized + if (!codeIndexManager.isInitialized) { + await codeIndexManager.initialize(this.contextProxy) + } + } + } catch (error) { + this.log(`Failed to update code index with Kilo props: ${error}`) + } + } + // kilocode_change end } diff --git a/src/core/webview/__tests__/webviewMessageHandler.codeIndex.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.codeIndex.spec.ts new file mode 100644 index 00000000000..2b08c979c19 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.codeIndex.spec.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +// Mock the getKiloConfig method +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +describe("webviewMessageHandler - requestIndexingStatus with managed indexing", () => { + let mockProvider: any + let mockManager: any + + beforeEach(() => { + vi.clearAllMocks() + + mockManager = { + getCurrentStatus: vi.fn(() => ({ + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + workspacePath: "/test/workspace", + })), + getKiloOrgCodeIndexProps: vi.fn(() => null), + setKiloOrgCodeIndexProps: vi.fn(), + workspacePath: "/test/workspace", + } + + mockProvider = { + getCurrentWorkspaceCodeIndexManager: vi.fn(() => mockManager), + getState: vi.fn(async () => ({ + apiConfiguration: { + kilocodeToken: "test-token", + kilocodeOrganizationId: "test-org-id", + }, + })), + getKiloConfig: vi.fn(async () => ({ + project: { + id: "test-project-id", + }, + })), + postMessageToWebview: vi.fn(), + log: vi.fn(), + } as unknown as ClineProvider + }) + + it("should set Kilo org props before getting status when organization credentials are available", async () => { + await webviewMessageHandler(mockProvider, { + type: "requestIndexingStatus", + }) + + // Verify that setKiloOrgCodeIndexProps was called with correct props + expect(mockManager.setKiloOrgCodeIndexProps).toHaveBeenCalledWith({ + kilocodeToken: "test-token", + organizationId: "test-org-id", + projectId: "test-project-id", + }) + + // Verify that status was retrieved and sent to webview + expect(mockManager.getCurrentStatus).toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: expect.objectContaining({ + systemStatus: "Standby", + }), + }) + }) + + it("should not set Kilo org props if they are already set", async () => { + // Mock that props are already set + mockManager.getKiloOrgCodeIndexProps.mockReturnValue({ + kilocodeToken: "test-token", + organizationId: "test-org-id", + projectId: "test-project-id", + }) + + await webviewMessageHandler(mockProvider, { + type: "requestIndexingStatus", + }) + + // Verify that setKiloOrgCodeIndexProps was NOT called + expect(mockManager.setKiloOrgCodeIndexProps).not.toHaveBeenCalled() + + // Verify that status was still retrieved + expect(mockManager.getCurrentStatus).toHaveBeenCalled() + }) + + it("should not set Kilo org props if organization credentials are missing", async () => { + mockProvider.getState = vi.fn(async () => ({ + apiConfiguration: { + // No kilocodeToken or kilocodeOrganizationId + }, + })) + + await webviewMessageHandler(mockProvider, { + type: "requestIndexingStatus", + }) + + // Verify that setKiloOrgCodeIndexProps was NOT called + expect(mockManager.setKiloOrgCodeIndexProps).not.toHaveBeenCalled() + + // Verify that status was still retrieved + expect(mockManager.getCurrentStatus).toHaveBeenCalled() + }) + + it("should not set Kilo org props if project ID is missing", async () => { + mockProvider.getKiloConfig = vi.fn(async () => ({ + project: { + // No id + }, + })) + + await webviewMessageHandler(mockProvider, { + type: "requestIndexingStatus", + }) + + // Verify that setKiloOrgCodeIndexProps was NOT called + expect(mockManager.setKiloOrgCodeIndexProps).not.toHaveBeenCalled() + + // Verify that status was still retrieved + expect(mockManager.getCurrentStatus).toHaveBeenCalled() + }) + + it("should send error status when no workspace is open", async () => { + mockProvider.getCurrentWorkspaceCodeIndexManager = vi.fn(() => null) + + await webviewMessageHandler(mockProvider, { + type: "requestIndexingStatus", + }) + + // Verify error status was sent + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "indexingStatusUpdate", + values: { + systemStatus: "Error", + message: "orchestrator.indexingRequiresWorkspace", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + workerspacePath: undefined, + }, + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 48199607afe..99a7baa3dc7 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3140,6 +3140,26 @@ export const webviewMessageHandler = async ( return } + // kilocode_change start: Set Kilo org props before getting status + const { apiConfiguration } = await provider.getState() + if (apiConfiguration.kilocodeToken && apiConfiguration.kilocodeOrganizationId) { + // Get project ID from Kilo config + const kiloConfig = await provider.getKiloConfig() + const projectId = kiloConfig?.project?.id + + if (projectId && !manager.getKiloOrgCodeIndexProps()) { + provider.log( + `[requestIndexingStatus] Setting Kilo org props: orgId=${apiConfiguration.kilocodeOrganizationId}`, + ) + manager.setKiloOrgCodeIndexProps({ + kilocodeToken: apiConfiguration.kilocodeToken, + organizationId: apiConfiguration.kilocodeOrganizationId, + projectId, + }) + } + } + // kilocode_change end + const status = manager ? manager.getCurrentStatus() : { @@ -3201,27 +3221,84 @@ export const webviewMessageHandler = async ( provider.log("Cannot start indexing: No workspace folder open") return } - if (manager.isFeatureEnabled && manager.isFeatureConfigured) { + + // kilocode_change start: Support managed indexing + const { apiConfiguration } = await provider.getState() + if (apiConfiguration.kilocodeToken && apiConfiguration.kilocodeOrganizationId) { + provider.log( + `[startIndexing] Setting Kilo org props: orgId=${apiConfiguration.kilocodeOrganizationId}`, + ) + // Get project ID from Kilo config + const kiloConfig = await provider.getKiloConfig() + const projectId = kiloConfig?.project?.id + + if (projectId) { + manager.setKiloOrgCodeIndexProps({ + kilocodeToken: apiConfiguration.kilocodeToken, + organizationId: apiConfiguration.kilocodeOrganizationId, + projectId, + }) + } else { + provider.log(`[startIndexing] No projectId found in Kilo config`) + } + } else { + provider.log( + `[startIndexing] No Kilo org props available: token=${!!apiConfiguration.kilocodeToken}, orgId=${!!apiConfiguration.kilocodeOrganizationId}`, + ) + } + + provider.log( + `[startIndexing] Feature enabled: ${manager.isFeatureEnabled}, configured: ${manager.isFeatureConfigured}, initialized: ${manager.isInitialized}`, + ) + + // Check if managed indexing is available (has org credentials) + if (manager.isManagedIndexingAvailable) { + provider.log( + `[startIndexing] Using managed indexing (already started via setKiloOrgCodeIndexProps)`, + ) + // Managed indexing is already started in setKiloOrgCodeIndexProps + // No need to start local indexing + } else if (manager.isFeatureEnabled && manager.isFeatureConfigured) { + // Use local indexing if (!manager.isInitialized) { - await manager.initialize(provider.contextProxy) + provider.log(`[startIndexing] Initializing manager for local indexing...`) + try { + await manager.initialize(provider.contextProxy) + provider.log(`[startIndexing] Manager initialized successfully`) + } catch (initError) { + provider.log( + `[startIndexing] Initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`, + ) + provider.log( + `[startIndexing] Stack: ${initError instanceof Error ? initError.stack : "N/A"}`, + ) + throw initError + } } // startIndexing now handles error recovery internally + provider.log(`[startIndexing] Starting local indexing...`) manager.startIndexing() // If startIndexing recovered from error, we need to reinitialize if (!manager.isInitialized) { + provider.log(`[startIndexing] Manager not initialized after startIndexing, reinitializing...`) await manager.initialize(provider.contextProxy) // Try starting again after initialization manager.startIndexing() } + } else { + provider.log( + `[startIndexing] Cannot start: enabled=${manager.isFeatureEnabled}, configured=${manager.isFeatureConfigured}`, + ) } } catch (error) { provider.log(`Error starting indexing: ${error instanceof Error ? error.message : String(error)}`) + provider.log(`Stack: ${error instanceof Error ? error.stack : "N/A"}`) } + // kilocode_change end break } - // kilocode_change start case "cancelIndexing": { try { const manager = provider.getCurrentWorkspaceCodeIndexManager() @@ -3281,6 +3358,100 @@ export const webviewMessageHandler = async ( } break } + // kilocode_change start - Managed indexing management operations + case "deleteManagedBranchIndex": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + vscode.window.showErrorMessage("No workspace folder open") + return + } + + const { apiConfiguration } = await provider.getState() + if (!apiConfiguration.kilocodeToken || !apiConfiguration.kilocodeOrganizationId) { + vscode.window.showErrorMessage("Organization credentials not configured") + return + } + + // Get project ID from Kilo config + const kiloConfig = await provider.getKiloConfig() + const projectId = kiloConfig?.project?.id + if (!projectId) { + vscode.window.showErrorMessage("No project ID found in Kilo config") + return + } + + // Get current branch + const { getCurrentBranch, deleteBranchIndex } = await import("../../services/code-index/managed") + const gitBranch = getCurrentBranch(manager.workspacePath) + + // Delete branch index from server + await deleteBranchIndex( + apiConfiguration.kilocodeOrganizationId, + projectId, + gitBranch, + apiConfiguration.kilocodeToken, + ) + + vscode.window.showInformationMessage(`Branch index for '${gitBranch}' deleted successfully`) + + // Update status + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error deleting managed branch index: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to delete branch index: ${errorMessage}`) + } + break + } + case "deleteManagedProjectIndex": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + vscode.window.showErrorMessage("No workspace folder open") + return + } + + const { apiConfiguration } = await provider.getState() + if (!apiConfiguration.kilocodeToken || !apiConfiguration.kilocodeOrganizationId) { + vscode.window.showErrorMessage("Organization credentials not configured") + return + } + + // Get project ID from Kilo config + const kiloConfig = await provider.getKiloConfig() + const projectId = kiloConfig?.project?.id + if (!projectId) { + vscode.window.showErrorMessage("No project ID found in Kilo config") + return + } + + // Delete entire project index from server + const { deleteProjectIndex } = await import("../../services/code-index/managed") + await deleteProjectIndex( + apiConfiguration.kilocodeOrganizationId, + projectId, + apiConfiguration.kilocodeToken, + ) + + vscode.window.showInformationMessage("Entire project index deleted successfully") + + // Update status + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error deleting managed project index: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to delete project index: ${errorMessage}`) + } + break + } + // kilocode_change end // kilocode_change start - add clearUsageData case "clearUsageData": { try { diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 2c0e8bb5c9e..04c22642724 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -25,11 +25,46 @@ export class CodeIndexConfigManager { private searchMinScore?: number private searchMaxResults?: number + // kilocode_change start: Kilo org indexing props + private _kiloOrgProps: { + organizationId: string + kilocodeToken: string + projectId: string + } | null = null + // kilocode_change end + constructor(private readonly contextProxy: ContextProxy) { // Initialize with current configuration to avoid false restart triggers this._loadAndSetConfiguration() } + // kilocode_change start: Kilo org indexing methods + /** + * Sets Kilo organization properties for cloud-based indexing + */ + public setKiloOrgProps(props: { organizationId: string; kilocodeToken: string; projectId: string }) { + this._kiloOrgProps = props + } + + /** + * Gets Kilo organization properties + */ + public getKiloOrgProps() { + return this._kiloOrgProps + } + + /** + * Checks if Kilo org mode is available (has valid credentials) + */ + public get isKiloOrgMode(): boolean { + return !!( + this._kiloOrgProps?.organizationId && + this._kiloOrgProps?.kilocodeToken && + this._kiloOrgProps?.projectId + ) + } + // kilocode_change end + /** * Gets the context proxy instance */ @@ -202,8 +237,15 @@ export class CodeIndexConfigManager { /** * Checks if the service is properly configured based on the embedder type. + * kilocode_change: Also returns true if Kilo org mode is available */ public isConfigured(): boolean { + // kilocode_change start: Allow Kilo org mode as configured + if (this.isKiloOrgMode) { + return true + } + // kilocode_change end + if (this.embedderProvider === "openai") { const openAiKey = this.openAiOptions?.openAiNativeApiKey const qdrantUrl = this.qdrantUrl diff --git a/src/services/code-index/constants/index.ts b/src/services/code-index/constants/index.ts index 6f0e0fe7e62..98684a47efd 100644 --- a/src/services/code-index/constants/index.ts +++ b/src/services/code-index/constants/index.ts @@ -29,3 +29,14 @@ export const BATCH_PROCESSING_CONCURRENCY = 10 /**Gemini Embedder */ export const GEMINI_MAX_ITEM_TOKENS = 2048 + +// kilocode_change start +/**Managed Indexing */ +export const MANAGED_MAX_CHUNK_CHARS = 1000 +export const MANAGED_MIN_CHUNK_CHARS = 200 +export const MANAGED_OVERLAP_LINES = 5 +export const MANAGED_BATCH_SIZE = 60 +export const MANAGED_FILE_WATCH_DEBOUNCE_MS = 500 +export const MANAGED_MAX_CONCURRENT_FILES = 10 +export const MANAGED_MAX_CONCURRENT_BATCHES = 50 +// kilocode_change end diff --git a/src/services/code-index/managed/__tests__/api-client-management.spec.ts b/src/services/code-index/managed/__tests__/api-client-management.spec.ts new file mode 100644 index 00000000000..2ac07966aca --- /dev/null +++ b/src/services/code-index/managed/__tests__/api-client-management.spec.ts @@ -0,0 +1,120 @@ +/** + * Tests for API client management operations + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import axios from "axios" +import { deleteBranchIndex, deleteProjectIndex } from "../api-client" + +// Mock axios +vi.mock("axios") + +// Mock token utilities +vi.mock("../../../../shared/kilocode/token", () => ({ + getKiloBaseUriFromToken: vi.fn(() => "https://api.kilocode.ai"), +})) + +// Mock logger +vi.mock("../../../../utils/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +describe("API Client Management Operations", () => { + const organizationId = "org-123" + const projectId = "proj-456" + const gitBranch = "feature/test" + const kilocodeToken = "test-token" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("deleteBranchIndex", () => { + it("should delete branch index successfully", async () => { + vi.mocked(axios).mockResolvedValueOnce({ + status: 200, + data: {}, + }) + + await deleteBranchIndex(organizationId, projectId, gitBranch, kilocodeToken) + + expect(axios).toHaveBeenCalledWith({ + method: "DELETE", + url: "https://api.kilocode.ai/api/codebase-indexing/branch", + data: { + organizationId, + projectId, + gitBranch, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + }) + + it("should throw error on failure", async () => { + vi.mocked(axios).mockResolvedValueOnce({ + status: 500, + statusText: "Internal Server Error", + }) + + await expect(deleteBranchIndex(organizationId, projectId, gitBranch, kilocodeToken)).rejects.toThrow( + "Failed to delete branch index", + ) + }) + + it("should handle network errors", async () => { + vi.mocked(axios).mockRejectedValueOnce(new Error("Network error")) + + await expect(deleteBranchIndex(organizationId, projectId, gitBranch, kilocodeToken)).rejects.toThrow( + "Network error", + ) + }) + }) + + describe("deleteProjectIndex", () => { + it("should delete project index successfully", async () => { + vi.mocked(axios).mockResolvedValueOnce({ + status: 200, + data: {}, + }) + + await deleteProjectIndex(organizationId, projectId, kilocodeToken) + + expect(axios).toHaveBeenCalledWith({ + method: "DELETE", + url: "https://api.kilocode.ai/api/codebase-indexing/project", + data: { + organizationId, + projectId, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + }) + + it("should throw error on failure", async () => { + vi.mocked(axios).mockResolvedValueOnce({ + status: 500, + statusText: "Internal Server Error", + }) + + await expect(deleteProjectIndex(organizationId, projectId, kilocodeToken)).rejects.toThrow( + "Failed to delete project index", + ) + }) + + it("should handle network errors", async () => { + vi.mocked(axios).mockRejectedValueOnce(new Error("Network error")) + + await expect(deleteProjectIndex(organizationId, projectId, kilocodeToken)).rejects.toThrow("Network error") + }) + }) +}) diff --git a/src/services/code-index/managed/__tests__/error-handling.spec.ts b/src/services/code-index/managed/__tests__/error-handling.spec.ts new file mode 100644 index 00000000000..e1352a8955e --- /dev/null +++ b/src/services/code-index/managed/__tests__/error-handling.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" +import { startIndexing } from "../indexer" +import { scanDirectory } from "../scanner" +import { ManagedIndexingConfig } from "../types" + +// Mock dependencies +vi.mock("../scanner") +vi.mock("../watcher", () => ({ + createFileWatcher: vi.fn(() => ({ + dispose: vi.fn(), + })), +})) +vi.mock("../git-utils", () => ({ + isGitRepository: vi.fn(() => true), + getCurrentBranch: vi.fn(() => "main"), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) +vi.mock("../../../utils/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})) + +describe("Managed Indexing Error Handling", () => { + let mockContext: vscode.ExtensionContext + let config: ManagedIndexingConfig + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + } as any + + config = { + organizationId: "test-org", + projectId: "test-project", + kilocodeToken: "test-token", + workspacePath: "/test/workspace", + chunker: { + maxChunkChars: 1000, + minChunkChars: 200, + overlapLines: 5, + }, + batchSize: 60, + autoSync: true, + } + }) + + it("should provide detailed error messages when scan fails", async () => { + // Mock scanDirectory to return errors + const mockErrors = [ + new Error("Failed to process file1.ts: Network error"), + new Error("Failed to process file2.ts: Permission denied"), + new Error("Failed to process file3.ts: Invalid syntax"), + ] + + vi.mocked(scanDirectory).mockResolvedValue({ + success: false, + filesProcessed: 0, + filesSkipped: 0, + chunksIndexed: 0, + errors: mockErrors, + }) + + // Attempt to start indexing + await expect(startIndexing(config, mockContext)).rejects.toThrow(/Scan failed with 3 errors/) + }) + + it("should include error details in thrown error message", async () => { + const mockErrors = [new Error("Error 1"), new Error("Error 2"), new Error("Error 3")] + + vi.mocked(scanDirectory).mockResolvedValue({ + success: false, + filesProcessed: 0, + filesSkipped: 0, + chunksIndexed: 0, + errors: mockErrors, + }) + + try { + await startIndexing(config, mockContext) + expect.fail("Should have thrown an error") + } catch (error) { + expect(error).toBeInstanceOf(Error) + const err = error as Error + expect(err.message).toContain("Error 1") + expect(err.message).toContain("Error 2") + expect(err.message).toContain("Error 3") + } + }) + + it("should truncate error list when there are many errors", async () => { + const mockErrors = Array.from({ length: 25 }, (_, i) => new Error(`Error ${i + 1}`)) + + vi.mocked(scanDirectory).mockResolvedValue({ + success: false, + filesProcessed: 0, + filesSkipped: 0, + chunksIndexed: 0, + errors: mockErrors, + }) + + try { + await startIndexing(config, mockContext) + expect.fail("Should have thrown an error") + } catch (error) { + expect(error).toBeInstanceOf(Error) + const err = error as Error + expect(err.message).toContain("Scan failed with 25 errors") + expect(err.message).toContain("and 20 more") + // Should only include first 5 errors in message + expect(err.message).toContain("Error 1") + expect(err.message).toContain("Error 5") + expect(err.message).not.toContain("Error 6") + } + }) + + it("should call state change callback with error state", async () => { + const mockErrors = [new Error("Test error")] + const onStateChange = vi.fn() + + vi.mocked(scanDirectory).mockResolvedValue({ + success: false, + filesProcessed: 0, + filesSkipped: 0, + chunksIndexed: 0, + errors: mockErrors, + }) + + try { + await startIndexing(config, mockContext, onStateChange) + } catch { + // Expected to throw + } + + // Should have called with error state + expect(onStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + status: "error", + message: expect.stringContaining("Failed to start indexing"), + }), + ) + }) +}) diff --git a/src/services/code-index/managed/__tests__/get-base-branch.spec.ts b/src/services/code-index/managed/__tests__/get-base-branch.spec.ts new file mode 100644 index 00000000000..8fea2d15f6b --- /dev/null +++ b/src/services/code-index/managed/__tests__/get-base-branch.spec.ts @@ -0,0 +1,207 @@ +/** + * Tests for getBaseBranch and getDefaultBranchFromRemote functionality + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { execSync } from "child_process" +import { getBaseBranch, getDefaultBranchFromRemote } from "../git-utils" + +// Mock child_process +vi.mock("child_process", () => ({ + execSync: vi.fn(), +})) + +describe("Git Base Branch Detection", () => { + const workspacePath = "/Users/test/project" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getDefaultBranchFromRemote", () => { + it("should return default branch from remote symbolic ref", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/main\n") + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBe("main") + expect(execSync).toHaveBeenCalledWith("git symbolic-ref refs/remotes/origin/HEAD", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + }) + + it("should return canary when remote default is canary", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/canary\n") + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBe("canary") + }) + + it("should return develop when remote default is develop", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/develop\n") + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBe("develop") + }) + + it("should try to set remote HEAD if symbolic-ref fails initially", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (callCount === 1) { + // First call to symbolic-ref fails + throw new Error("No symbolic ref") + } else if (callCount === 2) { + // Second call to set-head succeeds + return "" + } else if (callCount === 3) { + // Third call to symbolic-ref succeeds + return "refs/remotes/origin/main\n" + } + return "" + }) + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBe("main") + expect(execSync).toHaveBeenCalledTimes(3) + expect(execSync).toHaveBeenNthCalledWith(2, "git remote set-head origin --auto", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + }) + + it("should return null if unable to determine remote default", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("Failed") + }) + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBeNull() + }) + + it("should return null if symbolic-ref output is malformed", () => { + vi.mocked(execSync).mockReturnValue("invalid-format\n") + + const result = getDefaultBranchFromRemote(workspacePath) + + expect(result).toBeNull() + }) + }) + + describe("getBaseBranch", () => { + it("should return default branch from remote when available", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (cmd.includes("symbolic-ref")) { + return "refs/remotes/origin/canary\n" + } else if (cmd.includes("rev-parse --verify canary")) { + return "abc123\n" + } + return "" + }) + + const result = getBaseBranch(workspacePath) + + expect(result).toBe("canary") + }) + + it("should fallback to main if remote default doesn't exist locally", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (cmd.includes("symbolic-ref")) { + return "refs/remotes/origin/canary\n" + } else if (cmd.includes("rev-parse --verify canary")) { + throw new Error("Branch doesn't exist locally") + } else if (cmd.includes("rev-parse --verify main")) { + return "abc123\n" + } + return "" + }) + + const result = getBaseBranch(workspacePath) + + expect(result).toBe("main") + }) + + it("should check common branches when remote default is unavailable", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (cmd.includes("symbolic-ref")) { + throw new Error("No remote HEAD") + } else if (cmd.includes("set-head")) { + throw new Error("Cannot set HEAD") + } else if (cmd.includes("rev-parse --verify main")) { + throw new Error("main doesn't exist") + } else if (cmd.includes("rev-parse --verify develop")) { + return "abc123\n" + } + return "" + }) + + const result = getBaseBranch(workspacePath) + + expect(result).toBe("develop") + }) + + it("should return master if main and develop don't exist", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (cmd.includes("symbolic-ref") || cmd.includes("set-head")) { + throw new Error("No remote") + } else if (cmd.includes("rev-parse --verify main")) { + throw new Error("main doesn't exist") + } else if (cmd.includes("rev-parse --verify develop")) { + throw new Error("develop doesn't exist") + } else if (cmd.includes("rev-parse --verify master")) { + return "abc123\n" + } + return "" + }) + + const result = getBaseBranch(workspacePath) + + expect(result).toBe("master") + }) + + it("should fallback to main if no branches exist", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("No branches") + }) + + const result = getBaseBranch(workspacePath) + + expect(result).toBe("main") + }) + + it("should prioritize remote default over common branch names", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (cmd.includes("symbolic-ref")) { + return "refs/remotes/origin/production\n" + } else if (cmd.includes("rev-parse --verify production")) { + return "abc123\n" + } else if (cmd.includes("rev-parse --verify main")) { + return "def456\n" + } + return "" + }) + + const result = getBaseBranch(workspacePath) + + // Should return production (from remote) even though main exists + expect(result).toBe("production") + }) + }) +}) diff --git a/src/services/code-index/managed/__tests__/git-tracked-files.spec.ts b/src/services/code-index/managed/__tests__/git-tracked-files.spec.ts new file mode 100644 index 00000000000..6691c6dd0fe --- /dev/null +++ b/src/services/code-index/managed/__tests__/git-tracked-files.spec.ts @@ -0,0 +1,102 @@ +/** + * Tests for git-tracked files functionality + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { execSync } from "child_process" +import { getGitTrackedFilesSync } from "../git-utils" + +// Mock child_process +vi.mock("child_process", () => ({ + execSync: vi.fn(), +})) + +describe("Git Tracked Files", () => { + const workspacePath = "/Users/test/project" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getGitTrackedFilesSync", () => { + it("should return list of git-tracked files", () => { + const mockOutput = `src/app.ts +src/utils/helper.ts +src/index.ts +README.md +package.json` + + vi.mocked(execSync).mockReturnValue(mockOutput) + + const files = getGitTrackedFilesSync(workspacePath) + + expect(files).toEqual(["src/app.ts", "src/utils/helper.ts", "src/index.ts", "README.md", "package.json"]) + + expect(execSync).toHaveBeenCalledWith("git ls-files", { + cwd: workspacePath, + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + }) + }) + + it("should filter out empty lines", () => { + const mockOutput = `src/app.ts + +src/utils/helper.ts + +` + + vi.mocked(execSync).mockReturnValue(mockOutput) + + const files = getGitTrackedFilesSync(workspacePath) + + expect(files).toEqual(["src/app.ts", "src/utils/helper.ts"]) + }) + + it("should handle files with special characters", () => { + const mockOutput = `src/app/(app)/page.tsx +src/components/[id]/view.tsx +src/utils/file with spaces.ts` + + vi.mocked(execSync).mockReturnValue(mockOutput) + + const files = getGitTrackedFilesSync(workspacePath) + + expect(files).toEqual([ + "src/app/(app)/page.tsx", + "src/components/[id]/view.tsx", + "src/utils/file with spaces.ts", + ]) + }) + + it("should throw error if git command fails", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("Not a git repository") + }) + + expect(() => getGitTrackedFilesSync(workspacePath)).toThrow("Failed to get git tracked files") + }) + + it("should handle empty repository", () => { + vi.mocked(execSync).mockReturnValue("") + + const files = getGitTrackedFilesSync(workspacePath) + + expect(files).toEqual([]) + }) + + it("should handle large number of files", () => { + // Generate 10000 file paths + const mockFiles = Array.from({ length: 10000 }, (_, i) => `src/file${i}.ts`) + const mockOutput = mockFiles.join("\n") + + vi.mocked(execSync).mockReturnValue(mockOutput) + + const files = getGitTrackedFilesSync(workspacePath) + + expect(files).toHaveLength(10000) + expect(files[0]).toBe("src/file0.ts") + expect(files[9999]).toBe("src/file9999.ts") + }) + }) +}) diff --git a/src/services/code-index/managed/__tests__/is-base-branch.spec.ts b/src/services/code-index/managed/__tests__/is-base-branch.spec.ts new file mode 100644 index 00000000000..9b18a33d141 --- /dev/null +++ b/src/services/code-index/managed/__tests__/is-base-branch.spec.ts @@ -0,0 +1,133 @@ +/** + * Tests for isBaseBranch functionality + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { execSync } from "child_process" +import { isBaseBranch } from "../git-utils" + +// Mock child_process +vi.mock("child_process", () => ({ + execSync: vi.fn(), +})) + +describe("isBaseBranch", () => { + const workspacePath = "/Users/test/project" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("without workspace path", () => { + it("should return true for main", () => { + expect(isBaseBranch("main")).toBe(true) + }) + + it("should return true for master", () => { + expect(isBaseBranch("master")).toBe(true) + }) + + it("should return true for develop", () => { + expect(isBaseBranch("develop")).toBe(true) + }) + + it("should return true for development", () => { + expect(isBaseBranch("development")).toBe(true) + }) + + it("should be case insensitive for common branches", () => { + expect(isBaseBranch("MAIN")).toBe(true) + expect(isBaseBranch("Master")).toBe(true) + expect(isBaseBranch("DEVELOP")).toBe(true) + }) + + it("should return false for feature branches", () => { + expect(isBaseBranch("feature/new-api")).toBe(false) + expect(isBaseBranch("bugfix/issue-123")).toBe(false) + expect(isBaseBranch("canary")).toBe(false) + }) + }) + + describe("with workspace path", () => { + it("should return true for common base branches even without checking remote", () => { + expect(isBaseBranch("main", workspacePath)).toBe(true) + expect(isBaseBranch("master", workspacePath)).toBe(true) + expect(isBaseBranch("develop", workspacePath)).toBe(true) + }) + + it("should return true when branch matches remote default", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/canary\n") + + const result = isBaseBranch("canary", workspacePath) + + expect(result).toBe(true) + expect(execSync).toHaveBeenCalledWith("git symbolic-ref refs/remotes/origin/HEAD", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + }) + + it("should be case insensitive when comparing with remote default", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/Canary\n") + + expect(isBaseBranch("canary", workspacePath)).toBe(true) + expect(isBaseBranch("CANARY", workspacePath)).toBe(true) + expect(isBaseBranch("Canary", workspacePath)).toBe(true) + }) + + it("should return true for production when it's the remote default", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/production\n") + + expect(isBaseBranch("production", workspacePath)).toBe(true) + }) + + it("should return false when branch doesn't match remote default", () => { + vi.mocked(execSync).mockReturnValue("refs/remotes/origin/main\n") + + expect(isBaseBranch("feature/test", workspacePath)).toBe(false) + }) + + it("should return false when remote default cannot be determined", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("No remote") + }) + + expect(isBaseBranch("canary", workspacePath)).toBe(false) + }) + + it("should handle remote default check failure gracefully", () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error("Git error") + }) + + // Should still work for common branches + expect(isBaseBranch("main", workspacePath)).toBe(true) + // But return false for non-common branches + expect(isBaseBranch("canary", workspacePath)).toBe(false) + }) + + it("should try to set remote HEAD if symbolic-ref fails initially", () => { + let callCount = 0 + vi.mocked(execSync).mockImplementation((cmd: string) => { + callCount++ + if (callCount === 1) { + // First call to symbolic-ref fails + throw new Error("No symbolic ref") + } else if (callCount === 2) { + // Second call to set-head succeeds + return "" + } else if (callCount === 3) { + // Third call to symbolic-ref succeeds + return "refs/remotes/origin/canary\n" + } + return "" + }) + + const result = isBaseBranch("canary", workspacePath) + + expect(result).toBe(true) + expect(execSync).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/src/services/code-index/managed/__tests__/scanner-git-paths.spec.ts b/src/services/code-index/managed/__tests__/scanner-git-paths.spec.ts new file mode 100644 index 00000000000..fd9cacf3a9b --- /dev/null +++ b/src/services/code-index/managed/__tests__/scanner-git-paths.spec.ts @@ -0,0 +1,90 @@ +/** + * Tests for scanner git path handling + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as path from "path" +import * as scanner from "../scanner" +import * as gitUtils from "../git-utils" + +// Mock dependencies +vi.mock("../git-utils") +vi.mock("../../glob/list-files") +vi.mock("../../../core/ignore/RooIgnoreController") +vi.mock("vscode", () => ({ + workspace: { + fs: { + readFile: vi.fn(), + }, + }, + Uri: { + file: vi.fn((p) => ({ fsPath: p })), + }, +})) + +describe("Scanner Git Path Handling", () => { + const workspacePath = "/Users/test/project" + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should convert relative git paths to absolute paths for feature branches", async () => { + // Mock git utilities + vi.mocked(gitUtils.isGitRepository).mockReturnValue(true) + vi.mocked(gitUtils.getCurrentBranch).mockReturnValue("feature/test") + vi.mocked(gitUtils.getGitDiff).mockReturnValue({ + added: ["src/app.ts", "src/utils/helper.ts"], + modified: ["src/index.ts"], + deleted: ["src/old.ts"], + }) + + // The getFilesToScan function is not exported, but we can test it indirectly + // by checking that scanDirectory doesn't throw ENOENT errors + + // For this test, we'll verify the git diff returns relative paths + const diff = gitUtils.getGitDiff("feature/test", "main", workspacePath) + + // Verify git returns relative paths + expect(diff.added).toEqual(["src/app.ts", "src/utils/helper.ts"]) + expect(diff.modified).toEqual(["src/index.ts"]) + + // The scanner should convert these to absolute paths internally + // Expected absolute paths would be: + const expectedPaths = [ + path.join(workspacePath, "src/app.ts"), + path.join(workspacePath, "src/utils/helper.ts"), + path.join(workspacePath, "src/index.ts"), + ] + + // Verify the paths are absolute + expectedPaths.forEach((p) => { + expect(path.isAbsolute(p)).toBe(true) + }) + }) + + it("should handle git paths with special characters", () => { + const relativePaths = [ + "src/app/(app)/page.tsx", + "src/components/[id]/view.tsx", + "src/utils/file with spaces.ts", + ] + + // Convert to absolute paths + const absolutePaths = relativePaths.map((p) => path.join(workspacePath, p)) + + // Verify all are absolute + absolutePaths.forEach((p) => { + expect(path.isAbsolute(p)).toBe(true) + expect(p.startsWith(workspacePath)).toBe(true) + }) + }) + + it("should handle nested directory paths correctly", () => { + const relativePath = "src/deeply/nested/directory/file.ts" + const absolutePath = path.join(workspacePath, relativePath) + + expect(absolutePath).toBe(path.join(workspacePath, "src/deeply/nested/directory/file.ts")) + expect(path.isAbsolute(absolutePath)).toBe(true) + }) +}) diff --git a/src/services/code-index/managed/api-client.ts b/src/services/code-index/managed/api-client.ts new file mode 100644 index 00000000000..5c1848f2300 --- /dev/null +++ b/src/services/code-index/managed/api-client.ts @@ -0,0 +1,321 @@ +/** + * API client for managed codebase indexing + * + * This module provides pure functions for communicating with the Kilo Code + * backend API for managed indexing operations (upsert, search, delete, manifest). + */ + +import axios from "axios" +import { ManagedCodeChunk, SearchRequest, SearchResult, ServerManifest } from "./types" +import { logger } from "../../../utils/logging" +import { getKiloBaseUriFromToken } from "../../../../packages/types/src/kilocode/kilocode" + +/** + * Upserts code chunks to the server using the new envelope format + * + * @param chunks Array of chunks to upsert (must all be from same org/project/branch) + * @param kilocodeToken Authentication token + * @throws Error if the request fails or chunks are from different contexts + */ +export async function upsertChunks(chunks: ManagedCodeChunk[], kilocodeToken: string): Promise { + if (chunks.length === 0) { + return + } + + // Validate all chunks are from same context + const firstChunk = chunks[0] + const allSameContext = chunks.every( + (c) => + c.organizationId === firstChunk.organizationId && + c.projectId === firstChunk.projectId && + c.gitBranch === firstChunk.gitBranch && + c.isBaseBranch === firstChunk.isBaseBranch, + ) + + if (!allSameContext) { + throw new Error("All chunks must be from the same organization, project, and branch") + } + + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + // Transform to new envelope format + const requestBody = { + organizationId: firstChunk.organizationId, + projectId: firstChunk.projectId, + gitBranch: firstChunk.gitBranch, + isBaseBranch: firstChunk.isBaseBranch, + chunks: chunks.map((chunk) => ({ + id: chunk.id, + codeChunk: chunk.codeChunk, + filePath: chunk.filePath, + startLine: chunk.startLine, + endLine: chunk.endLine, + chunkHash: chunk.chunkHash, + })), + } + + try { + const response = await axios({ + method: "PUT", + url: `${baseUrl}/api/codebase-indexing/upsert`, + data: requestBody, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Failed to upsert chunks: ${response.statusText}`) + } + + logger.info(`Successfully upserted ${chunks.length} chunks`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Failed to upsert chunks: ${errorMessage}`) + throw error + } +} + +/** + * Searches code in the managed index with branch preferences + * + * @param request Search request with preferences + * @param kilocodeToken Authentication token + * @returns Array of search results sorted by relevance + * @throws Error if the request fails + */ +export async function searchCode(request: SearchRequest, kilocodeToken: string): Promise { + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + try { + const response = await axios({ + method: "POST", + url: `${baseUrl}/api/codebase-indexing/search`, + data: request, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Search failed: ${response.statusText}`) + } + + const results: SearchResult[] = response.data || [] + logger.info(`Search returned ${results.length} results`) + return results + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Search failed: ${errorMessage}`) + throw error + } +} + +/** + * Deletes chunks for specific files on a specific branch + * + * @param filePaths Array of file paths to delete + * @param gitBranch Git branch to delete from + * @param organizationId Organization ID + * @param projectId Project ID + * @param kilocodeToken Authentication token + * @throws Error if the request fails + */ +export async function deleteFiles( + filePaths: string[], + gitBranch: string, + organizationId: string, + projectId: string, + kilocodeToken: string, +): Promise { + if (filePaths.length === 0) { + return + } + + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + try { + const response = await axios({ + method: "PUT", + url: `${baseUrl}/api/codebase-indexing/delete`, + data: { + organizationId, + projectId, + gitBranch, + filePaths, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Failed to delete files: ${response.statusText}`) + } + + logger.info(`Successfully deleted ${filePaths.length} files from branch ${gitBranch}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Failed to delete files: ${errorMessage}`) + throw error + } +} + +/** + * Gets the server manifest for a specific branch + * + * The manifest contains metadata about all indexed files on the branch, + * allowing clients to determine what needs to be indexed. + * + * @param organizationId Organization ID + * @param projectId Project ID + * @param gitBranch Git branch name + * @param kilocodeToken Authentication token + * @returns Server manifest with file metadata + * @throws Error if the request fails + */ +export async function getServerManifest( + organizationId: string, + projectId: string, + gitBranch: string, + kilocodeToken: string, +): Promise { + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + try { + const response = await axios({ + method: "GET", + url: `${baseUrl}/api/codebase-indexing/manifest`, + params: { + organizationId, + projectId, + gitBranch, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Failed to get manifest: ${response.statusText}`) + } + + const manifest: ServerManifest = response.data + logger.info(`Retrieved manifest for ${gitBranch}: ${manifest.totalFiles} files, ${manifest.totalChunks} chunks`) + return manifest + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Failed to get manifest: ${errorMessage}`) + throw error + } +} + +/** + * Deletes all chunks for a specific file on a specific branch + * + * This is a convenience wrapper around deleteFiles for single file operations. + * + * @param filePath File path to delete + * @param gitBranch Git branch to delete from + * @param organizationId Organization ID + * @param projectId Project ID + * @param kilocodeToken Authentication token + */ +export async function deleteFile( + filePath: string, + gitBranch: string, + organizationId: string, + projectId: string, + kilocodeToken: string, +): Promise { + return deleteFiles([filePath], gitBranch, organizationId, projectId, kilocodeToken) +} + +/** + * Deletes all chunks for a specific branch + * + * @param organizationId Organization ID + * @param projectId Project ID + * @param gitBranch Git branch to delete + * @param kilocodeToken Authentication token + * @throws Error if the request fails + */ +export async function deleteBranchIndex( + organizationId: string, + projectId: string, + gitBranch: string, + kilocodeToken: string, +): Promise { + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + try { + const response = await axios({ + method: "DELETE", + url: `${baseUrl}/api/codebase-indexing/branch`, + data: { + organizationId, + projectId, + gitBranch, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Failed to delete branch index: ${response.statusText}`) + } + + logger.info(`Successfully deleted branch index for ${gitBranch}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Failed to delete branch index: ${errorMessage}`) + throw error + } +} + +/** + * Deletes all chunks for a project (all branches) + * + * @param organizationId Organization ID + * @param projectId Project ID + * @param kilocodeToken Authentication token + * @throws Error if the request fails + */ +export async function deleteProjectIndex( + organizationId: string, + projectId: string, + kilocodeToken: string, +): Promise { + const baseUrl = getKiloBaseUriFromToken(kilocodeToken) + + try { + const response = await axios({ + method: "DELETE", + url: `${baseUrl}/api/codebase-indexing/project`, + data: { + organizationId, + projectId, + }, + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.status !== 200) { + throw new Error(`Failed to delete project index: ${response.statusText}`) + } + + logger.info(`Successfully deleted project index`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Failed to delete project index: ${errorMessage}`) + throw error + } +} diff --git a/src/services/code-index/managed/chunker.ts b/src/services/code-index/managed/chunker.ts new file mode 100644 index 00000000000..485566d0018 --- /dev/null +++ b/src/services/code-index/managed/chunker.ts @@ -0,0 +1,215 @@ +/** + * Line-based file chunking for managed codebase indexing + * + * This module provides a simple, fast alternative to tree-sitter parsing. + * It chunks files based on line boundaries with configurable overlap, + * making it language-agnostic and 3-5x faster than AST-based approaches. + */ + +import { createHash } from "crypto" +import { v5 as uuidv5 } from "uuid" +import { ManagedCodeChunk, ChunkerConfig } from "./types" +import { MANAGED_MAX_CHUNK_CHARS, MANAGED_MIN_CHUNK_CHARS, MANAGED_OVERLAP_LINES } from "../constants" + +/** + * Chunks a file's content into overlapping segments based on line boundaries + * + * Algorithm: + * 1. Split content into lines + * 2. Accumulate lines until maxChunkChars is reached + * 3. Create chunk (always includes complete lines, never splits mid-line) + * 4. Start next chunk with overlapLines from previous chunk + * 5. Continue until all lines are processed + * + * @param filePath Relative file path from workspace root + * @param content File content to chunk + * @param fileHash SHA-256 hash of the file content + * @param organizationId Organization ID + * @param projectId Project ID + * @param gitBranch Git branch name + * @param isBaseBranch Whether this is a base branch (main/develop) + * @param config Chunker configuration (optional, uses defaults if not provided) + * @returns Array of code chunks with metadata + */ +export function chunkFile( + filePath: string, + content: string, + fileHash: string, + organizationId: string, + projectId: string, + gitBranch: string, + isBaseBranch: boolean, + config?: Partial, +): ManagedCodeChunk[] { + const chunkerConfig: ChunkerConfig = { + maxChunkChars: config?.maxChunkChars ?? MANAGED_MAX_CHUNK_CHARS, + minChunkChars: config?.minChunkChars ?? MANAGED_MIN_CHUNK_CHARS, + overlapLines: config?.overlapLines ?? MANAGED_OVERLAP_LINES, + } + + const lines = content.split("\n") + const chunks: ManagedCodeChunk[] = [] + + let currentChunk: string[] = [] + let currentChunkChars = 0 + let startLine = 1 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineLength = line.length + 1 // +1 for newline character + + // Check if adding this line would exceed max chunk size + if (currentChunkChars + lineLength > chunkerConfig.maxChunkChars && currentChunk.length > 0) { + // Finalize current chunk if it meets minimum size + if (currentChunkChars >= chunkerConfig.minChunkChars) { + chunks.push( + createChunk( + currentChunk, + startLine, + i, // endLine is current index (0-based) + 1 = i + 1, but we want i (last line of chunk) + filePath, + fileHash, + organizationId, + projectId, + gitBranch, + isBaseBranch, + ), + ) + + // Start next chunk with overlap + const overlapStart = Math.max(0, currentChunk.length - chunkerConfig.overlapLines) + currentChunk = currentChunk.slice(overlapStart) + currentChunkChars = currentChunk.reduce((sum, l) => sum + l.length + 1, 0) + startLine = i - (currentChunk.length - 1) + } + } + + currentChunk.push(line) + currentChunkChars += lineLength + } + + // Finalize last chunk if it meets minimum size + if (currentChunk.length > 0 && currentChunkChars >= chunkerConfig.minChunkChars) { + chunks.push( + createChunk( + currentChunk, + startLine, + lines.length, + filePath, + fileHash, + organizationId, + projectId, + gitBranch, + isBaseBranch, + ), + ) + } + + return chunks +} + +/** + * Creates a single chunk with all required metadata + * + * @param lines Array of lines that make up this chunk + * @param startLine Starting line number (1-based) + * @param endLine Ending line number (1-based, inclusive) + * @param filePath Relative file path + * @param fileHash SHA-256 hash of the file + * @param organizationId Organization ID + * @param projectId Project ID + * @param gitBranch Git branch name + * @param isBaseBranch Whether this is a base branch + * @returns ManagedCodeChunk with all metadata + */ +function createChunk( + lines: string[], + startLine: number, + endLine: number, + filePath: string, + fileHash: string, + organizationId: string, + projectId: string, + gitBranch: string, + isBaseBranch: boolean, +): ManagedCodeChunk { + const content = lines.join("\n") + const chunkHash = generateChunkHash(filePath, startLine, endLine) + const id = generateChunkId(chunkHash, organizationId, gitBranch) + + return { + id, + organizationId, + projectId, + filePath, + codeChunk: content, + startLine, + endLine, + chunkHash, + gitBranch, + isBaseBranch, + } +} + +/** + * Generates a unique hash for a chunk based on its content and location + * + * The hash includes: + * - File path (to distinguish same content in different files) + * - Line range (to distinguish same content at different locations) + * - Content length (quick differentiator) + * - Content preview (first 100 chars for uniqueness) + * + * @param filePath Relative file path + * @param startLine Starting line number + * @param endLine Ending line number + * @param content Chunk content + * @returns SHA-256 hash string + */ +function generateChunkHash(filePath: string, startLine: number, endLine: number): string { + return createHash("sha256").update(`${filePath}-${startLine}-${endLine}`).digest("hex") +} + +/** + * Generates a unique ID for a chunk + * + * The ID is a UUIDv5 based on the chunk hash and organization ID. + * This ensures: + * - Same content in same location = same ID (idempotent upserts) + * - Different organizations = different IDs (isolation) + * - Different branches = different IDs (branch isolation via chunk hash) + * + * @param chunkHash Hash of the chunk content and location + * @param organizationId Organization ID (used as UUID namespace) + * @param gitBranch Git branch name (included in hash for branch isolation) + * @returns UUID string + */ +function generateChunkId(chunkHash: string, organizationId: string, gitBranch: string): string { + // Include branch in the hash to ensure different IDs across branches + const branchAwareHash = createHash("sha256").update(`${chunkHash}-${gitBranch}`).digest("hex") + + return uuidv5(branchAwareHash, organizationId) +} + +/** + * Calculates the SHA-256 hash of file content + * + * @param content File content + * @returns SHA-256 hash string + */ +export function calculateFileHash(content: string): string { + return createHash("sha256").update(content).digest("hex") +} + +/** + * Gets the default chunker configuration + * + * @returns Default ChunkerConfig + */ +export function getDefaultChunkerConfig(): ChunkerConfig { + return { + maxChunkChars: MANAGED_MAX_CHUNK_CHARS, + minChunkChars: MANAGED_MIN_CHUNK_CHARS, + overlapLines: MANAGED_OVERLAP_LINES, + } +} diff --git a/src/services/code-index/managed/git-utils.ts b/src/services/code-index/managed/git-utils.ts new file mode 100644 index 00000000000..df8178154eb --- /dev/null +++ b/src/services/code-index/managed/git-utils.ts @@ -0,0 +1,369 @@ +/** + * Git utility functions for managed codebase indexing + * + * This module provides pure functions for interacting with git to determine + * branch state and file changes. Used to implement delta-based indexing. + */ + +import { execSync } from "child_process" +import { GitDiff } from "./types" + +/** + * Gets the current git branch name + * @param workspacePath Path to the workspace + * @returns Current branch name (e.g., "main", "feature/new-api") + * @throws Error if not in a git repository + */ +export function getCurrentBranch(workspacePath: string): string { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { + cwd: workspacePath, + encoding: "utf8", + }).trim() + } catch (error) { + throw new Error(`Failed to get current git branch: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Gets the current git commit SHA + * @param workspacePath Path to the workspace + * @returns Current commit SHA (full 40-character hash) + * @throws Error if not in a git repository + */ +export function getCurrentCommitSha(workspacePath: string): string { + try { + return execSync("git rev-parse HEAD", { + cwd: workspacePath, + encoding: "utf8", + }).trim() + } catch (error) { + throw new Error(`Failed to get current commit SHA: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Gets the remote URL for the repository + * @param workspacePath Path to the workspace + * @returns Remote URL (e.g., "https://github.com/org/repo.git") + * @throws Error if no remote is configured + */ +export function getRemoteUrl(workspacePath: string): string { + try { + return execSync("git config --get remote.origin.url", { + cwd: workspacePath, + encoding: "utf8", + }).trim() + } catch (error) { + throw new Error(`Failed to get remote URL: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Checks if the workspace is a git repository + * @param workspacePath Path to the workspace + * @returns true if workspace is a git repository + */ +export function isGitRepository(workspacePath: string): boolean { + try { + execSync("git rev-parse --git-dir", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + return true + } catch { + return false + } +} + +/** + * Gets the diff between a feature branch and base branch + * @param featureBranch The feature branch name + * @param baseBranch The base branch name (usually 'main' or 'develop') + * @param workspacePath Path to the workspace + * @returns GitDiff object with added, modified, and deleted files + * @throws Error if git command fails + */ +export function getGitDiff(featureBranch: string, baseBranch: string, workspacePath: string): GitDiff { + try { + // Get the merge base (commit where branches diverged) + const mergeBase = execSync(`git merge-base ${baseBranch} ${featureBranch}`, { + cwd: workspacePath, + encoding: "utf8", + }).trim() + + // Get diff between merge base and feature branch + const diffOutput = execSync(`git diff --name-status ${mergeBase}..${featureBranch}`, { + cwd: workspacePath, + encoding: "utf8", + }) + + return parseDiffOutput(diffOutput) + } catch (error) { + throw new Error( + `Failed to get git diff between ${featureBranch} and ${baseBranch}: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +/** + * Parses git diff --name-status output into structured format + * @param diffOutput Raw output from git diff --name-status + * @returns GitDiff object with categorized file changes + */ +function parseDiffOutput(diffOutput: string): GitDiff { + const added: string[] = [] + const modified: string[] = [] + const deleted: string[] = [] + + const lines = diffOutput.split("\n").filter((line) => line.trim()) + + for (const line of lines) { + const parts = line.split("\t") + if (parts.length < 2) continue + + const status = parts[0] + const filePath = parts.slice(1).join("\t") // Handle file paths with tabs + + switch (status[0]) { + case "A": + added.push(filePath) + break + case "M": + modified.push(filePath) + break + case "D": + deleted.push(filePath) + break + case "R": // Renamed - treat as delete + add + if (parts.length >= 3) { + deleted.push(parts[1]) + added.push(parts[2]) + } + break + case "C": // Copied - treat as add + if (parts.length >= 3) { + added.push(parts[2]) + } + break + // Ignore other statuses (T=type change, U=unmerged, X=unknown) + } + } + + return { added, modified, deleted } +} + +/** + * Determines if a branch is a base branch (main or develop) + * @param branchName The branch name to check + * @param workspacePath Optional workspace path to check against remote default branch + * @returns true if this is a base branch + */ +export function isBaseBranch(branchName: string, workspacePath?: string): boolean { + const baseBranches = ["main", "master", "develop", "development"] + const isCommonBaseBranch = baseBranches.includes(branchName.toLowerCase()) + + // If it's a common base branch, return true + if (isCommonBaseBranch) { + return true + } + + // If workspace path is provided, check if this branch is the remote's default branch + if (workspacePath) { + const defaultBranch = getDefaultBranchFromRemote(workspacePath) + if (defaultBranch && defaultBranch.toLowerCase() === branchName.toLowerCase()) { + return true + } + } + + return false +} + +/** + * Gets the default branch name from the remote repository + * @param workspacePath Path to the workspace + * @returns The default branch name or null if it cannot be determined + */ +export function getDefaultBranchFromRemote(workspacePath: string): string | null { + try { + // Try to get the default branch from the remote's symbolic ref + const output = execSync("git symbolic-ref refs/remotes/origin/HEAD", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }).trim() + + // Output format: refs/remotes/origin/main + // Extract the branch name after the last / + const match = output.match(/refs\/remotes\/origin\/(.+)$/) + if (match && match[1]) { + return match[1] + } + } catch { + // If symbolic-ref fails, try to set it first + try { + execSync("git remote set-head origin --auto", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + + // Try again after setting + const output = execSync("git symbolic-ref refs/remotes/origin/HEAD", { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }).trim() + + const match = output.match(/refs\/remotes\/origin\/(.+)$/) + if (match && match[1]) { + return match[1] + } + } catch { + // Failed to determine from remote + } + } + + return null +} + +/** + * Gets the base branch for a given feature branch + * First tries to get the default branch from the remote repository, + * then checks if common base branches exist, defaults to 'main' + * @param workspacePath Path to the workspace + * @returns The base branch name (e.g., 'main', 'canary', 'develop') + */ +export function getBaseBranch(workspacePath: string): string { + // First, try to get the default branch from the remote + const defaultBranch = getDefaultBranchFromRemote(workspacePath) + if (defaultBranch) { + // Verify the branch exists locally + try { + execSync(`git rev-parse --verify ${defaultBranch}`, { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + return defaultBranch + } catch { + // Default branch from remote doesn't exist locally, continue to fallback + } + } + + // Fallback: Check common base branch names + const commonBranches = ["main", "develop", "master"] + for (const branch of commonBranches) { + try { + execSync(`git rev-parse --verify ${branch}`, { + cwd: workspacePath, + encoding: "utf8", + stdio: "pipe", + }) + return branch + } catch { + // Branch doesn't exist, try next + } + } + + // Ultimate fallback + return "main" +} + +/** + * Checks if there are uncommitted changes in the workspace + * @param workspacePath Path to the workspace + * @returns true if there are uncommitted changes + */ +export function hasUncommittedChanges(workspacePath: string): boolean { + try { + const status = execSync("git status --porcelain", { + cwd: workspacePath, + encoding: "utf8", + }) + return status.trim().length > 0 + } catch { + return false + } +} + +/** + * Gets all files tracked by git using async generator for memory efficiency + * @param workspacePath Path to the workspace + * @yields File paths relative to workspace root + */ +// export async function* getGitTrackedFiles(workspacePath: string): AsyncGenerator { +// const { spawn } = await import("child_process") + +// return new Promise((resolve, reject) => { +// const gitProcess = spawn("git", ["ls-files"], { +// cwd: workspacePath, +// stdio: ["ignore", "pipe", "pipe"], +// }) + +// let buffer = "" + +// gitProcess.stdout.on("data", (chunk: Buffer) => { +// buffer += chunk.toString() +// const lines = buffer.split("\n") +// // Keep the last incomplete line in the buffer +// buffer = lines.pop() || "" + +// // Yield complete lines +// for (const line of lines) { +// const trimmed = line.trim() +// if (trimmed) { +// // This is a hack to make the generator work synchronously +// // We'll refactor this to use a proper async generator pattern +// ;(async () => { +// // Yield the file path +// })() +// } +// } +// }) + +// gitProcess.stderr.on("data", (chunk: Buffer) => { +// console.error(`git ls-files error: ${chunk.toString()}`) +// }) + +// gitProcess.on("close", (code) => { +// if (code !== 0) { +// reject(new Error(`git ls-files exited with code ${code}`)) +// } else { +// // Process any remaining buffer +// if (buffer.trim()) { +// // Yield final line +// } +// resolve() +// } +// }) + +// gitProcess.on("error", (error) => { +// reject(new Error(`Failed to execute git ls-files: ${error.message}`)) +// }) +// }) +// } + +/** + * Gets all files tracked by git (synchronous version) + * @param workspacePath Path to the workspace + * @returns Array of file paths relative to workspace root + * @throws Error if git command fails + */ +export function getGitTrackedFilesSync(workspacePath: string): string[] { + try { + const output = execSync("git ls-files", { + cwd: workspacePath, + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large repos + }) + + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + } catch (error) { + throw new Error(`Failed to get git tracked files: ${error instanceof Error ? error.message : String(error)}`) + } +} diff --git a/src/services/code-index/managed/index.ts b/src/services/code-index/managed/index.ts new file mode 100644 index 00000000000..c8fa165d7b1 --- /dev/null +++ b/src/services/code-index/managed/index.ts @@ -0,0 +1,90 @@ +/** + * Managed Codebase Indexing + * + * This module provides a complete, standalone indexing system for Kilo Code + * organization users. It is completely separate from the local indexing system + * and uses a simpler, more efficient approach: + * + * - Line-based chunking (no tree-sitter) + * - Delta indexing (only changed files on feature branches) + * - Server-side embeddings (no client computation) + * - Client-driven search (client sends deleted files) + * - Functional architecture (stateless, composable functions) + * + * @example + * ```typescript + * import { startIndexing, search, createManagedIndexingConfig } from './managed' + * + * // Create configuration + * const config = createManagedIndexingConfig( + * organizationId, + * projectId, + * kilocodeToken, + * workspacePath + * ) + * + * // Start indexing + * const disposable = await startIndexing(config, context, (state) => { + * console.log('State:', state) + * }) + * + * // Search + * const results = await search('my query', config) + * + * // Stop indexing + * disposable.dispose() + * ``` + */ + +// Main API +export { startIndexing, search, getIndexerState, createManagedIndexingConfig } from "./indexer" + +// Scanner functions (for advanced usage) +export { scanDirectory, indexFile, handleFileDeleted } from "./scanner" + +// Watcher functions +export { createFileWatcher } from "./watcher" + +// Chunker functions +export { chunkFile, calculateFileHash, getDefaultChunkerConfig } from "./chunker" + +// API client functions +export { + upsertChunks, + searchCode, + deleteFiles, + deleteFile, + getServerManifest, + deleteBranchIndex, + deleteProjectIndex, +} from "./api-client" + +// Git utilities +export { + getCurrentBranch, + getCurrentCommitSha, + getRemoteUrl, + getGitDiff, + isBaseBranch, + getBaseBranch, + isGitRepository, + hasUncommittedChanges, + // getGitTrackedFiles, + getGitTrackedFilesSync, +} from "./git-utils" + +// Types +export type { + ManagedCodeChunk, + ChunkerConfig, + GitDiff, + ManagedIndexingConfig, + ScanProgress, + ScanResult, + ManifestFileEntry, + ServerManifest, + SearchRequest, + SearchResult, + FileChangeEvent, + IndexerState, +} from "./types" diff --git a/src/services/code-index/managed/indexer.ts b/src/services/code-index/managed/indexer.ts new file mode 100644 index 00000000000..daa8fa7fe6a --- /dev/null +++ b/src/services/code-index/managed/indexer.ts @@ -0,0 +1,318 @@ +/** + * Main orchestration module for managed codebase indexing + * + * This module provides the high-level API for managed indexing operations: + * - Starting/stopping indexing + * - Searching the index + * - Managing state + */ + +import * as vscode from "vscode" +import { scanDirectory } from "./scanner" +import { createFileWatcher } from "./watcher" +import { searchCode as apiSearchCode, getServerManifest } from "./api-client" +import { getCurrentBranch, getGitDiff, isGitRepository } from "./git-utils" +import { ManagedIndexingConfig, IndexerState, SearchResult, ServerManifest } from "./types" +import { getDefaultChunkerConfig } from "./chunker" +import { logger } from "../../../utils/logging" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" + +/** + * Starts the managed indexing process + * + * This function: + * 1. Validates the workspace is a git repository + * 2. Performs initial scan (full for main, delta for feature branches) + * 3. Starts file watcher for incremental updates + * 4. Reports progress via state callback + * + * @param config Managed indexing configuration + * @param context VSCode extension context + * @param onStateChange Optional state change callback + * @returns Disposable that stops the indexer when disposed + */ +export async function startIndexing( + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, + onStateChange?: (state: IndexerState) => void, +): Promise { + try { + // Validate git repository + if (!isGitRepository(config.workspacePath)) { + const error = new Error("Workspace is not a git repository") + onStateChange?.({ + status: "error", + message: "Not a git repository", + error: error.message, + }) + throw error + } + + // Get current branch + const gitBranch = getCurrentBranch(config.workspacePath) + + // Fetch server manifest to determine what's already indexed + let manifest: ServerManifest | undefined + let serverHasNoData = false + try { + manifest = await getServerManifest(config.organizationId, config.projectId, gitBranch, config.kilocodeToken) + logger.info( + `[Managed Indexing] Server manifest: ${manifest.totalFiles} files, ${manifest.totalChunks} chunks`, + ) + } catch (error) { + // Check if this is a 404 (no data on server) + const is404 = + error && typeof error === "object" && "response" in error && (error as any).response?.status === 404 + + if (is404) { + logger.info("[Managed Indexing] No data on server (404), will perform full scan") + serverHasNoData = true + } else { + logger.warn("[Managed Indexing] Failed to fetch manifest, will perform full scan:", error) + } + // Continue without manifest - scanner will index everything + } + + // Update state: scanning + onStateChange?.({ + status: "scanning", + message: `Starting scan on branch ${gitBranch}...`, + gitBranch, + }) + + // Perform initial scan with manifest for intelligent delta indexing + const result = await scanDirectory(config, context, manifest, (progress) => { + onStateChange?.({ + status: "scanning", + message: `Scanning: ${progress.filesProcessed}/${progress.filesTotal} files (${progress.chunksIndexed} chunks)`, + gitBranch, + }) + }) + + if (!result.success) { + // Log all errors for debugging + logger.error(`Scan failed with ${result.errors.length} errors:`) + result.errors.forEach((err, index) => { + logger.error(` Error ${index + 1}: ${err.message}`) + if (err.stack) { + logger.error(` Stack: ${err.stack}`) + } + }) + + // Create a detailed error message + const errorSummary = result.errors + .slice(0, 5) + .map((e) => e.message) + .join("; ") + const remainingCount = result.errors.length > 5 ? ` (and ${result.errors.length - 5} more)` : "" + throw new Error(`Scan failed with ${result.errors.length} errors: ${errorSummary}${remainingCount}`) + } + + logger.info( + `Initial scan complete: ${result.filesProcessed} files processed, ${result.chunksIndexed} chunks indexed`, + ) + + // TODO: Re-enable file watcher once git-tracking issues are resolved + // File watcher is temporarily disabled to prevent endless loops with .gitignored files + // const watcher = createFileWatcher(config, context, (events) => { + // logger.info(`File watcher processed ${events.length} changes`) + // }) + + // Check if we actually have indexed data + // If no chunks were indexed and no files were processed, the index is empty + const hasIndexedData = result.chunksIndexed > 0 || result.filesProcessed > 0 + + // Fetch updated manifest after indexing to get accurate server state + let updatedManifest: ServerManifest | undefined + if (hasIndexedData) { + try { + updatedManifest = await getServerManifest( + config.organizationId, + config.projectId, + gitBranch, + config.kilocodeToken, + ) + } catch (error) { + logger.warn("[Managed Indexing] Failed to fetch updated manifest after indexing:", error) + } + } + + // Update state based on whether we have data + if (hasIndexedData) { + onStateChange?.({ + status: "watching", + message: "Index up-to-date. File watching temporarily disabled.", + gitBranch, + lastSyncTime: Date.now(), + totalFiles: result.filesProcessed, + totalChunks: result.chunksIndexed, + manifest: updatedManifest + ? { + totalFiles: updatedManifest.totalFiles, + totalChunks: updatedManifest.totalChunks, + lastUpdated: updatedManifest.lastUpdated, + } + : undefined, + }) + } else { + // No data indexed - set to idle state to indicate re-scan is needed + onStateChange?.({ + status: "idle", + message: "No files indexed. Click 'Start Indexing' to begin.", + gitBranch, + }) + } + + // Return disposable that cleans up state + return vscode.Disposable.from({ + dispose: () => { + onStateChange?.({ + status: "idle", + message: "Indexing stopped", + gitBranch, + }) + }, + }) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + logger.error(`Failed to start indexing: ${err.message}`) + + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "startIndexing", + }) + + onStateChange?.({ + status: "error", + message: `Failed to start indexing: ${err.message}`, + error: err.message, + }) + + throw err + } +} + +/** + * Searches the managed index with branch-aware preferences + * + * This function: + * 1. Gets deleted files from git diff (for feature branches) + * 2. Sends search request with branch preferences + * 3. Returns results with feature branch files preferred over main + * + * @param query Search query + * @param config Managed indexing configuration + * @param path Optional directory path filter + * @returns Array of search results sorted by relevance + */ +export async function search(query: string, config: ManagedIndexingConfig, path?: string): Promise { + try { + const gitBranch = getCurrentBranch(config.workspacePath) + + // Get deleted files for feature branches + let excludeFiles: string[] = [] + if (gitBranch !== "main" && gitBranch !== "master" && gitBranch !== "develop") { + try { + const diff = getGitDiff(gitBranch, "main", config.workspacePath) + excludeFiles = diff.deleted + } catch (error) { + // If git diff fails, continue without exclusions + logger.warn(`Failed to get git diff for search: ${error}`) + } + } + + // Perform search + const results = await apiSearchCode( + { + query, + organizationId: config.organizationId, + projectId: config.projectId, + preferBranch: gitBranch, + fallbackBranch: "main", + excludeFiles, + path, + }, + config.kilocodeToken, + ) + + logger.info(`Search for "${query}" returned ${results.length} results`) + + return results + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + logger.error(`Search failed: ${err.message}`) + + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "search", + query, + }) + + throw err + } +} + +/** + * Gets the current indexer state + * + * @param config Managed indexing configuration + * @param context VSCode extension context + * @returns Current indexer state + */ +export async function getIndexerState( + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, +): Promise { + try { + if (!isGitRepository(config.workspacePath)) { + return { + status: "error", + message: "Not a git repository", + error: "Workspace is not a git repository", + } + } + + const gitBranch = getCurrentBranch(config.workspacePath) + + return { + status: "idle", + message: "Ready", + gitBranch, + } + } catch (error) { + return { + status: "error", + message: "Failed to get state", + error: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Creates a managed indexing configuration from organization credentials + * + * @param organizationId Organization ID + * @param projectId Project ID + * @param kilocodeToken Authentication token + * @param workspacePath Workspace root path + * @returns Managed indexing configuration with defaults + */ +export function createManagedIndexingConfig( + organizationId: string, + projectId: string, + kilocodeToken: string, + workspacePath: string, +): ManagedIndexingConfig { + return { + organizationId, + projectId, + kilocodeToken, + workspacePath, + chunker: getDefaultChunkerConfig(), + batchSize: 60, + autoSync: true, + } +} diff --git a/src/services/code-index/managed/scanner.ts b/src/services/code-index/managed/scanner.ts new file mode 100644 index 00000000000..4a751b572d5 --- /dev/null +++ b/src/services/code-index/managed/scanner.ts @@ -0,0 +1,425 @@ +/** + * File scanner for managed codebase indexing + * + * This module provides functions for scanning directories and indexing files. + * It implements delta-based indexing where feature branches only index changed files. + */ + +import * as vscode from "vscode" +import * as path from "path" +import { stat } from "fs/promises" +import pLimit from "p-limit" +import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" +import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" +import { scannerExtensions } from "../shared/supported-extensions" +import { generateRelativeFilePath } from "../shared/get-relative-path" +import { chunkFile, calculateFileHash } from "./chunker" +import { upsertChunks, deleteFiles } from "./api-client" +import { + getCurrentBranch, + getGitDiff, + isBaseBranch as checkIsBaseBranch, + isGitRepository, + getGitTrackedFilesSync, + getBaseBranch, +} from "./git-utils" +import { ManagedIndexingConfig, ScanProgress, ScanResult, ServerManifest } from "./types" +import { MAX_FILE_SIZE_BYTES, MANAGED_MAX_CONCURRENT_FILES, MANAGED_BATCH_SIZE } from "../constants" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { logger } from "../../../utils/logging" + +/** + * Helper function to compare two arrays for equality + */ +function arraysEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +/** + * Scans a directory and indexes files based on branch strategy + * + * - Main branch: Scans all files + * - Feature branch: Only scans files changed from main (delta) + * + * @param config Managed indexing configuration + * @param context VSCode extension context + * @param manifest Optional server manifest for intelligent delta indexing + * @param onProgress Optional progress callback + * @param forceFullScan Force a full scan even on feature branches (used when server has no data) + * @returns Scan result with statistics + */ +export async function scanDirectory( + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, + manifest?: ServerManifest, + onProgress?: (progress: ScanProgress) => void, + forceFullScan: boolean = false, +): Promise { + const errors: Error[] = [] + + try { + // Check if workspace is a git repository + if (!isGitRepository(config.workspacePath)) { + throw new Error("Workspace is not a git repository") + } + + // Get current branch + const currentBranch = getCurrentBranch(config.workspacePath) + const isBase = checkIsBaseBranch(currentBranch, config.workspacePath) + + // Determine which files to scan + const filesToScan = await getFilesToScan(config.workspacePath, currentBranch, isBase) + + console.info(`Scanning ${filesToScan.length} files on branch ${currentBranch} (isBase: ${isBase})`) + + // Process files with manifest for intelligent skipping + const result = await processFiles(filesToScan, config, context, currentBranch, isBase, manifest, onProgress) + + return { + success: result.errors.length === 0, + filesProcessed: result.filesProcessed, + filesSkipped: result.filesSkipped, + chunksIndexed: result.chunksIndexed, + errors: result.errors, + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + errors.push(err) + console.error(`Scan directory failed: ${err.message}`) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "scanDirectory", + }) + + return { + success: false, + filesProcessed: 0, + filesSkipped: 0, + chunksIndexed: 0, + errors, + } + } +} + +/** + * Determines which files to scan based on branch strategy + * + * @param workspacePath Workspace root path + * @param currentBranch Current git branch + * @param isBase Whether current branch is a base branch + * @returns Array of file paths to scan + */ +async function getFilesToScan(workspacePath: string, currentBranch: string, isBase: boolean): Promise { + if (isBase) { + // Base branch: scan all files + return await getAllSupportedFiles(workspacePath) + } else { + // Feature branch: only scan changed files + const baseBranch = getBaseBranch(workspacePath) + const diff = getGitDiff(currentBranch, baseBranch, workspacePath) + const changedFiles = [...diff.added, ...diff.modified] + + // Convert relative paths from git to absolute paths and filter to only supported files + return changedFiles + .filter((file) => { + const ext = path.extname(file).toLowerCase() + return scannerExtensions.includes(ext) + }) + .map((file) => path.join(workspacePath, file)) + } +} + +/** + * Gets all supported files in the workspace that are tracked by git + * + * @param workspacePath Workspace root path + * @returns Array of supported file paths (absolute paths) + */ +async function getAllSupportedFiles(workspacePath: string): Promise { + // Get all git-tracked files (relative paths) + const gitTrackedFiles = getGitTrackedFilesSync(workspacePath) + + logger.info(`Found ${gitTrackedFiles.length} git-tracked files`) + + // Initialize ignore controller for .rooignore + const ignoreController = new RooIgnoreController(workspacePath) + await ignoreController.initialize() + + // Filter by .rooignore + const allowedPaths = ignoreController.filterPaths(gitTrackedFiles) + + logger.info(`After .rooignore filter: ${allowedPaths.length} files`) + + // Filter by supported extensions and convert to absolute paths + const supportedFiles = allowedPaths + .filter((filePath) => { + const ext = path.extname(filePath).toLowerCase() + + // Check if file is in an ignored directory + if (isPathInIgnoredDirectory(filePath)) { + return false + } + + return scannerExtensions.includes(ext) + }) + .map((filePath) => path.join(workspacePath, filePath)) + + logger.info(`After extension filter: ${supportedFiles.length} files`) + + return supportedFiles +} + +/** + * Processes files in parallel with batching + * + * @param filePaths Files to process + * @param config Indexing configuration + * @param context VSCode extension context + * @param gitBranch Current git branch + * @param isBase Whether this is a base branch + * @param manifest Optional server manifest for intelligent skipping + * @param onProgress Progress callback + * @returns Processing result + */ +async function processFiles( + filePaths: string[], + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, + gitBranch: string, + isBase: boolean, + manifest?: ServerManifest, + onProgress?: (progress: ScanProgress) => void, +): Promise<{ + filesProcessed: number + filesSkipped: number + chunksIndexed: number + errors: Error[] +}> { + const limit = pLimit(MANAGED_MAX_CONCURRENT_FILES) + const errors: Error[] = [] + let filesProcessed = 0 + let filesSkipped = 0 + let chunksIndexed = 0 + + // Batch accumulator + let currentBatch: any[] = [] + + const processBatch = async () => { + if (currentBatch.length === 0) return + + try { + await upsertChunks(currentBatch, config.kilocodeToken) + chunksIndexed += currentBatch.length + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))) + } + + currentBatch = [] + } + + const promises = filePaths.map((filePath) => + limit(async () => { + try { + // Check file size + const stats = await stat(filePath) + if (stats.size > MAX_FILE_SIZE_BYTES) { + filesSkipped++ + console.warn(`Skipping large file: ${filePath} (${stats.size} bytes)`) + return + } + + // Read file content + const content = await vscode.workspace.fs + .readFile(vscode.Uri.file(filePath)) + .then((buffer) => Buffer.from(buffer).toString("utf-8")) + + // Calculate file hash + const fileHash = calculateFileHash(content) + + // Get relative path for manifest comparison + const relativeFilePath = generateRelativeFilePath(filePath, config.workspacePath) + + // Chunk the file + const chunks = chunkFile( + relativeFilePath, + content, + fileHash, + config.organizationId, + config.projectId, + gitBranch, + isBase, + config.chunker, + ) + + // Extract chunk hashes for comparison + const currentChunkHashes = chunks.map((c) => c.chunkHash) + + // Check if file is already indexed on server with matching chunk hashes + if (manifest) { + const manifestEntry = manifest.files.find((f) => f.filePath === relativeFilePath) + if (manifestEntry && arraysEqual(currentChunkHashes, manifestEntry.chunkHashes)) { + // File already indexed on server with same chunks - skip + filesSkipped++ + logger.info(`[Scanner] Skipping ${relativeFilePath} - already indexed on server`) + return + } + } + + // Add to batch + currentBatch.push(...chunks) + + // Process batch if threshold reached + if (currentBatch.length >= MANAGED_BATCH_SIZE) { + await processBatch() + } + + filesProcessed++ + + // Report progress + onProgress?.({ + filesProcessed, + filesTotal: filePaths.length, + chunksIndexed, + currentFile: relativeFilePath, + }) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + // Create a more descriptive error with file context + const contextualError = new Error(`Failed to process ${filePath}: ${err.message}`) + contextualError.stack = err.stack + errors.push(contextualError) + console.error(`Error processing file ${filePath}: ${err.message}`) + if (err.stack) { + console.error(`Stack trace: ${err.stack}`) + } + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "processFiles", + filePath, + }) + } + }), + ) + + // Wait for all files to be processed + await Promise.all(promises) + + // Process remaining batch + await processBatch() + + return { + filesProcessed, + filesSkipped, + chunksIndexed, + errors, + } +} + +/** + * Indexes a single file + * + * This is used by the file watcher for incremental updates. + * + * @param filePath Absolute file path + * @param config Indexing configuration + * @param context VSCode extension context + */ +export async function indexFile( + filePath: string, + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, +): Promise { + try { + // Get current branch + const gitBranch = getCurrentBranch(config.workspacePath) + const isBase = checkIsBaseBranch(gitBranch, config.workspacePath) + + // Check file size + const stats = await stat(filePath) + if (stats.size > MAX_FILE_SIZE_BYTES) { + console.warn(`Skipping large file: ${filePath} (${stats.size} bytes)`) + return + } + + // Read file content + const content = await vscode.workspace.fs + .readFile(vscode.Uri.file(filePath)) + .then((buffer) => Buffer.from(buffer).toString("utf-8")) + + // Calculate file hash + const fileHash = calculateFileHash(content) + + // Get relative path + const relativeFilePath = generateRelativeFilePath(filePath, config.workspacePath) + + // Delete old chunks for this file on this branch + await deleteFiles([relativeFilePath], gitBranch, config.organizationId, config.projectId, config.kilocodeToken) + + // Chunk the file + const chunks = chunkFile( + relativeFilePath, + content, + fileHash, + config.organizationId, + config.projectId, + gitBranch, + isBase, + config.chunker, + ) + + // Upsert new chunks + await upsertChunks(chunks, config.kilocodeToken) + + console.info(`Indexed file: ${relativeFilePath} (${chunks.length} chunks)`) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + console.error(`Failed to index file ${filePath}: ${err.message}`) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "indexFile", + filePath, + }) + throw err + } +} + +/** + * Handles file deletion + * + * @param filePath Absolute file path + * @param config Indexing configuration + * @param context VSCode extension context + */ +export async function handleFileDeleted( + filePath: string, + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, +): Promise { + try { + const gitBranch = getCurrentBranch(config.workspacePath) + const relativeFilePath = generateRelativeFilePath(filePath, config.workspacePath) + + // Delete chunks from server + await deleteFiles([relativeFilePath], gitBranch, config.organizationId, config.projectId, config.kilocodeToken) + + console.info(`Deleted file from index: ${relativeFilePath}`) + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + console.error(`Failed to handle file deletion ${filePath}: ${err.message}`) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: err.message, + stack: err.stack, + location: "handleFileDeleted", + filePath, + }) + throw err + } +} diff --git a/src/services/code-index/managed/types.ts b/src/services/code-index/managed/types.ts new file mode 100644 index 00000000000..79db7fd5a34 --- /dev/null +++ b/src/services/code-index/managed/types.ts @@ -0,0 +1,221 @@ +/** + * Type definitions for Managed Codebase Indexing + * + * This module defines the core types used throughout the managed indexing system. + * The system uses a delta-based approach where only the main branch has a full index, + * and feature branches only index their changes (added/modified files). + */ + +/** + * A code chunk with git metadata for managed indexing + */ +export interface ManagedCodeChunk { + /** Unique identifier for this chunk (uuidv5 based on chunk hash + org ID) */ + id: string + /** Organization ID */ + organizationId: string + /** Project ID */ + projectId: string + /** Relative file path from workspace root */ + filePath: string + /** The actual code content of this chunk */ + codeChunk: string + /** Starting line number (1-based) */ + startLine: number + /** Ending line number (1-based, inclusive) */ + endLine: number + /** Hash of the chunk content for deduplication */ + chunkHash: string + /** Git branch this chunk belongs to */ + gitBranch: string + /** Whether this is from a base branch (main/develop) */ + isBaseBranch: boolean +} + +/** + * Configuration for the line-based chunker + */ +export interface ChunkerConfig { + /** Maximum characters per chunk (default: 1000) */ + maxChunkChars: number + /** Minimum characters per chunk (default: 200) */ + minChunkChars: number + /** Number of lines to overlap between chunks (default: 5) */ + overlapLines: number +} + +/** + * Git diff result showing changes between branches + */ +export interface GitDiff { + /** Files added on the feature branch */ + added: string[] + /** Files modified on the feature branch */ + modified: string[] + /** Files deleted on the feature branch */ + deleted: string[] +} + +/** + * Configuration for managed indexing + */ +export interface ManagedIndexingConfig { + /** Organization ID */ + organizationId: string + /** Project ID */ + projectId: string + /** Kilo Code authentication token */ + kilocodeToken: string + /** Workspace root path */ + workspacePath: string + /** Chunker configuration */ + chunker: ChunkerConfig + /** Batch size for API calls (default: 60) */ + batchSize: number + /** Whether to auto-sync on file changes (default: true) */ + autoSync: boolean +} + +/** + * Progress information during scanning + */ +export interface ScanProgress { + /** Number of files processed so far */ + filesProcessed: number + /** Total number of files to process */ + filesTotal: number + /** Number of chunks indexed so far */ + chunksIndexed: number + /** Current file being processed (optional) */ + currentFile?: string +} + +/** + * Result of a directory scan operation + */ +export interface ScanResult { + /** Whether the scan completed successfully */ + success: boolean + /** Number of files processed */ + filesProcessed: number + /** Number of files skipped (unchanged) */ + filesSkipped: number + /** Number of chunks indexed */ + chunksIndexed: number + /** Any errors encountered during scanning */ + errors: Error[] +} + +/** + * Server manifest entry for a single file + */ +export interface ManifestFileEntry { + /** Relative file path */ + filePath: string + /** Array of chunk hashes for this file (for accurate change detection) */ + chunkHashes: string[] + /** Number of chunks for this file */ + chunkCount: number + /** When this file was last indexed */ + lastIndexed: string + /** Optional: which user/client indexed it */ + indexedBy?: string +} + +/** + * Server manifest response + */ +export interface ServerManifest { + /** Organization ID */ + organizationId: string + /** Project ID */ + projectId: string + /** Git branch */ + gitBranch: string + /** List of indexed files */ + files: ManifestFileEntry[] + /** Total number of files in manifest */ + totalFiles: number + /** Total number of chunks across all files */ + totalChunks: number + /** When manifest was last updated */ + lastUpdated: string +} + +/** + * Search request with branch preferences + */ +export interface SearchRequest { + /** Search query */ + query: string + /** Organization ID */ + organizationId: string + /** Project ID */ + projectId: string + /** Preferred branch to search first */ + preferBranch: string + /** Fallback branch to search (usually 'main') */ + fallbackBranch: string + /** Files to exclude from results (deleted on preferred branch) */ + excludeFiles: string[] + /** Optional directory path filter */ + path?: string +} + +/** + * Search result from the server + */ +export interface SearchResult { + /** Chunk ID */ + id: string + /** File path */ + filePath: string + /** Starting line number */ + startLine: number + /** Ending line number */ + endLine: number + /** Relevance score */ + score: number + /** Which branch this result came from */ + gitBranch: string + /** Whether this result came from the preferred branch */ + fromPreferredBranch: boolean +} + +/** + * File change event + */ +export interface FileChangeEvent { + /** Type of change */ + type: "created" | "changed" | "deleted" + /** File path */ + filePath: string + /** Timestamp of change */ + timestamp: number +} + +/** + * Indexer state for UI updates + */ +export interface IndexerState { + /** Current status */ + status: "idle" | "scanning" | "watching" | "error" + /** Status message */ + message: string + /** Current git branch */ + gitBranch?: string + /** Last sync timestamp */ + lastSyncTime?: number + /** Total files indexed */ + totalFiles?: number + /** Total chunks indexed */ + totalChunks?: number + /** Error message if status is 'error' */ + error?: string + /** Server manifest data (when available) */ + manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + } +} diff --git a/src/services/code-index/managed/watcher.ts b/src/services/code-index/managed/watcher.ts new file mode 100644 index 00000000000..fac57147cca --- /dev/null +++ b/src/services/code-index/managed/watcher.ts @@ -0,0 +1,153 @@ +/** + * File watcher for managed codebase indexing + * + * This module provides functions for watching file changes and incrementally + * updating the index. Changes are debounced and batched for efficiency. + */ + +import * as vscode from "vscode" +import * as path from "path" +import { execSync } from "child_process" +import { indexFile, handleFileDeleted } from "./scanner" +import { ManagedIndexingConfig, FileChangeEvent } from "./types" +import { scannerExtensions } from "../shared/supported-extensions" +import { MANAGED_FILE_WATCH_DEBOUNCE_MS } from "../constants" +import { logger } from "../../../utils/logging" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" + +/** + * Creates and initializes a file watcher for managed indexing + * + * The watcher: + * - Monitors file create, change, and delete events + * - Debounces rapid changes (500ms default) + * - Filters by supported file extensions + * - Updates the index incrementally + * + * @param config Managed indexing configuration + * @param context VSCode extension context + * @param onFilesChanged Optional callback when files are processed + * @returns Disposable watcher instance + */ +export function createFileWatcher( + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, + onFilesChanged?: (events: FileChangeEvent[]) => void, +): vscode.Disposable { + // Create file system watcher for all files + const watcher = vscode.workspace.createFileSystemWatcher("**/*") + + // Change queue for debouncing + const changeQueue: FileChangeEvent[] = [] + let debounceTimer: NodeJS.Timeout | null = null + + /** + * Handles a file change event + */ + const handleChange = (uri: vscode.Uri, type: FileChangeEvent["type"]) => { + // Filter by supported extensions + const ext = path.extname(uri.fsPath).toLowerCase() + if (!scannerExtensions.includes(ext)) { + return + } + + // Add to queue + changeQueue.push({ + type, + filePath: uri.fsPath, + timestamp: Date.now(), + }) + + // Debounce processing + if (debounceTimer) { + clearTimeout(debounceTimer) + } + + debounceTimer = setTimeout(async () => { + await processChangeQueue([...changeQueue], config, context, onFilesChanged) + changeQueue.length = 0 + }, MANAGED_FILE_WATCH_DEBOUNCE_MS) + } + + // Register event handlers + const createDisposable = watcher.onDidCreate((uri) => handleChange(uri, "created")) + const changeDisposable = watcher.onDidChange((uri) => handleChange(uri, "changed")) + const deleteDisposable = watcher.onDidDelete((uri) => handleChange(uri, "deleted")) + + // Return composite disposable + return vscode.Disposable.from(watcher, createDisposable, changeDisposable, deleteDisposable) +} + +/** + * Processes a queue of file changes + * + * @param events Array of file change events + * @param config Indexing configuration + * @param context VSCode extension context + * @param onFilesChanged Optional callback + */ +async function processChangeQueue( + events: FileChangeEvent[], + config: ManagedIndexingConfig, + context: vscode.ExtensionContext, + onFilesChanged?: (events: FileChangeEvent[]) => void, +): Promise { + if (events.length === 0) { + return + } + + logger.info(`Processing ${events.length} file changes`) + + try { + // Group events by type + const created = events.filter((e) => e.type === "created") + const changed = events.filter((e) => e.type === "changed") + const deleted = events.filter((e) => e.type === "deleted") + + // Process deletions first + for (const event of deleted) { + try { + await handleFileDeleted(event.filePath, config, context) + } catch (error) { + logger.error(`Failed to handle deletion of ${event.filePath}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "processChangeQueue:delete", + filePath: event.filePath, + }) + } + } + + // Process created and changed files + const toIndex = [...created, ...changed] + for (const event of toIndex) { + try { + await indexFile(event.filePath, config, context) + } catch (error) { + logger.error(`Failed to index ${event.filePath}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "processChangeQueue:index", + filePath: event.filePath, + }) + } + } + + // Notify callback + onFilesChanged?.(events) + + logger.info( + `Processed ${events.length} file changes: ${created.length} created, ${changed.length} changed, ${deleted.length} deleted`, + ) + } catch (error) { + logger.error("Failed to process change queue:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "processChangeQueue", + }) + } +} diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 3481ec81b12..aab00bf7c6d 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -12,9 +12,12 @@ import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" import fs from "fs/promises" import ignore from "ignore" import path from "path" -import { t } from "../../i18n" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +// kilocode_change start: Managed indexing (new standalone system) +import { startIndexing as startManagedIndexing, search as searchManaged, createManagedIndexingConfig } from "./managed" +import type { IndexerState as ManagedIndexerState } from "./managed" +// kilocode_change end export class CodeIndexManager { // --- Singleton Implementation --- @@ -28,6 +31,11 @@ export class CodeIndexManager { private _searchService: CodeIndexSearchService | undefined private _cacheManager: CacheManager | undefined + // kilocode_change start: Managed indexing (new standalone system) + private _managedIndexerDisposable: vscode.Disposable | undefined + private _managedIndexerState: ManagedIndexerState | undefined + // kilocode_change end + // Flag to prevent race conditions during error recovery private _isRecoveringFromError = false @@ -63,7 +71,7 @@ export class CodeIndexManager { CodeIndexManager.instances.clear() } - private readonly workspacePath: string + public readonly workspacePath: string // kilocode_change private readonly context: vscode.ExtensionContext // Private constructor for singleton pattern @@ -120,6 +128,12 @@ export class CodeIndexManager { if (!this._configManager) { this._configManager = new CodeIndexConfigManager(contextProxy) } + + // Pass Kilo org props to config manager if available + if (this._kiloOrgCodeIndexProps) { + this._configManager.setKiloOrgProps(this._kiloOrgCodeIndexProps) + } + // Load configuration once to get current state and restart requirements const { requiresRestart } = await this._configManager.loadConfiguration() @@ -148,7 +162,20 @@ export class CodeIndexManager { const needsServiceRecreation = !this._serviceFactory || requiresRestart if (needsServiceRecreation) { - await this._recreateServices() + // kilocode_change start: add additional logging + try { + await this._recreateServices() + } catch (error) { + // Log the error and set error state + console.error("[CodeIndexManager] Failed to recreate services:", error) + this._stateManager.setSystemState( + "Error", + `Failed to initialize: ${error instanceof Error ? error.message : String(error)}`, + ) + // Re-throw to prevent further initialization + throw error + } + // kilocode_change end } // 5. Handle Indexing Start/Restart @@ -264,6 +291,12 @@ export class CodeIndexManager { if (this._orchestrator) { this.stopWatcher() } + // kilocode_change start + if (this._managedIndexerDisposable) { + this._managedIndexerDisposable.dispose() + this._managedIndexerDisposable = undefined + } + // kilocode_change end this._stateManager.dispose() } @@ -291,6 +324,12 @@ export class CodeIndexManager { } public async searchIndex(query: string, directoryPrefix?: string): Promise { + // kilocode_change start: Route to managed indexing if available + if (this.isManagedIndexingAvailable) { + return this.searchManagedIndex(query, directoryPrefix) + } + + // kilocode_change end: Fall back to local indexing if (!this.isFeatureEnabled) { return [] } @@ -354,17 +393,21 @@ export class CodeIndexManager { rooIgnoreController, ) - // kilocode_change start - // Only validate the embedder if it matches the currently configured provider - const config = this._configManager!.getConfig() - const shouldValidate = embedder.embedderInfo.name === config.embedderProvider - - if (shouldValidate) { - const validationResult = await this._serviceFactory.validateEmbedder(embedder) - if (!validationResult.valid) { - const errorMessage = validationResult.error || "Embedder configuration validation failed" - this._stateManager.setSystemState("Error", errorMessage) - throw new Error(errorMessage) + // kilocode_change start: Handle Kilo org mode (no embedder/vector store validation needed) + const isKiloOrgMode = this._configManager!.isKiloOrgMode + + if (!isKiloOrgMode) { + // Only validate the embedder if it matches the currently configured provider + const config = this._configManager!.getConfig() + const shouldValidate = embedder && embedder.embedderInfo.name === config.embedderProvider + + if (shouldValidate) { + const validationResult = await this._serviceFactory.validateEmbedder(embedder) + if (!validationResult.valid) { + const errorMessage = validationResult.error || "Embedder configuration validation failed" + this._stateManager.setSystemState("Error", errorMessage) + throw new Error(errorMessage) + } } } // kilocode_change end @@ -375,21 +418,26 @@ export class CodeIndexManager { this._stateManager, this.workspacePath, this._cacheManager!, - vectorStore, + vectorStore!, scanner, - fileWatcher, + fileWatcher!, ) - // (Re)Initialize search service + // kilocode_change start: Always create search service (it handles both local and Kilo org mode) + // In Kilo org mode, embedder and vectorStore will be null, but search service handles this this._searchService = new CodeIndexSearchService( this._configManager!, this._stateManager, - embedder, - vectorStore, + embedder!, + vectorStore!, ) + // kilocode_change end // Clear any error state after successful recreation this._stateManager.setSystemState("Standby", "") + + // Update scanner with Kilo org props if they exist + this._updateScannerWithKiloProps() } /** @@ -440,4 +488,181 @@ export class CodeIndexManager { } } } + + // kilocode_change start Add ability to set kilo specific props + private _kiloOrgCodeIndexProps: { + organizationId: string + kilocodeToken: string + projectId: string + } | null = null + + public setKiloOrgCodeIndexProps(props: NonNullable) { + this._kiloOrgCodeIndexProps = props + + // Pass props to config manager if it exists + if (this._configManager) { + this._configManager.setKiloOrgProps(props) + } + + // Start managed indexing automatically + this.startManagedIndexing().catch((error) => { + const err = error instanceof Error ? error : new Error(String(error)) + console.error("[CodeIndexManager] Failed to start managed indexing:", err.message) + if (err.stack) { + console.error("[CodeIndexManager] Stack trace:", err.stack) + } + // Don't throw - allow the manager to continue functioning + // Set error state so UI can show the issue + this._stateManager.setSystemState("Error", `Failed to start indexing: ${err.message}`) + }) + + // Pass the props to the scanner through the service factory + // The scanner will be updated when services are recreated + this._updateScannerWithKiloProps() + } + + public getKiloOrgCodeIndexProps() { + return this._kiloOrgCodeIndexProps + } + + /** + * Updates the scanner with Kilo org props if available + * This is called after services are created or when props are set + */ + private _updateScannerWithKiloProps(): void { + if (this._kiloOrgCodeIndexProps && this._orchestrator) { + const scanner = (this._orchestrator as any).scanner + if (scanner && typeof scanner.setKiloOrgCodeIndexProps === "function") { + scanner.setKiloOrgCodeIndexProps(this._kiloOrgCodeIndexProps) + } + } + } + // kilocode_change end + + // --- Managed Indexing Methods --- + + /** + * Starts the managed indexer (for organization users) + * This is the new standalone indexing system that uses delta-based indexing + */ + public async startManagedIndexing(): Promise { + if (!this._kiloOrgCodeIndexProps) { + throw new Error("Managed indexing requires organization credentials") + } + + try { + // Stop any existing managed indexer + if (this._managedIndexerDisposable) { + this._managedIndexerDisposable.dispose() + this._managedIndexerDisposable = undefined + } + + // Create configuration + const config = createManagedIndexingConfig( + this._kiloOrgCodeIndexProps.organizationId, + this._kiloOrgCodeIndexProps.projectId, + this._kiloOrgCodeIndexProps.kilocodeToken, + this.workspacePath, + ) + + // Start indexing + this._managedIndexerDisposable = await startManagedIndexing(config, this.context, (state) => { + this._managedIndexerState = state + // Emit state change event through state manager + // Map managed indexer states to system states: + // - "error" → "Error" + // - "scanning" → "Indexing" + // - "watching" → "Indexed" (has data and watching for changes) + // - "idle" → "Standby" (no data or needs re-scan) + let systemState: "Standby" | "Indexing" | "Indexed" | "Error" + if (state.status === "error") { + systemState = "Error" + } else if (state.status === "scanning") { + systemState = "Indexing" + } else if (state.status === "watching") { + systemState = "Indexed" + } else { + // "idle" or any other status + systemState = "Standby" + } + + this._stateManager.setSystemState(systemState, state.message, state.manifest) + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error("[CodeIndexManager] Failed to start managed indexing:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + location: "startManagedIndexing", + }) + throw error + } + } + + /** + * Stops the managed indexer + */ + public stopManagedIndexing(): void { + if (this._managedIndexerDisposable) { + this._managedIndexerDisposable.dispose() + this._managedIndexerDisposable = undefined + this._managedIndexerState = undefined + } + } + + /** + * Searches using the managed indexer + */ + public async searchManagedIndex(query: string, directoryPrefix?: string): Promise { + if (!this._kiloOrgCodeIndexProps) { + return [] + } + + try { + const config = createManagedIndexingConfig( + this._kiloOrgCodeIndexProps.organizationId, + this._kiloOrgCodeIndexProps.projectId, + this._kiloOrgCodeIndexProps.kilocodeToken, + this.workspacePath, + ) + + const results = await searchManaged(query, config, directoryPrefix) + + // Convert to VectorStoreSearchResult format + return results.map((result) => ({ + id: result.id, + score: result.score, + payload: { + filePath: result.filePath, + codeChunk: "", // Managed indexing doesn't return code chunks + startLine: result.startLine, + endLine: result.endLine, + }, + })) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error("[CodeIndexManager] Managed search failed:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + location: "searchManagedIndex", + }) + return [] + } + } + + /** + * Gets the managed indexer state + */ + public getManagedIndexerState(): ManagedIndexerState | undefined { + return this._managedIndexerState + } + + /** + * Checks if managed indexing is available (has org credentials) + */ + public get isManagedIndexingAvailable(): boolean { + return !!this._kiloOrgCodeIndexProps + } } diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index 90257fdfb19..05bd79d9c5f 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -8,6 +8,11 @@ export class CodeIndexStateManager { private _processedItems: number = 0 private _totalItems: number = 0 private _currentItemUnit: string = "blocks" + private _manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + } private _progressEmitter = new vscode.EventEmitter>() // --- Public API --- @@ -25,12 +30,21 @@ export class CodeIndexStateManager { processedItems: this._processedItems, totalItems: this._totalItems, currentItemUnit: this._currentItemUnit, + manifest: this._manifest, } } // --- State Management --- - public setSystemState(newState: IndexingState, message?: string): void { + public setSystemState( + newState: IndexingState, + message?: string, + manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + }, + ): void { const stateChanged = newState !== this._systemStatus || (message !== undefined && message !== this._statusMessage) @@ -39,6 +53,9 @@ export class CodeIndexStateManager { if (message !== undefined) { this._statusMessage = message } + if (manifest !== undefined) { + this._manifest = manifest + } // Reset progress counters if moving to a non-indexing state or starting fresh if (newState !== "Indexing") { @@ -51,6 +68,11 @@ export class CodeIndexStateManager { if (newState === "Error" && message === undefined) this._statusMessage = "An error occurred." } + // Clear manifest if not in Indexed state + if (newState !== "Indexed") { + this._manifest = undefined + } + this._progressEmitter.fire(this.getCurrentStatus()) } } diff --git a/src/services/kilocode/OrganizationService.ts b/src/services/kilocode/OrganizationService.ts new file mode 100644 index 00000000000..a471332f9ff --- /dev/null +++ b/src/services/kilocode/OrganizationService.ts @@ -0,0 +1,84 @@ +import axios from "axios" +import { getKiloUrlFromToken } from "@roo-code/types" +import { X_KILOCODE_ORGANIZATIONID, X_KILOCODE_TESTER } from "../../shared/kilocode/headers" +import { KiloOrganization, KiloOrganizationSchema } from "../../shared/kilocode/organization" +import { logger } from "../../utils/logging" + +/** + * Service for fetching and managing Kilo Code organization settings + */ +export class OrganizationService { + /** + * Fetches organization details from the Kilo Code API + * @param kilocodeToken - The authentication token + * @param organizationId - The organization ID + * @param kilocodeTesterWarningsDisabledUntil - Timestamp for suppressing tester warnings + * @returns The organization object with settings + */ + public static async fetchOrganization( + kilocodeToken: string, + organizationId: string, + kilocodeTesterWarningsDisabledUntil?: number, + ): Promise { + try { + if (!organizationId || !kilocodeToken) { + logger.warn("[OrganizationService] Missing required parameters for fetching organization") + return null + } + + const headers: Record = { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + } + + headers[X_KILOCODE_ORGANIZATIONID] = organizationId + + // Add X-KILOCODE-TESTER: SUPPRESS header if the setting is enabled + if (kilocodeTesterWarningsDisabledUntil && kilocodeTesterWarningsDisabledUntil > Date.now()) { + headers[X_KILOCODE_TESTER] = "SUPPRESS" + } + + const url = getKiloUrlFromToken( + `https://api.kilocode.ai/api/organizations/${organizationId}`, + kilocodeToken, + ) + + const response = await axios.get(url, { headers }) + + // Validate the response against the schema + const validationResult = KiloOrganizationSchema.safeParse(response.data) + + if (!validationResult.success) { + logger.error("[OrganizationService] Invalid organization response format", { + organizationId, + errors: validationResult.error.errors, + }) + return null + } + + logger.info("[OrganizationService] Successfully fetched organization", { + organizationId, + codeIndexingEnabled: validationResult.data.settings.code_indexing_enabled, + }) + + return validationResult.data + } catch (error) { + // Log error but don't throw - gracefully degrade + logger.error("[OrganizationService] Failed to fetch organization", { + organizationId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } + } + + /** + * Checks if code indexing is enabled for an organization + * @param organization - The organization object + * @returns true if code indexing is enabled (defaults to true if not specified) + */ + public static isCodeIndexingEnabled(organization: KiloOrganization | null): boolean { + // Default to true if organization is null or setting is not specified + return organization?.settings?.code_indexing_enabled ?? true + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7c20c4b9149..601534bed79 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -56,6 +56,11 @@ export interface IndexingStatus { totalItems: number currentItemUnit?: string workspacePath?: string + manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + } } export interface IndexingStatusUpdateMessage { @@ -155,6 +160,8 @@ export interface ExtensionMessage { | "dismissedUpsells" | "showTimestamps" // kilocode_change | "organizationSwitchResult" + | "deleteManagedBranchIndex" // kilocode_change + | "deleteManagedProjectIndex" // kilocode_change text?: string // kilocode_change start payload?: diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 5287a8db938..3c41cfc81a4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -305,6 +305,8 @@ export interface WebviewMessage { | "editQueuedMessage" | "dismissUpsell" | "getDismissedUpsells" + | "deleteManagedBranchIndex" // kilocode_change + | "deleteManagedProjectIndex" // kilocode_change text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/shared/kilocode/organization.ts b/src/shared/kilocode/organization.ts new file mode 100644 index 00000000000..a7dac356dd8 --- /dev/null +++ b/src/shared/kilocode/organization.ts @@ -0,0 +1,42 @@ +import { z } from "zod" + +/** + * Kilo Code Organization Settings Schema + * These settings control organization-level features and configurations + */ +export const KiloOrganizationSettingsSchema = z.object({ + model_allow_list: z.array(z.string()).optional(), + provider_allow_list: z.array(z.string()).optional(), + default_model: z.string().optional(), + data_collection: z.enum(["allow", "deny"]).nullable().optional(), + // null means they were grandfathered in and so they have usage limits enabled + enable_usage_limits: z.boolean().optional(), + code_indexing_enabled: z.boolean().optional(), + projects_ui_enabled: z.boolean().optional(), +}) + +export type KiloOrganizationSettings = z.infer + +/** + * Kilo Code Organization Schema + * Represents the full organization object returned from the API + */ +export const KiloOrganizationSchema = z.object({ + id: z.string(), + name: z.string(), + created_at: z.string(), + updated_at: z.string(), + microdollars_balance: z.number(), + microdollars_used: z.number(), + stripe_customer_id: z.string().nullable(), + auto_top_up_enabled: z.boolean(), + settings: KiloOrganizationSettingsSchema, + seat_count: z.number().min(0).default(0), + require_seats: z.boolean().default(false), + created_by_kilo_user_id: z.string().nullable(), + deleted_at: z.string().nullable(), + sso_domain: z.string().nullable(), + plan: z.enum(["teams", "enterprise"]), +}) + +export type KiloOrganization = z.infer diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx index bf0e8f7d79a..9949df4218e 100644 --- a/webview-ui/src/components/chat/IndexingStatusBadge.tsx +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -11,6 +11,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { PopoverTrigger, StandardTooltip, Button } from "@src/components/ui" import { CodeIndexPopover } from "./CodeIndexPopover" +import { ManagedCodeIndexPopover } from "./kilocode/ManagedCodeIndexPopover" interface IndexingStatusBadgeProps { className?: string @@ -18,7 +19,10 @@ interface IndexingStatusBadgeProps { export const IndexingStatusBadge: React.FC = ({ className }) => { const { t } = useAppTranslation() - const { cwd } = useExtensionState() + const { cwd, apiConfiguration } = useExtensionState() + + // Check if organization indexing is available + const hasOrganization = !!apiConfiguration?.kilocodeOrganizationId const [indexingStatus, setIndexingStatus] = useState({ systemStatus: "Standby", @@ -82,8 +86,11 @@ export const IndexingStatusBadge: React.FC = ({ classN return statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby }, [indexingStatus.systemStatus]) + // Use ManagedCodeIndexPopover when organization is available, otherwise use regular CodeIndexPopover + const PopoverComponent = hasOrganization ? ManagedCodeIndexPopover : CodeIndexPopover + return ( - + - + ) } diff --git a/webview-ui/src/components/chat/kilocode/ManagedCodeIndexPopover.tsx b/webview-ui/src/components/chat/kilocode/ManagedCodeIndexPopover.tsx new file mode 100644 index 00000000000..711d824a898 --- /dev/null +++ b/webview-ui/src/components/chat/kilocode/ManagedCodeIndexPopover.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect, useCallback } from "react" +import { Trans } from "react-i18next" +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" + +import type { IndexingStatus } from "@roo/ExtensionMessage" + +import { vscode } from "@src/utils/vscode" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { buildDocLink } from "@src/utils/docLinks" +import { Popover, PopoverContent } from "@src/components/ui" +import { useRooPortal } from "@src/components/ui/hooks/useRooPortal" +import { useEscapeKey } from "@src/hooks/useEscapeKey" +import { OrganizationIndexingTab } from "./OrganizationIndexingTab" + +interface CodeIndexPopoverProps { + children: React.ReactNode + indexingStatus: IndexingStatus +} + +export const ManagedCodeIndexPopover: React.FC = ({ + children, + indexingStatus: externalIndexingStatus, +}) => { + const { t } = useAppTranslation() + const { cwd } = useExtensionState() + const [open, setOpen] = useState(false) + + const [indexingStatus, setIndexingStatus] = useState(externalIndexingStatus) + + // Update indexing status from parent + useEffect(() => { + setIndexingStatus(externalIndexingStatus) + }, [externalIndexingStatus]) + + // Request initial indexing status + useEffect(() => { + if (open) { + vscode.postMessage({ type: "requestIndexingStatus" }) + } + const handleMessage = (event: MessageEvent) => { + if (event.data.type === "workspaceUpdated") { + // When workspace changes, request updated indexing status + if (open) { + vscode.postMessage({ type: "requestIndexingStatus" }) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [open]) + + // Listen for indexing status updates + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data.type === "indexingStatusUpdate") { + 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", + }) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [cwd]) + + // Use the shared ESC key handler hook + useEscapeKey(open, () => setOpen(false)) + + const handleCancelIndexing = useCallback(() => { + // Optimistically update UI while backend cancels + setIndexingStatus((prev) => ({ + ...prev, + message: t("settings:codeIndex.cancelling"), + })) + vscode.postMessage({ type: "cancelIndexing" }) + }, [t]) + + const portalContainer = useRooPortal("roo-portal") + + return ( + + {children} + +
+
+

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

+
+

+ + + +

+
+ +
+ +
+
+
+ ) +} diff --git a/webview-ui/src/components/chat/kilocode/OrganizationIndexingTab.tsx b/webview-ui/src/components/chat/kilocode/OrganizationIndexingTab.tsx new file mode 100644 index 00000000000..5cbf16f53f2 --- /dev/null +++ b/webview-ui/src/components/chat/kilocode/OrganizationIndexingTab.tsx @@ -0,0 +1,199 @@ +import React, { useMemo } from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" + +import type { IndexingStatus } from "@roo/ExtensionMessage" + +import { vscode } from "@src/utils/vscode" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { cn } from "@src/lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@src/components/ui" + +interface OrganizationIndexingTabProps { + indexingStatus: IndexingStatus & { + manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + } + } + onCancelIndexing: () => void +} + +export const OrganizationIndexingTab: React.FC = ({ + indexingStatus, + onCancelIndexing, +}) => { + const { t } = useAppTranslation() + + const progressPercentage = useMemo( + () => + indexingStatus.totalItems > 0 + ? Math.round((indexingStatus.processedItems / indexingStatus.totalItems) * 100) + : 0, + [indexingStatus.processedItems, indexingStatus.totalItems], + ) + + const transformStyleString = `translateX(-${100 - progressPercentage}%)` + + return ( +
+
+

+ Organization indexing is managed automatically by Kilo Code. Your codebase is indexed on our servers + with delta-based indexing for feature branches. +

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

Status

+
+ + {t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} + {indexingStatus.message ? ` - ${indexingStatus.message}` : ""} +
+ + {indexingStatus.systemStatus === "Indexing" && ( +
+ + + +
+ )} +
+ + {/* Info Section - Show manifest data when indexed, otherwise show how it works */} + {indexingStatus.systemStatus === "Indexed" && indexingStatus.manifest ? ( +
+

Index Information

+
+
+ Files indexed: + {indexingStatus.manifest.totalFiles.toLocaleString()} +
+
+ Total chunks: + {indexingStatus.manifest.totalChunks.toLocaleString()} +
+
+ Last indexed: + + {new Date(indexingStatus.manifest.lastUpdated).toLocaleString()} + +
+
+
+ ) : ( +
+

How it works

+
    +
  • Main branch: Full index (shared across organization)
  • +
  • Feature branches: Only changed files indexed (99% storage savings)
  • +
  • Automatic file watching and incremental updates
  • +
  • Branch-aware search with deleted file handling
  • +
+
+ )} + + {/* Action Buttons */} +
+
+ {indexingStatus.systemStatus === "Indexing" && ( + + {t("settings:codeIndex.cancelIndexingButton")} + + )} + {(indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && ( + vscode.postMessage({ type: "startIndexing" })}> + Start Organization Indexing + + )} +
+ + {/* Management Buttons */} +
+

Management

+
+ + + + Delete Branch Index + + + + + Delete Branch Index? + + This will delete all indexed data for the current branch from the server. This + action cannot be undone. You'll need to re-index to restore the data. + + + + Cancel + vscode.postMessage({ type: "deleteManagedBranchIndex" })}> + Delete Branch Index + + + + + + + + + Delete Entire Index + + + + + Delete Entire Index? + + This will delete ALL indexed data for this project across ALL branches from the + server. This action cannot be undone and will affect all team members. + You'll need to re-index everything to restore the data. + + + + Cancel + + vscode.postMessage({ + type: "deleteManagedProjectIndex", + }) + }> + Delete Entire Index + + + + +
+
+
+
+ ) +}