From a67b2dcd7591af3fb7a1e9dd304f600b19bc86a9 Mon Sep 17 00:00:00 2001 From: Naituw Date: Tue, 15 Jul 2025 14:42:49 +0800 Subject: [PATCH 1/4] fix: Improve @ file search compatibility with large or complext projects - Increase file listing limit to handle larger projects (5000 -> 500000) - Implement caching for file lists to prevent performance degradation - Respect VSCode search configuration (`search.useIgnoreFiles`, `search.useGlobalIgnoreFiles`, `search.useParentIgnoreFiles`) when listing files fixes #5721 --- src/core/task/__tests__/Task.spec.ts | 1 + src/core/webview/webviewMessageHandler.ts | 8 +- .../workspace/WorkspaceTracker.ts | 118 ++++++ .../__tests__/WorkspaceTracker.spec.ts | 363 ++++++++++++++++++ src/services/search/file-search.ts | 9 +- 5 files changed, 495 insertions(+), 4 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 693f72d1c7b9..72bd3b8fd21e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -116,6 +116,7 @@ vi.mock("vscode", () => { stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1 }, onDidSaveTextDocument: vi.fn(() => mockDisposable), + onDidChangeConfiguration: vi.fn(() => mockDisposable), getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })), }, env: { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e70b39df8fde..ae284b7d1b31 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1400,10 +1400,16 @@ export const webviewMessageHandler = async ( break } try { - // Call file search service with query from message + // Get cached file list from WorkspaceTracker + const workspaceFiles = provider.workspaceTracker + ? await provider.workspaceTracker.getRipgrepFileList() + : [] + + // Call file search service with query and cached files from message const results = await searchWorkspaceFiles( message.query || "", workspacePath, + workspaceFiles, 20, // Use default limit, as filtering is now done in the backend ) diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index 7de8f994c033..085be7c49abf 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -4,6 +4,8 @@ import * as path from "path" import { listFiles } from "../../services/glob/list-files" import { ClineProvider } from "../../core/webview/ClineProvider" import { toRelativePath, getWorkspacePath } from "../../utils/path" +import { executeRipgrepForFiles, FileResult } from "../../services/search/file-search" +import { isPathInIgnoredDirectory } from "../../services/glob/ignore-utils" const MAX_INITIAL_FILES = 1_000 @@ -16,14 +18,105 @@ class WorkspaceTracker { private prevWorkSpacePath: string | undefined private resetTimer: NodeJS.Timeout | null = null + // Ripgrep cache related properties + private ripgrepFileCache: FileResult[] | null = null + private ripgrepCacheWorkspacePath: string | undefined = undefined + private ripgrepOperationPromise: Promise | null = null + get cwd() { return getWorkspacePath() } + constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) this.registerListeners() } + /** + * Get ripgrep extra options based on VSCode search configuration + */ + private getRipgrepExtraOptions(): string[] { + const config = vscode.workspace.getConfiguration("search") + const extraOptions: string[] = [] + + const useIgnoreFiles = config.get("useIgnoreFiles", true) + + if (!useIgnoreFiles) { + extraOptions.push("--no-ignore") + } else { + const useGlobalIgnoreFiles = config.get("useGlobalIgnoreFiles", true) + const useParentIgnoreFiles = config.get("useParentIgnoreFiles", true) + + if (!useGlobalIgnoreFiles) { + extraOptions.push("--no-ignore-global") + } + + if (!useParentIgnoreFiles) { + extraOptions.push("--no-ignore-parent") + } + } + + return extraOptions + } + + /** + * Get comprehensive file list using ripgrep with caching + * This provides a more complete file list than the limited filePaths set + */ + async getRipgrepFileList(): Promise { + const currentWorkspacePath = this.cwd + + if (!currentWorkspacePath) { + return [] + } + + // Return cached results if available and workspace hasn't changed + if (this.ripgrepFileCache && this.ripgrepCacheWorkspacePath === currentWorkspacePath) { + return this.ripgrepFileCache + } + + // If there's an ongoing operation, wait for it + if (this.ripgrepOperationPromise) { + try { + return await this.ripgrepOperationPromise + } catch (error) { + // If the ongoing operation failed, we'll start a new one below + this.ripgrepOperationPromise = null + } + } + + try { + // Start new operation and store the promise + this.ripgrepOperationPromise = executeRipgrepForFiles( + currentWorkspacePath, + 500000, + this.getRipgrepExtraOptions(), + ) + const fileResults = await this.ripgrepOperationPromise + + // Cache the results and clear the operation promise + this.ripgrepFileCache = fileResults + this.ripgrepCacheWorkspacePath = currentWorkspacePath + this.ripgrepOperationPromise = null + + return fileResults + } catch (error) { + console.error("Error getting ripgrep file list:", error) + this.ripgrepOperationPromise = null + return [] + } + } + + /** + * Clear the ripgrep file cache + * Called when workspace changes or files are modified + */ + private clearRipgrepCache() { + this.ripgrepFileCache = null + this.ripgrepCacheWorkspacePath = undefined + this.ripgrepOperationPromise = null + } + async initializeFilePaths() { // should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file if (!this.cwd) { @@ -36,6 +129,9 @@ class WorkspaceTracker { } files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file))) this.workspaceDidUpdate() + + // preheat filelist + this.getRipgrepFileList() } private registerListeners() { @@ -43,6 +139,9 @@ class WorkspaceTracker { this.prevWorkSpacePath = this.cwd this.disposables.push( watcher.onDidCreate(async (uri) => { + if (!isPathInIgnoredDirectory(uri.fsPath)) { + this.clearRipgrepCache() + } await this.addFilePath(uri.fsPath) this.workspaceDidUpdate() }), @@ -51,6 +150,9 @@ class WorkspaceTracker { // Renaming files triggers a delete and create event this.disposables.push( watcher.onDidDelete(async (uri) => { + if (!isPathInIgnoredDirectory(uri.fsPath)) { + this.clearRipgrepCache() + } if (await this.removeFilePath(uri.fsPath)) { this.workspaceDidUpdate() } @@ -71,6 +173,20 @@ class WorkspaceTracker { } }), ) + + // Listen for VSCode configuration changes + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((event: vscode.ConfigurationChangeEvent) => { + // Clear cache when search-related configuration changes + if ( + event.affectsConfiguration("search.useIgnoreFiles") || + event.affectsConfiguration("search.useGlobalIgnoreFiles") || + event.affectsConfiguration("search.useParentIgnoreFiles") + ) { + this.clearRipgrepCache() + } + }), + ) } private getOpenedTabsInfo() { @@ -97,6 +213,8 @@ class WorkspaceTracker { } this.resetTimer = setTimeout(async () => { if (this.prevWorkSpacePath !== this.cwd) { + // Clear cache when workspace changes + this.clearRipgrepCache() await this.providerRef.deref()?.postMessageToWebview({ type: "workspaceUpdated", filePaths: [], diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts index b0a617d97096..42cf8b4e94da 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts @@ -4,6 +4,7 @@ import WorkspaceTracker from "../WorkspaceTracker" import { ClineProvider } from "../../../core/webview/ClineProvider" import { listFiles } from "../../../services/glob/list-files" import { getWorkspacePath } from "../../../utils/path" +import { executeRipgrepForFiles } from "../../../services/search/file-search" // Mock functions - must be defined before vitest.mock calls const mockOnDidCreate = vitest.fn() @@ -12,6 +13,8 @@ const mockDispose = vitest.fn() // Store registered tab change callback let registeredTabChangeCallback: (() => Promise) | null = null +// Store registered configuration change callback +let registeredConfigChangeCallback: ((event: any) => void) | null = null // Mock workspace path vitest.mock("../../../utils/path", () => ({ @@ -26,6 +29,16 @@ vitest.mock("../../../utils/path", () => ({ }), })) +// Mock executeRipgrepForFiles function +vitest.mock("../../../services/search/file-search", () => ({ + executeRipgrepForFiles: vitest.fn(), +})) + +// Mock ignore utils +vitest.mock("../../../services/glob/ignore-utils", () => ({ + isPathInIgnoredDirectory: vitest.fn().mockReturnValue(false), +})) + // Mock watcher - must be defined after mockDispose but before vitest.mock("vscode") const mockWatcher = { onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }), @@ -57,6 +70,11 @@ vitest.mock("vscode", () => ({ fs: { stat: vitest.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1 }, + getConfiguration: vitest.fn(), + onDidChangeConfiguration: vitest.fn((callback) => { + registeredConfigChangeCallback = callback + return { dispose: mockDispose } + }), }, FileType: { File: 1, Directory: 2 }, })) @@ -68,6 +86,7 @@ vitest.mock("../../../services/glob/list-files", () => ({ describe("WorkspaceTracker", () => { let workspaceTracker: WorkspaceTracker let mockProvider: ClineProvider + let mockExecuteRipgrepForFiles: Mock beforeEach(() => { vitest.clearAllMocks() @@ -75,10 +94,35 @@ describe("WorkspaceTracker", () => { // Reset all mock implementations registeredTabChangeCallback = null + registeredConfigChangeCallback = null // Reset workspace path mock ;(getWorkspacePath as Mock).mockReturnValue("/test/workspace") + // Setup executeRipgrepForFiles mock + mockExecuteRipgrepForFiles = executeRipgrepForFiles as Mock + mockExecuteRipgrepForFiles.mockResolvedValue([ + { path: "src/test1.ts", type: "file", label: "test1.ts" }, + { path: "src/test2.ts", type: "file", label: "test2.ts" }, + ]) + + // Setup workspace configuration mock + const mockConfig = { + get: vitest.fn((key: string, defaultValue?: any) => { + switch (key) { + case "useIgnoreFiles": + return true + case "useGlobalIgnoreFiles": + return true + case "useParentIgnoreFiles": + return true + default: + return defaultValue + } + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + // Create provider mock mockProvider = { postMessageToWebview: vitest.fn().mockResolvedValue(undefined), @@ -89,6 +133,8 @@ describe("WorkspaceTracker", () => { // Ensure the tab change callback was registered expect(registeredTabChangeCallback).not.toBeNull() + // Ensure the configuration change callback was registered + expect(registeredConfigChangeCallback).not.toBeNull() }) it("should initialize with workspace files", async () => { @@ -347,4 +393,321 @@ describe("WorkspaceTracker", () => { // No postMessage should be called after dispose expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) + + describe("ripgrep file list caching", () => { + it("should fetch and cache ripgrep file list", async () => { + const expectedFiles = [ + { path: "src/component.tsx", type: "file", label: "component.tsx" }, + { path: "src/utils.ts", type: "file", label: "utils.ts" }, + ] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(expectedFiles) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, []) + expect(result).toEqual(expectedFiles) + + // Second call should return cached results + mockExecuteRipgrepForFiles.mockClear() + const cachedResult = await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() + expect(cachedResult).toEqual(expectedFiles) + }) + + it("should clear cache when workspace path changes", async () => { + // First fetch file list + const initialFiles = [{ path: "src/file1.ts", type: "file", label: "file1.ts" }] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(initialFiles) + await workspaceTracker.getRipgrepFileList() + + // Change workspace path + ;(getWorkspacePath as Mock).mockReturnValue("/test/new-workspace") + const newFiles = [{ path: "src/file2.ts", type: "file", label: "file2.ts" }] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(newFiles) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/new-workspace", 500000, []) + expect(result).toEqual(newFiles) + }) + + it("should handle ripgrep execution errors", async () => { + mockExecuteRipgrepForFiles.mockRejectedValueOnce(new Error("ripgrep execution failed")) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(result).toEqual([]) + }) + + it("should wait for ongoing ripgrep operations", async () => { + let resolveFirst: (value: any) => void + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve + }) + + mockExecuteRipgrepForFiles.mockImplementationOnce(() => firstPromise) + + // Start first operation + const firstCall = workspaceTracker.getRipgrepFileList() + + // Start second operation (should wait for first) + const secondCall = workspaceTracker.getRipgrepFileList() + + // Resolve first promise + const expectedFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] + resolveFirst!(expectedFiles) + + const [firstResult, secondResult] = await Promise.all([firstCall, secondCall]) + + expect(firstResult).toEqual(expectedFiles) + expect(secondResult).toEqual(expectedFiles) + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledTimes(1) + }) + + it("should return empty array when no workspace path", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(undefined) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(result).toEqual([]) + expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() + }) + }) + + describe("VSCode search configuration options", () => { + it("should generate correct ripgrep options based on useIgnoreFiles config", async () => { + const mockConfig = { + get: vitest.fn((key: string, defaultValue?: any) => { + if (key === "useIgnoreFiles") return false + return defaultValue ?? true + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore"]) + }) + + it("should generate correct ripgrep options based on useGlobalIgnoreFiles config", async () => { + const mockConfig = { + get: vitest.fn((key: string, defaultValue?: any) => { + switch (key) { + case "useIgnoreFiles": + return true + case "useGlobalIgnoreFiles": + return false + case "useParentIgnoreFiles": + return true + default: + return defaultValue + } + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore-global"]) + }) + + it("should generate correct ripgrep options based on useParentIgnoreFiles config", async () => { + const mockConfig = { + get: vitest.fn((key: string, defaultValue?: any) => { + switch (key) { + case "useIgnoreFiles": + return true + case "useGlobalIgnoreFiles": + return true + case "useParentIgnoreFiles": + return false + default: + return defaultValue + } + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore-parent"]) + }) + + it("should combine multiple ignore options", async () => { + const mockConfig = { + get: vitest.fn((key: string, defaultValue?: any) => { + switch (key) { + case "useIgnoreFiles": + return false + case "useGlobalIgnoreFiles": + return false + case "useParentIgnoreFiles": + return false + default: + return defaultValue + } + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore"]) + }) + }) + + describe("configuration change listeners", () => { + it("should clear ripgrep cache when search configuration changes", async () => { + // First get and cache file list + const initialFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(initialFiles) + await workspaceTracker.getRipgrepFileList() + + // Simulate configuration change event + const mockEvent = { + affectsConfiguration: vitest.fn((section: string) => section === "search.useIgnoreFiles"), + } + + registeredConfigChangeCallback!(mockEvent) + + // After configuration change should re-call executeRipgrepForFiles + const newFiles = [{ path: "src/newtest.ts", type: "file", label: "newtest.ts" }] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(newFiles) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledTimes(2) + expect(result).toEqual(newFiles) + }) + + it("should clear cache when useGlobalIgnoreFiles configuration changes", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + + const mockEvent = { + affectsConfiguration: vitest.fn((section: string) => section === "search.useGlobalIgnoreFiles"), + } + + registeredConfigChangeCallback!(mockEvent) + + // Next fetch should re-call executeRipgrepForFiles + mockExecuteRipgrepForFiles.mockClear() + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + }) + + it("should clear cache when useParentIgnoreFiles configuration changes", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + + const mockEvent = { + affectsConfiguration: vitest.fn((section: string) => section === "search.useParentIgnoreFiles"), + } + + registeredConfigChangeCallback!(mockEvent) + + // Next fetch should re-call executeRipgrepForFiles + mockExecuteRipgrepForFiles.mockClear() + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + }) + + it("should not clear cache for non-search related configuration changes", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + + const mockEvent = { + affectsConfiguration: vitest.fn((section: string) => section === "editor.fontSize"), + } + + registeredConfigChangeCallback!(mockEvent) + + // Next fetch should use cache, not re-call + mockExecuteRipgrepForFiles.mockClear() + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() + }) + }) + + describe("cache invalidation on file changes", () => { + it("should clear ripgrep cache when files are created", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + mockExecuteRipgrepForFiles.mockClear() + + // Trigger file creation event + const [[createCallback]] = mockOnDidCreate.mock.calls + await createCallback({ fsPath: "/test/workspace/newfile.ts" }) + + // Next fetch should re-call executeRipgrepForFiles + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + }) + + it("should clear ripgrep cache when files are deleted", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + mockExecuteRipgrepForFiles.mockClear() + + // Trigger file deletion event + const [[deleteCallback]] = mockOnDidDelete.mock.calls + await deleteCallback({ fsPath: "/test/workspace/oldfile.ts" }) + + // Next fetch should re-call executeRipgrepForFiles + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + }) + + it("should clear ripgrep cache when workspace resets", async () => { + // First get file list to establish cache + await workspaceTracker.getRipgrepFileList() + + // Change workspace path and trigger reset + ;(getWorkspacePath as Mock).mockReturnValue("/test/new-workspace") + await registeredTabChangeCallback!() + + // Advance reset timer + vitest.advanceTimersByTime(300) + + // Next fetch should use new workspace path + mockExecuteRipgrepForFiles.mockClear() + await workspaceTracker.getRipgrepFileList() + + expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/new-workspace", 500000, []) + }) + }) + + describe("file list preheating", () => { + it("should preheat ripgrep file list during initialization", async () => { + const mockFiles = [["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false] + ;(listFiles as Mock).mockResolvedValue(mockFiles) + + await workspaceTracker.initializeFilePaths() + + // Should have called getRipgrepFileList to preheat cache + expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + }) + }) + + describe("public getRipgrepFileList method", () => { + it("should be accessible as a public method", () => { + expect(typeof workspaceTracker.getRipgrepFileList).toBe("function") + }) + + it("should return a promise that resolves to FileResult array", async () => { + const expectedFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] + mockExecuteRipgrepForFiles.mockResolvedValueOnce(expectedFiles) + + const result = await workspaceTracker.getRipgrepFileList() + + expect(Array.isArray(result)).toBe(true) + expect(result).toEqual(expectedFiles) + }) + }) }) diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index a25dd4068f97..5dd1d3c7d0ec 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -88,11 +88,13 @@ export async function executeRipgrep({ export async function executeRipgrepForFiles( workspacePath: string, limit: number = 5000, -): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> { + extraOptions: string[] = [], +): Promise { const args = [ "--files", "--follow", "--hidden", + ...extraOptions, "-g", "!**/node_modules/**", "-g", @@ -110,11 +112,12 @@ export async function executeRipgrepForFiles( export async function searchWorkspaceFiles( query: string, workspacePath: string, + workspaceFiles: FileResult[], limit: number = 20, ): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> { try { - // Get all files and directories (from our modified function) - const allItems = await executeRipgrepForFiles(workspacePath, 5000) + // Use the provided workspace files + const allItems = workspaceFiles // If no query, just return the top items if (!query.trim()) { From f44c5102116d6cc991cdf730d41b2a7b11dd6a5d Mon Sep 17 00:00:00 2001 From: Naituw Date: Thu, 17 Jul 2025 00:42:46 +0800 Subject: [PATCH 2/4] tree based RipgrepResultCache implementation # Conflicts: # src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts --- .../workspace/RipgrepResultCache.ts | 357 ++++++ .../workspace/WorkspaceTracker.ts | 188 ++- .../__tests__/RipgrepResultCache.spec.ts | 1005 +++++++++++++++++ .../__tests__/WorkspaceTracker.spec.ts | 453 +++++--- src/package.json | 7 + src/package.nls.ca.json | 3 +- src/package.nls.de.json | 3 +- src/package.nls.es.json | 3 +- src/package.nls.fr.json | 3 +- src/package.nls.hi.json | 3 +- src/package.nls.id.json | 3 +- src/package.nls.it.json | 3 +- src/package.nls.ja.json | 3 +- src/package.nls.json | 3 +- src/package.nls.ko.json | 3 +- src/package.nls.nl.json | 3 +- src/package.nls.pl.json | 3 +- src/package.nls.pt-BR.json | 3 +- src/package.nls.ru.json | 3 +- src/package.nls.tr.json | 3 +- src/package.nls.vi.json | 3 +- src/package.nls.zh-CN.json | 3 +- src/package.nls.zh-TW.json | 3 +- src/services/search/file-search.ts | 72 +- 24 files changed, 1824 insertions(+), 312 deletions(-) create mode 100644 src/integrations/workspace/RipgrepResultCache.ts create mode 100644 src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts diff --git a/src/integrations/workspace/RipgrepResultCache.ts b/src/integrations/workspace/RipgrepResultCache.ts new file mode 100644 index 000000000000..c0df63746600 --- /dev/null +++ b/src/integrations/workspace/RipgrepResultCache.ts @@ -0,0 +1,357 @@ +import { spawn } from "child_process" +import { dirname, resolve as pathResolve, relative } from "path" + +// Simplified tree structure - files represented by true, directories by nested objects +export type SimpleTreeNode = { + [key: string]: true | SimpleTreeNode +} + +/** + * Ripgrep result cache class + * Provides file tree caching functionality with incremental updates + */ +export class RipgrepResultCache { + private rgPath: string + private _targetPath: string + private cachedTree: SimpleTreeNode | null = null + private invalidatedDirectories = new Set() + private rgArgs: string[] + private currentBuildPromise: Promise | null = null + private fileLimit: number + + constructor(rgPath: string, targetPath: string, rgArgs: string[] = [], fileLimit: number = 5000) { + this.rgPath = rgPath + this._targetPath = pathResolve(targetPath) + this.fileLimit = fileLimit + this.rgArgs = rgArgs.length > 0 ? rgArgs : ["--files"] + } + + get targetPath(): string { + return this._targetPath + } + + /** + * Asynchronously get file tree + * - If there's valid cache and no invalid directories, return cache + * - If currently building, wait for current build result + * - Otherwise trigger new build + */ + async getTree(): Promise { + // If there's valid cache, return directly + if (this.cachedTree && this.invalidatedDirectories.size === 0) { + return this.cachedTree + } + + // If already building, wait for current build result + if (this.currentBuildPromise) { + return this.currentBuildPromise + } + + // Start new build + try { + this.currentBuildPromise = this.buildTree() + const result = await this.currentBuildPromise + return result + } finally { + // Clear Promise cache after build completion + this.currentBuildPromise = null + } + } + + /** + * Internal method: build or update tree + */ + private async buildTree(): Promise { + try { + if (this.cachedTree && this.invalidatedDirectories.size > 0) { + // Has cache but has invalid directories, perform incremental update + await this.updateInvalidatedDirectories() + } else { + // No cache, complete rebuild + this.cachedTree = await this.buildTreeStreaming() + } + + // Clear invalid directory markers + this.invalidatedDirectories.clear() + return this.cachedTree + } catch (error) { + // Clear cache state on error + this.cachedTree = null + this.invalidatedDirectories.clear() + throw error + } + } + + /** + * Called when file is added + * Mark parent directory as invalid and remove corresponding subtree from tree + */ + fileAdded(filePath: string): void { + this.fileAddedOrRemoved(filePath) + } + + /** + * Called when file is removed + * Mark parent directory as invalid and remove corresponding subtree from tree + */ + fileRemoved(filePath: string): void { + this.fileAddedOrRemoved(filePath) + } + + private fileAddedOrRemoved(filePath: string): void { + const relativePath = relative(this._targetPath, pathResolve(this._targetPath, filePath)) + const parentDir = dirname(relativePath) + + if (parentDir !== "." && parentDir !== "") { + this.invalidateDirectory(parentDir) + } + } + + /** + * Mark directory as invalid + * Check containment relationship with existing invalid directories to avoid duplicate marking + */ + private invalidateDirectory(dirPath: string): void { + if (!this.cachedTree) { + return + } + + const normalizedPath = dirPath.replace(/\\/g, "/") + + // Check if already contained by larger scope invalid directory + for (const invalidDir of this.invalidatedDirectories) { + if (normalizedPath.startsWith(invalidDir + "/") || normalizedPath === invalidDir) { + // Current directory already contained in invalid directory, no need to mark again + return + } + } + + // Remove existing invalid directories contained by current directory + const toRemove: string[] = [] + for (const invalidDir of this.invalidatedDirectories) { + if (invalidDir.startsWith(normalizedPath + "/")) { + toRemove.push(invalidDir) + } + } + + // Remove contained invalid directories + for (const dir of toRemove) { + this.invalidatedDirectories.delete(dir) + } + + // Mark current directory as invalid + this.invalidatedDirectories.add(normalizedPath) + + // Remove corresponding subtree from cache tree + this.removeDirectoryFromTree(normalizedPath) + } + + /** + * Remove specified directory subtree from simplified tree + */ + private removeDirectoryFromTree(dirPath: string): void { + if (!this.cachedTree) { + return + } + + const pathParts = dirPath.split("/").filter(Boolean) + this.removeNodeByPath(this.cachedTree, pathParts, 0) + } + + /** + * Recursively remove simplified tree node + */ + private removeNodeByPath(tree: SimpleTreeNode, pathParts: string[], depth: number): boolean { + if (depth >= pathParts.length) { + return false + } + + const currentPart = pathParts[depth] + + if (!(currentPart in tree)) { + return false + } + + if (depth === pathParts.length - 1) { + // Found target node, remove it + delete tree[currentPart] + return true + } + + // Continue searching in child nodes + const childNode = tree[currentPart] + if (childNode !== true && typeof childNode === "object") { + const removed = this.removeNodeByPath(childNode, pathParts, depth + 1) + + // If child node is removed and current node is empty object, remove current node + if (removed && Object.keys(childNode).length === 0) { + delete tree[currentPart] + return true + } + } + + return false + } + + /** + * Update directories marked as invalid + * Use ripgrep's multi-path support to update all invalid directories at once + */ + private async updateInvalidatedDirectories(): Promise { + if (this.invalidatedDirectories.size === 0) { + return + } + + try { + // Stream build subtrees for all invalid directories (pass directory paths directly) + const invalidDirectories = Array.from(this.invalidatedDirectories) + const subtree = await this.buildTreeStreaming(invalidDirectories) + + // Merge subtrees into main tree (replace original invalid parts) + this.mergeInvalidatedSubtrees(subtree) + } catch (error) { + console.warn("Error updating invalid directories:", error) + // If incremental update fails, fallback to complete rebuild + this.cachedTree = await this.buildTreeStreaming() + } + } + + /** + * Unified streaming tree building method (simplified version, builds SimpleTreeNode) + * @param targetPaths Array of target paths to scan, scans entire targetPath when empty + */ + private async buildTreeStreaming(targetPaths: string[] = []): Promise { + return new Promise((resolve, reject) => { + // Build ripgrep arguments + const args = [...this.rgArgs] + + // If target paths specified, use relative paths directly (ripgrep supports multiple paths) + if (targetPaths.length > 0) { + args.push(...targetPaths) + } + + const child = spawn(this.rgPath, args, { + cwd: this._targetPath, + stdio: ["pipe", "pipe", "pipe"], + }) + + const tree: SimpleTreeNode = {} + let buffer = "" + let fileCount = 0 + + // Stream add file paths to simplified tree structure + const addFileToTree = (filePath: string) => { + // ripgrep output is already relative path, use directly + const parts = filePath.split("/").filter(Boolean) + let currentNode: SimpleTreeNode = tree + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isFile = i === parts.length - 1 // Last part is file + + if (isFile) { + // Files represented by true + currentNode[part] = true + fileCount++ + + // Check if file limit reached + if (fileCount >= this.fileLimit) { + child.kill() + return true // Indicate limit reached + } + } else { + // Directories represented by nested objects + if (!currentNode[part] || currentNode[part] === true) { + currentNode[part] = {} + } + currentNode = currentNode[part] as SimpleTreeNode + } + } + return false // Limit not reached + } + + child.stdout.on("data", (data: Buffer) => { + buffer += data.toString() + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine) { + const limitReached = addFileToTree(trimmedLine) + if (limitReached) { + break + } + } + } + }) + + let errorOutput = "" + + child.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString() + }) + + child.on("close", (code: number | null) => { + // Process final buffer content + if (buffer.trim() && fileCount < this.fileLimit) { + addFileToTree(buffer.trim()) + } + + if (errorOutput && Object.keys(tree).length === 0) { + reject(new Error(`ripgrep process error: ${errorOutput}`)) + } else { + resolve(tree) + } + }) + + child.on("error", (error: Error) => { + reject(error) + }) + }) + } + + /** + * Merge invalidated subtrees into main tree + * subtree already contains complete content of all invalid directories, merge directly + */ + private mergeInvalidatedSubtrees(subtree: SimpleTreeNode): void { + if (!this.cachedTree) { + this.cachedTree = subtree + return + } + + // Modify original object directly to avoid new object creation overhead + this.mergeSimpleTreeNodesInPlace(this.cachedTree, subtree) + } + + /** + * In-place merge two simplified tree nodes (optimized version, reduces object creation) + */ + private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void { + for (const [key, value] of Object.entries(newTree)) { + if (value === true) { + // New node is file, overwrite directly + existing[key] = true + } else { + // New node is directory + if (!existing[key] || existing[key] === true) { + // Original doesn't exist or is file, replace with directory directly + existing[key] = value + } else { + // Original is also directory, merge recursively + this.mergeSimpleTreeNodesInPlace(existing[key] as SimpleTreeNode, value) + } + } + } + } + + /** + * Clear cache + */ + clearCache(): void { + this.cachedTree = null + this.invalidatedDirectories.clear() + this.currentBuildPromise = null + } +} diff --git a/src/integrations/workspace/WorkspaceTracker.ts b/src/integrations/workspace/WorkspaceTracker.ts index 085be7c49abf..7901aa7bb22f 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -4,8 +4,9 @@ import * as path from "path" import { listFiles } from "../../services/glob/list-files" import { ClineProvider } from "../../core/webview/ClineProvider" import { toRelativePath, getWorkspacePath } from "../../utils/path" -import { executeRipgrepForFiles, FileResult } from "../../services/search/file-search" -import { isPathInIgnoredDirectory } from "../../services/glob/ignore-utils" +import { RipgrepResultCache, SimpleTreeNode } from "./RipgrepResultCache" +import { getBinPath } from "../../services/ripgrep" +import { FileResult } from "../../services/search/file-search" const MAX_INITIAL_FILES = 1_000 @@ -18,10 +19,9 @@ class WorkspaceTracker { private prevWorkSpacePath: string | undefined private resetTimer: NodeJS.Timeout | null = null - // Ripgrep cache related properties - private ripgrepFileCache: FileResult[] | null = null - private ripgrepCacheWorkspacePath: string | undefined = undefined - private ripgrepOperationPromise: Promise | null = null + // RipgrepResultCache related properties + private ripgrepCache: RipgrepResultCache | null = null + private cachedRipgrepPath: string | null = null get cwd() { return getWorkspacePath() @@ -32,89 +32,150 @@ class WorkspaceTracker { this.registerListeners() } + private getRipgrepFileLimit(): number { + const config = vscode.workspace.getConfiguration("roo-cline") + return Math.max(5000, config.get("maximumIndexedFilesForFileSearch", 200000)) + } + + private DIRS_IGNORED_BY_RIPGREP = ["node_modules", ".git", "out", "dist"] /** - * Get ripgrep extra options based on VSCode search configuration + * Get complete ripgrep arguments based on VSCode search configuration */ - private getRipgrepExtraOptions(): string[] { + private getRipgrepArgs(): string[] { const config = vscode.workspace.getConfiguration("search") - const extraOptions: string[] = [] + const args: string[] = ["--files", "--follow", "--hidden"] const useIgnoreFiles = config.get("useIgnoreFiles", true) if (!useIgnoreFiles) { - extraOptions.push("--no-ignore") + args.push("--no-ignore") } else { const useGlobalIgnoreFiles = config.get("useGlobalIgnoreFiles", true) const useParentIgnoreFiles = config.get("useParentIgnoreFiles", true) if (!useGlobalIgnoreFiles) { - extraOptions.push("--no-ignore-global") + args.push("--no-ignore-global") } if (!useParentIgnoreFiles) { - extraOptions.push("--no-ignore-parent") + args.push("--no-ignore-parent") } } - return extraOptions + // Add default exclude patterns + for (const dir of this.DIRS_IGNORED_BY_RIPGREP) { + args.push("-g", `!**/${dir}/**`) + } + return args + } + + private isPathIgnoredByRipgrep(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, "/") + for (const dir of this.DIRS_IGNORED_BY_RIPGREP) { + // Check if the directory appears in the path + if (normalizedPath.includes(`/${dir}/`)) { + return true + } + } + return false } /** - * Get comprehensive file list using ripgrep with caching - * This provides a more complete file list than the limited filePaths set + * Get comprehensive file tree using RipgrepResultCache + * This provides a more complete and efficient file structure than the limited filePaths set */ - async getRipgrepFileList(): Promise { + async getRipgrepFileTree(): Promise { const currentWorkspacePath = this.cwd if (!currentWorkspacePath) { - return [] + return {} + } + + // Check if we need to recreate the cache + await this.ensureRipgrepCache() + + if (!this.ripgrepCache) { + return {} } - // Return cached results if available and workspace hasn't changed - if (this.ripgrepFileCache && this.ripgrepCacheWorkspacePath === currentWorkspacePath) { - return this.ripgrepFileCache + try { + return await this.ripgrepCache.getTree() + } catch (error) { + return {} } + } + + async getRipgrepFileList(): Promise { + const tree = await this.getRipgrepFileTree() + return this.treeToFileResults(tree) + } - // If there's an ongoing operation, wait for it - if (this.ripgrepOperationPromise) { - try { - return await this.ripgrepOperationPromise - } catch (error) { - // If the ongoing operation failed, we'll start a new one below - this.ripgrepOperationPromise = null + /** + * Convert SimpleTreeNode to FileResult array + */ + private treeToFileResults(tree: SimpleTreeNode): FileResult[] { + const result: FileResult[] = [] + const stack: [SimpleTreeNode, string][] = [[tree, ""]] + + while (stack.length > 0) { + const [node, currentPath] = stack.pop()! + for (const key in node) { + const value = node[key] + const fullPath = currentPath ? `${currentPath}/${key}` : key + + if (value === true) { + result.push({ path: fullPath, type: "file" }) + } else { + result.push({ path: fullPath, type: "folder" }) + stack.push([value as SimpleTreeNode, fullPath]) + } } } - try { - // Start new operation and store the promise - this.ripgrepOperationPromise = executeRipgrepForFiles( + return result + } + + /** + * Ensure RipgrepResultCache is properly initialized + */ + private async ensureRipgrepCache(): Promise { + const currentWorkspacePath = this.cwd + if (!currentWorkspacePath) { + return + } + + const currentRipgrepPath = await this.getRipgrepPath() + + if (!this.ripgrepCache || this.ripgrepCache.targetPath !== currentWorkspacePath) { + this.ripgrepCache = new RipgrepResultCache( + currentRipgrepPath, currentWorkspacePath, - 500000, - this.getRipgrepExtraOptions(), + this.getRipgrepArgs(), + this.getRipgrepFileLimit(), ) - const fileResults = await this.ripgrepOperationPromise - - // Cache the results and clear the operation promise - this.ripgrepFileCache = fileResults - this.ripgrepCacheWorkspacePath = currentWorkspacePath - this.ripgrepOperationPromise = null + } + } - return fileResults - } catch (error) { - console.error("Error getting ripgrep file list:", error) - this.ripgrepOperationPromise = null - return [] + /** + * Get ripgrep binary path with caching + */ + private async getRipgrepPath(): Promise { + if (!this.cachedRipgrepPath) { + const rgPath = await getBinPath(vscode.env.appRoot) + if (!rgPath) { + throw new Error("Could not find ripgrep binary") + } + this.cachedRipgrepPath = rgPath } + return this.cachedRipgrepPath } /** - * Clear the ripgrep file cache - * Called when workspace changes or files are modified + * Helper method to compare arrays */ - private clearRipgrepCache() { - this.ripgrepFileCache = null - this.ripgrepCacheWorkspacePath = undefined - this.ripgrepOperationPromise = null + private arraysEqual(a: string[] | null, b: string[]): boolean { + if (!a) return false + return a.length === b.length && a.every((val, i) => val === b[i]) } async initializeFilePaths() { @@ -130,8 +191,8 @@ class WorkspaceTracker { files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file))) this.workspaceDidUpdate() - // preheat filelist - this.getRipgrepFileList() + // preheat file tree + this.getRipgrepFileTree() } private registerListeners() { @@ -139,10 +200,13 @@ class WorkspaceTracker { this.prevWorkSpacePath = this.cwd this.disposables.push( watcher.onDidCreate(async (uri) => { - if (!isPathInIgnoredDirectory(uri.fsPath)) { - this.clearRipgrepCache() + const fsPath = uri.fsPath + if (this.ripgrepCache) { + if (!this.isPathIgnoredByRipgrep(fsPath)) { + this.ripgrepCache.fileAdded(fsPath) + } } - await this.addFilePath(uri.fsPath) + await this.addFilePath(fsPath) this.workspaceDidUpdate() }), ) @@ -150,10 +214,13 @@ class WorkspaceTracker { // Renaming files triggers a delete and create event this.disposables.push( watcher.onDidDelete(async (uri) => { - if (!isPathInIgnoredDirectory(uri.fsPath)) { - this.clearRipgrepCache() + const fsPath = uri.fsPath + if (this.ripgrepCache) { + if (!this.isPathIgnoredByRipgrep(fsPath)) { + this.ripgrepCache.fileRemoved(fsPath) + } } - if (await this.removeFilePath(uri.fsPath)) { + if (await this.removeFilePath(fsPath)) { this.workspaceDidUpdate() } }), @@ -181,9 +248,10 @@ class WorkspaceTracker { if ( event.affectsConfiguration("search.useIgnoreFiles") || event.affectsConfiguration("search.useGlobalIgnoreFiles") || - event.affectsConfiguration("search.useParentIgnoreFiles") + event.affectsConfiguration("search.useParentIgnoreFiles") || + event.affectsConfiguration("roo-cline.maximumIndexedFilesForFileSearch") ) { - this.clearRipgrepCache() + this.ripgrepCache = null } }), ) @@ -214,7 +282,7 @@ class WorkspaceTracker { this.resetTimer = setTimeout(async () => { if (this.prevWorkSpacePath !== this.cwd) { // Clear cache when workspace changes - this.clearRipgrepCache() + this.ripgrepCache = null await this.providerRef.deref()?.postMessageToWebview({ type: "workspaceUpdated", filePaths: [], diff --git a/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts b/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts new file mode 100644 index 000000000000..c40294365133 --- /dev/null +++ b/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts @@ -0,0 +1,1005 @@ +import type { Mock } from "vitest" +import { RipgrepResultCache, type SimpleTreeNode } from "../RipgrepResultCache" +import { spawn } from "child_process" +import { EventEmitter } from "events" + +// Mock child_process spawn +vitest.mock("child_process", () => ({ + spawn: vitest.fn(), +})) + +// Mock process for testing +class MockChildProcess extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + killed = false + + kill() { + this.killed = true + this.emit("close", 0) + } +} + +describe("RipgrepResultCache", () => { + let mockSpawn: Mock + let mockChildProcess: MockChildProcess + + beforeEach(() => { + vitest.clearAllMocks() + mockChildProcess = new MockChildProcess() + mockSpawn = spawn as Mock + mockSpawn.mockReturnValue(mockChildProcess) + }) + + describe("constructor", () => { + it("should initialize with default parameters", () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + expect(cache.targetPath).toBe("/test/workspace") + }) + + it("should resolve target path", () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "test/workspace") + + expect(cache.targetPath).toContain("test/workspace") + }) + + it("should use custom file limit", () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 10000) + + expect(cache.targetPath).toBe("/test/workspace") + expect((cache as any).fileLimit).toBe(10000) + }) + }) + + describe("getTree", () => { + it("should build tree on first call", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Simulate successful ripgrep output + const treePromise = cache.getTree() + + // Simulate ripgrep output + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", ["--files"], { + cwd: "/test/workspace", + stdio: ["pipe", "pipe", "pipe"], + }) + expect(result).toEqual({ + src: { + "file1.ts": true, + "file2.ts": true, + }, + }) + }) + + it("should return cached tree on subsequent calls", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // First call + const firstPromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + await firstPromise + + // Second call should not spawn new process + mockSpawn.mockClear() + const secondResult = await cache.getTree() + + expect(mockSpawn).not.toHaveBeenCalled() + expect(secondResult).toEqual({ + src: { + "file1.ts": true, + }, + }) + }) + + it("should wait for ongoing build if already building", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Start first build + const firstPromise = cache.getTree() + + // Start second build immediately + const secondPromise = cache.getTree() + + // Complete the build + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise]) + + // Should only spawn once + expect(mockSpawn).toHaveBeenCalledTimes(1) + expect(firstResult).toEqual(secondResult) + }) + + it("should handle ripgrep errors gracefully", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + + setTimeout(() => { + mockChildProcess.stderr.emit("data", Buffer.from("ripgrep error")) + mockChildProcess.emit("close", 1) // Error exit code + }, 10) + + await expect(treePromise).rejects.toThrow() + }) + + it("should respect file limit", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 2) + + const treePromise = cache.getTree() + + setTimeout(() => { + // Send more files than the limit + mockChildProcess.stdout.emit("data", Buffer.from("file1.ts\nfile2.ts\nfile3.ts\nfile4.ts\n")) + // Process should be killed when limit reached + expect(mockChildProcess.killed).toBe(true) + }, 10) + + const result = await treePromise + + // Should only have 2 files due to limit + const fileCount = Object.keys(result).length + expect(fileCount).toBeLessThanOrEqual(2) + }) + }) + + describe("file change notifications", () => { + let cache: RipgrepResultCache + + beforeEach(async () => { + cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Build initial tree + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/utils/helper.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + await treePromise + }) + + it("should mark directory as invalid when file is added", async () => { + // Clear spawn mock to detect new calls + mockSpawn.mockClear() + + cache.fileAdded("/test/workspace/src/newfile.ts") + + // Next getTree call should trigger rebuild + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/newfile.ts\nsrc/utils/helper.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + // Should have rebuilt the tree + expect(mockSpawn).toHaveBeenCalled() + }) + + it("should mark directory as invalid when file is removed", async () => { + // Clear spawn mock to detect new calls + mockSpawn.mockClear() + + cache.fileRemoved("/test/workspace/src/file1.ts") + + // Next getTree call should trigger rebuild + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/utils/helper.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + // Should have rebuilt the tree + expect(mockSpawn).toHaveBeenCalled() + }) + + it("should handle relative file paths correctly", async () => { + mockSpawn.mockClear() + + // Test with relative path + cache.fileAdded("src/relative.ts") + + // Next getTree call should trigger rebuild + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/relative.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + }) + + describe("incremental updates", () => { + let cache: RipgrepResultCache + + beforeEach(async () => { + cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Build initial tree + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from("src/file1.ts\nsrc/components/Button.tsx\nutils/helper.ts\n"), + ) + mockChildProcess.emit("close", 0) + }, 10) + await treePromise + }) + + it("should perform incremental update for invalidated directories", async () => { + // Add file to invalidate src directory + cache.fileAdded("/test/workspace/src/newfile.ts") + mockSpawn.mockClear() + + // Mock incremental update response (only for src directory) + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from("src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\n"), + ) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "file1.ts": true, + "newfile.ts": true, + components: { + "Button.tsx": true, + }, + }, + utils: { + "helper.ts": true, + }, + }) + }) + + it("should handle nested directory invalidation correctly", async () => { + // Add file to nested directory + cache.fileAdded("/test/workspace/src/components/Icon.tsx") + mockSpawn.mockClear() + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from("src/components/Button.tsx\nsrc/components/Icon.tsx\n"), + ) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + + it("should avoid duplicate invalidation for parent directories", async () => { + // First invalidate parent directory + cache.fileAdded("/test/workspace/src/newfile.ts") + + // Then try to invalidate child directory (should be ignored) + cache.fileAdded("/test/workspace/src/components/NewComponent.tsx") + + mockSpawn.mockClear() + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from( + "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nsrc/components/NewComponent.tsx\n", + ), + ) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + // Should have called spawn for incremental update + expect(mockSpawn).toHaveBeenCalled() + }) + + it("should handle multiple directory invalidations in single incremental update", async () => { + // Invalidate multiple independent directories + cache.fileAdded("/test/workspace/src/newfile.ts") + cache.fileAdded("/test/workspace/utils/newutil.ts") + cache.fileAdded("/test/workspace/lib/helper.ts") + + mockSpawn.mockClear() + + // Mock incremental update response for all invalidated directories + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from( + "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nutils/helper.ts\nutils/newutil.ts\nlib/helper.ts\n", + ), + ) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + // Should have called spawn for incremental update + expect(mockSpawn).toHaveBeenCalled() + + // Verify all directories are updated correctly + expect(result).toEqual({ + src: { + "file1.ts": true, + "newfile.ts": true, + components: { + "Button.tsx": true, + }, + }, + utils: { + "helper.ts": true, + "newutil.ts": true, + }, + lib: { + "helper.ts": true, + }, + }) + }) + + it("should handle multiple nested directory invalidations with parent-child relationships", async () => { + // Invalidate multiple directories with parent-child relationships + cache.fileAdded("/test/workspace/src/newfile.ts") // Parent directory + cache.fileAdded("/test/workspace/src/components/NewComponent.tsx") // Child directory + cache.fileAdded("/test/workspace/src/components/ui/Button.tsx") // Grandchild directory + cache.fileAdded("/test/workspace/utils/newutil.ts") // Independent directory + + mockSpawn.mockClear() + + // Mock incremental update response for all invalidated directories + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from( + "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nsrc/components/NewComponent.tsx\nsrc/components/ui/Button.tsx\nutils/helper.ts\nutils/newutil.ts\n", + ), + ) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + // Should have called spawn for incremental update + expect(mockSpawn).toHaveBeenCalled() + + // Verify all directories are updated correctly with proper nesting + expect(result).toEqual({ + src: { + "file1.ts": true, + "newfile.ts": true, + components: { + "Button.tsx": true, + "NewComponent.tsx": true, + ui: { + "Button.tsx": true, + }, + }, + }, + utils: { + "helper.ts": true, + "newutil.ts": true, + }, + }) + }) + + it("should trigger only one ripgrep call when multiple directories are invalidated", async () => { + // Invalidate multiple directories in sequence + cache.fileAdded("/test/workspace/src/newfile.ts") + cache.fileAdded("/test/workspace/utils/newutil.ts") + cache.fileAdded("/test/workspace/lib/helper.ts") + cache.fileAdded("/test/workspace/tests/test.ts") + + mockSpawn.mockClear() + + // Call getTree - should trigger only one ripgrep process + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit( + "data", + Buffer.from( + "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nutils/helper.ts\nutils/newutil.ts\nlib/helper.ts\ntests/test.ts\n", + ), + ) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + // Should have called spawn exactly once for all invalidations + expect(mockSpawn).toHaveBeenCalledTimes(1) + }) + }) + + describe("ripgrep arguments", () => { + it("should use custom ripgrep arguments", async () => { + const customArgs = ["--files", "--follow", "--hidden", "--no-ignore"] + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", customArgs) + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", customArgs, { + cwd: "/test/workspace", + stdio: ["pipe", "pipe", "pipe"], + }) + }) + + it("should use default --files argument when no args provided", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", []) + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", ["--files"], { + cwd: "/test/workspace", + stdio: ["pipe", "pipe", "pipe"], + }) + }) + }) + + describe("clearCache", () => { + it("should clear all cached data", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Build initial cache + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + await treePromise + + // Clear cache + cache.clearCache() + + // Next call should rebuild + mockSpawn.mockClear() + const newTreePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await newTreePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + }) + + describe("edge cases", () => { + it("should handle empty ripgrep output", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({}) + }) + + it("should handle ripgrep output with empty lines", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n\n\nsrc/file2.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "file1.ts": true, + "file2.ts": true, + }, + }) + }) + + it("should handle file paths with special characters", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file-with-dash.ts\nsrc/file with spaces.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "file-with-dash.ts": true, + "file with spaces.ts": true, + }, + }) + }) + + it("should handle deeply nested directory structures", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("a/b/c/d/e/f/deep.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + a: { + b: { + c: { + d: { + e: { + f: { + "deep.ts": true, + }, + }, + }, + }, + }, + }, + }) + }) + + it("should handle mixed files and directories with same names", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/utils.ts\nsrc/utils/helper.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "utils.ts": true, + utils: { + "helper.ts": true, + }, + }, + }) + }) + }) + + describe("streaming buffer handling", () => { + it("should handle partial data chunks correctly", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + // Send data in partial chunks + mockChildProcess.stdout.emit("data", Buffer.from("src/fi")) + mockChildProcess.stdout.emit("data", Buffer.from("le1.ts\nsrc/file")) + mockChildProcess.stdout.emit("data", Buffer.from("2.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "file1.ts": true, + "file2.ts": true, + }, + }) + }) + + it("should handle final buffer content without newline", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + const treePromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts")) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + expect(result).toEqual({ + src: { + "file1.ts": true, + "file2.ts": true, + }, + }) + }) + }) + + describe("concurrent ripgrep calls", () => { + beforeEach(() => { + vitest.clearAllMocks() + mockChildProcess = new MockChildProcess() + mockSpawn = spawn as Mock + }) + + it("should handle multiple concurrent successful calls", async () => { + mockSpawn.mockReturnValue(mockChildProcess) + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Start multiple concurrent getTree calls + const promise1 = cache.getTree() + const promise2 = cache.getTree() + const promise3 = cache.getTree() + + // All should wait for the same build + expect(mockSpawn).toHaveBeenCalledTimes(1) + + // Simulate successful ripgrep output + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts\nlib/utils.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]) + + // All should return the same result + const expectedResult = { + src: { + "file1.ts": true, + "file2.ts": true, + }, + lib: { + "utils.ts": true, + }, + } + + expect(result1).toEqual(expectedResult) + expect(result2).toEqual(expectedResult) + expect(result3).toEqual(expectedResult) + + // Only one spawn call should have been made + expect(mockSpawn).toHaveBeenCalledTimes(1) + }) + + it("should handle multiple concurrent calls when first fails", async () => { + mockSpawn.mockReturnValue(mockChildProcess) + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Start multiple concurrent getTree calls + const promise1 = cache.getTree() + const promise2 = cache.getTree() + const promise3 = cache.getTree() + + // Simulate ripgrep error + setTimeout(() => { + mockChildProcess.stderr.emit("data", Buffer.from("Permission denied")) + mockChildProcess.emit("close", 1) // Error exit code + }, 10) + + // All should reject with the same error + await expect(promise1).rejects.toThrow() + await expect(promise2).rejects.toThrow() + await expect(promise3).rejects.toThrow() + + // Only one spawn call should have been made + expect(mockSpawn).toHaveBeenCalledTimes(1) + + // After error, subsequent calls should trigger new builds + mockSpawn.mockClear() + mockChildProcess = new MockChildProcess() + mockSpawn.mockReturnValue(mockChildProcess) + + const retryPromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + await retryPromise + expect(mockSpawn).toHaveBeenCalledTimes(1) + }) + + it("should handle concurrent calls after invalidation", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + + // Build initial cache + mockSpawn.mockReturnValue(mockChildProcess) + const initialPromise = cache.getTree() + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + await initialPromise + + // Clear spawn mock and create new process for subsequent calls + mockSpawn.mockClear() + mockChildProcess = new MockChildProcess() + mockSpawn.mockReturnValue(mockChildProcess) + + // Invalidate cache + cache.fileAdded("/test/workspace/src/newfile.ts") + + // Start multiple concurrent calls after invalidation + const promise1 = cache.getTree() + const promise2 = cache.getTree() + const promise3 = cache.getTree() + + // Should trigger only one rebuild + expect(mockSpawn).toHaveBeenCalledTimes(1) + + setTimeout(() => { + mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/newfile.ts\n")) + mockChildProcess.emit("close", 0) + }, 10) + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]) + + const expectedResult = { + src: { + "file1.ts": true, + "newfile.ts": true, + }, + } + + expect(result1).toEqual(expectedResult) + expect(result2).toEqual(expectedResult) + expect(result3).toEqual(expectedResult) + }) + }) + + describe("large file set performance and memory", () => { + it("should handle 500k files efficiently", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 500000) + + mockSpawn.mockReturnValue(mockChildProcess) + + const treePromise = cache.getTree() + + setTimeout(async () => { + // Generate 500k file paths with varying depth + const generateLargeFileSet = () => { + const files: string[] = [] + const dirs = ["src", "lib", "components", "utils", "services", "types", "hooks", "pages"] + const subdirs = ["common", "shared", "specific", "custom", "base", "core"] + const fileTypes = [".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte"] + + for (let i = 0; i < 500000; i++) { + const dir = dirs[i % dirs.length] + const subdir = subdirs[i % subdirs.length] + const fileType = fileTypes[i % fileTypes.length] + const fileName = `file${i}${fileType}` + files.push(`${dir}/${subdir}/${fileName}`) + } + return files + } + + const files = generateLargeFileSet() + const fileData = files.join("\n") + "\n" + + // Emit data in chunks to simulate real ripgrep behavior + const chunkSize = 10000 + for (let i = 0; i < fileData.length; i += chunkSize) { + const chunk = fileData.slice(i, i + chunkSize) + mockChildProcess.stdout.emit("data", Buffer.from(chunk)) + } + + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + let memoryBeforeBuild = process.memoryUsage() + + let copy = JSON.parse(JSON.stringify(result)) + + let memoryAfterBuild = process.memoryUsage() + + // Verify tree structure + expect(Object.keys(result)).toHaveLength(8) // 8 main directories + + // Calculate memory increase (approximate) + const memoryIncrease = memoryAfterBuild.heapUsed - memoryBeforeBuild.heapUsed + const memoryIncreaseInMB = memoryIncrease / (1024 * 1024) + + // Memory should be reasonable for 500k files (should be less than 80MB) + expect(memoryIncreaseInMB).toBeLessThan(80) + + // Verify tree contains expected number of files + const countFilesInTree = (node: any): number => { + let count = 0 + for (const key in node) { + if (node[key] === true) { + count++ + } else if (typeof node[key] === "object") { + count += countFilesInTree(node[key]) + } + } + return count + } + + const totalFiles = countFilesInTree(result) + expect(totalFiles).toBe(500000) + }) + + it("should handle extreme case with deep nesting", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 10000) + + mockSpawn.mockReturnValue(mockChildProcess) + + const treePromise = cache.getTree() + + setTimeout(() => { + // Create extremely deep nesting paths that would create large tree structures + const deepPaths = [] + + // Case 1: Very deep directory nesting (100 levels deep) + for (let i = 0; i < 50; i++) { + const deepPath = Array.from({ length: 100 }, (_, j) => `level${j}`).join("/") + `/file${i}.ts` + deepPaths.push(deepPath) + } + + // Case 2: Many sibling directories at same level + for (let i = 0; i < 1000; i++) { + deepPaths.push(`dir${i}/file.ts`) + } + + // Case 3: Factorial explosion (each level has more directories) + for (let level = 0; level < 5; level++) { + for (let branch = 0; branch < Math.pow(10, level); branch++) { + const path = + Array.from({ length: level + 1 }, (_, i) => `l${i}d${branch % Math.pow(10, i + 1)}`).join( + "/", + ) + "/file.ts" + deepPaths.push(path) + } + } + + const fileData = deepPaths.join("\n") + "\n" + mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + // Verify the tree structure handles deep nesting + const getMaxDepth = (node: any, currentDepth = 0): number => { + let maxDepth = currentDepth + for (const key in node) { + if (typeof node[key] === "object" && node[key] !== true) { + const depth = getMaxDepth(node[key], currentDepth + 1) + maxDepth = Math.max(maxDepth, depth) + } + } + return maxDepth + } + + const maxDepth = getMaxDepth(result) + expect(maxDepth).toBeGreaterThan(50) // Should handle deep nesting + + // Verify we have the expected wide structure + expect(Object.keys(result).length).toBeGreaterThan(100) // Many top-level directories + }) + + it("should respect file limit and stop processing when reached", async () => { + const fileLimit = 1000 + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], fileLimit) + + mockSpawn.mockReturnValue(mockChildProcess) + + const treePromise = cache.getTree() + + setTimeout(() => { + // Generate more files than the limit + const files = Array.from({ length: 2000 }, (_, i) => `file${i}.ts`) + const fileData = files.join("\n") + "\n" + + // Emit all data at once + mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + + // Process should be killed when limit is reached + expect(mockChildProcess.killed).toBe(true) + }, 10) + + const result = await treePromise + + // Count total files in result + const countFiles = (node: any): number => { + let count = 0 + for (const key in node) { + if (node[key] === true) { + count++ + } else if (typeof node[key] === "object") { + count += countFiles(node[key]) + } + } + return count + } + + const totalFiles = countFiles(result) + expect(totalFiles).toBeLessThanOrEqual(fileLimit) + }) + + it("should handle memory-intensive file name patterns", async () => { + const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 50000) + + mockSpawn.mockReturnValue(mockChildProcess) + + const treePromise = cache.getTree() + + setTimeout(() => { + const memoryIntensivePaths = [] + + // Case 1: Very long file names + for (let i = 0; i < 1000; i++) { + const longFileName = "a".repeat(200) + i + ".ts" + memoryIntensivePaths.push(`long-names/${longFileName}`) + } + + // Case 2: Many files with common prefixes (creates large branching factor) + for (let i = 0; i < 10000; i++) { + const fileName = `component-${i.toString().padStart(10, "0")}.tsx` + memoryIntensivePaths.push(`components/${fileName}`) + } + + // Case 3: Unicode and special characters + const specialChars = ["测试", "файл", "ファイル", "αρχείο", "파일"] + for (let i = 0; i < 1000; i++) { + const char = specialChars[i % specialChars.length] + memoryIntensivePaths.push(`unicode/${char}${i}.ts`) + } + + const fileData = memoryIntensivePaths.join("\n") + "\n" + mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + mockChildProcess.emit("close", 0) + }, 10) + + const result = await treePromise + + // Verify structure integrity + expect(result["long-names"]).toBeDefined() + expect(result["components"]).toBeDefined() + expect(result["unicode"]).toBeDefined() + + // Verify we can handle special characters + expect(typeof result["unicode"]).toBe("object") + expect(Object.keys(result["unicode"] as any).length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts index 42cf8b4e94da..3dfb0e9aabf7 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts @@ -4,7 +4,7 @@ import WorkspaceTracker from "../WorkspaceTracker" import { ClineProvider } from "../../../core/webview/ClineProvider" import { listFiles } from "../../../services/glob/list-files" import { getWorkspacePath } from "../../../utils/path" -import { executeRipgrepForFiles } from "../../../services/search/file-search" +import { RipgrepResultCache } from "../RipgrepResultCache" // Mock functions - must be defined before vitest.mock calls const mockOnDidCreate = vitest.fn() @@ -29,16 +29,26 @@ vitest.mock("../../../utils/path", () => ({ }), })) -// Mock executeRipgrepForFiles function -vitest.mock("../../../services/search/file-search", () => ({ - executeRipgrepForFiles: vitest.fn(), -})) - // Mock ignore utils vitest.mock("../../../services/glob/ignore-utils", () => ({ isPathInIgnoredDirectory: vitest.fn().mockReturnValue(false), })) +// Mock RipgrepResultCache +vitest.mock("../RipgrepResultCache", () => ({ + RipgrepResultCache: vitest.fn().mockImplementation(() => ({ + getTree: vitest.fn().mockResolvedValue({}), + fileAdded: vitest.fn(), + fileRemoved: vitest.fn(), + targetPath: "/test/workspace", + })), +})) + +// Mock ripgrep getBinPath +vitest.mock("../../../services/ripgrep", () => ({ + getBinPath: vitest.fn().mockResolvedValue("/usr/local/bin/rg"), +})) + // Mock watcher - must be defined after mockDispose but before vitest.mock("vscode") const mockWatcher = { onDidCreate: mockOnDidCreate.mockReturnValue({ dispose: mockDispose }), @@ -58,6 +68,9 @@ vitest.mock("vscode", () => ({ }, onDidChangeActiveTextEditor: vitest.fn(() => ({ dispose: vitest.fn() })), }, + env: { + appRoot: "/test/vscode", + }, workspace: { workspaceFolders: [ { @@ -86,7 +99,6 @@ vitest.mock("../../../services/glob/list-files", () => ({ describe("WorkspaceTracker", () => { let workspaceTracker: WorkspaceTracker let mockProvider: ClineProvider - let mockExecuteRipgrepForFiles: Mock beforeEach(() => { vitest.clearAllMocks() @@ -99,13 +111,6 @@ describe("WorkspaceTracker", () => { // Reset workspace path mock ;(getWorkspacePath as Mock).mockReturnValue("/test/workspace") - // Setup executeRipgrepForFiles mock - mockExecuteRipgrepForFiles = executeRipgrepForFiles as Mock - mockExecuteRipgrepForFiles.mockResolvedValue([ - { path: "src/test1.ts", type: "file", label: "test1.ts" }, - { path: "src/test2.ts", type: "file", label: "test2.ts" }, - ]) - // Setup workspace configuration mock const mockConfig = { get: vitest.fn((key: string, defaultValue?: any) => { @@ -116,6 +121,8 @@ describe("WorkspaceTracker", () => { return true case "useParentIgnoreFiles": return true + case "maximumIndexedFilesForFileSearch": + return 200000 default: return defaultValue } @@ -394,88 +401,96 @@ describe("WorkspaceTracker", () => { expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - describe("ripgrep file list caching", () => { - it("should fetch and cache ripgrep file list", async () => { - const expectedFiles = [ - { path: "src/component.tsx", type: "file", label: "component.tsx" }, - { path: "src/utils.ts", type: "file", label: "utils.ts" }, - ] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(expectedFiles) - - const result = await workspaceTracker.getRipgrepFileList() - - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, []) - expect(result).toEqual(expectedFiles) - - // Second call should return cached results - mockExecuteRipgrepForFiles.mockClear() - const cachedResult = await workspaceTracker.getRipgrepFileList() + describe("RipgrepCache integration", () => { + let mockRipgrepCache: any + let createCallback: any + let deleteCallback: any + + beforeEach(async () => { + // Setup mock before creating WorkspaceTracker + mockRipgrepCache = { + getTree: vitest.fn().mockResolvedValue({}), + fileAdded: vitest.fn(), + fileRemoved: vitest.fn(), + targetPath: "/test/workspace", + isMock: true, + } + ;(RipgrepResultCache as Mock).mockImplementation(() => mockRipgrepCache) - expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() - expect(cachedResult).toEqual(expectedFiles) + // Get the callbacks before clearing mocks + createCallback = mockOnDidCreate.mock.calls[0][0] + deleteCallback = mockOnDidDelete.mock.calls[0][0] }) - it("should clear cache when workspace path changes", async () => { - // First fetch file list - const initialFiles = [{ path: "src/file1.ts", type: "file", label: "file1.ts" }] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(initialFiles) - await workspaceTracker.getRipgrepFileList() + it("should provide getRipgrepFileTree method", () => { + expect(typeof workspaceTracker.getRipgrepFileTree).toBe("function") + }) - // Change workspace path - ;(getWorkspacePath as Mock).mockReturnValue("/test/new-workspace") - const newFiles = [{ path: "src/file2.ts", type: "file", label: "file2.ts" }] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(newFiles) + it("should return empty tree when no workspace path", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(undefined) - const result = await workspaceTracker.getRipgrepFileList() + const result = await workspaceTracker.getRipgrepFileTree() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/new-workspace", 500000, []) - expect(result).toEqual(newFiles) + expect(result).toEqual({}) }) - it("should handle ripgrep execution errors", async () => { - mockExecuteRipgrepForFiles.mockRejectedValueOnce(new Error("ripgrep execution failed")) + it("should notify ripgrep cache when non-ignored file is added", async () => { + const testPath = "/test/workspace/src/file.ts" - const result = await workspaceTracker.getRipgrepFileList() + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache - expect(result).toEqual([]) + await createCallback({ fsPath: testPath }) + + // Verify the cache method was called + expect(mockRipgrepCache.fileAdded).toHaveBeenCalledWith(testPath) }) - it("should wait for ongoing ripgrep operations", async () => { - let resolveFirst: (value: any) => void - const firstPromise = new Promise((resolve) => { - resolveFirst = resolve - }) + it("should not notify ripgrep cache when ignored file is added", async () => { + const testPath = "/test/workspace/node_modules/file.ts" + + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache - mockExecuteRipgrepForFiles.mockImplementationOnce(() => firstPromise) + await createCallback({ fsPath: testPath }) - // Start first operation - const firstCall = workspaceTracker.getRipgrepFileList() + // Since the file is ignored, fileAdded should not be called + expect(mockRipgrepCache.fileAdded).not.toHaveBeenCalled() + }) - // Start second operation (should wait for first) - const secondCall = workspaceTracker.getRipgrepFileList() + it("should notify ripgrep cache when non-ignored file is deleted", async () => { + const testPath = "/test/workspace/src/file.ts" - // Resolve first promise - const expectedFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] - resolveFirst!(expectedFiles) + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache - const [firstResult, secondResult] = await Promise.all([firstCall, secondCall]) + await deleteCallback({ fsPath: testPath }) - expect(firstResult).toEqual(expectedFiles) - expect(secondResult).toEqual(expectedFiles) - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledTimes(1) + // Verify the cache method was called + expect(mockRipgrepCache.fileRemoved).toHaveBeenCalledWith(testPath) }) - it("should return empty array when no workspace path", async () => { - ;(getWorkspacePath as Mock).mockReturnValue(undefined) + it("should not notify ripgrep cache when ignored file is deleted", async () => { + const testPath = "/test/workspace/node_modules/file.ts" + + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache - const result = await workspaceTracker.getRipgrepFileList() + await deleteCallback({ fsPath: testPath }) - expect(result).toEqual([]) - expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() + // Since the file is ignored, fileRemoved should not be called + expect(mockRipgrepCache.fileRemoved).not.toHaveBeenCalled() }) }) - describe("VSCode search configuration options", () => { + describe("VSCode configuration support", () => { + beforeEach(async () => { + // Setup mock before creating WorkspaceTracker + const mockRipgrepCache = { + getTree: vitest.fn().mockResolvedValue({}), + fileAdded: vitest.fn(), + fileRemoved: vitest.fn(), + targetPath: "/test/workspace", + } + ;(RipgrepResultCache as Mock).mockImplementation(() => mockRipgrepCache) + }) + it("should generate correct ripgrep options based on useIgnoreFiles config", async () => { const mockConfig = { get: vitest.fn((key: string, defaultValue?: any) => { @@ -485,9 +500,9 @@ describe("WorkspaceTracker", () => { } ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) - await workspaceTracker.getRipgrepFileList() + let args = await (workspaceTracker as any).getRipgrepArgs() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore"]) + expect(args.includes("--no-ignore")).toBe(true) }) it("should generate correct ripgrep options based on useGlobalIgnoreFiles config", async () => { @@ -507,9 +522,9 @@ describe("WorkspaceTracker", () => { } ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) - await workspaceTracker.getRipgrepFileList() + let args = await (workspaceTracker as any).getRipgrepArgs() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore-global"]) + expect(args.includes("--no-ignore-global")).toBe(true) }) it("should generate correct ripgrep options based on useParentIgnoreFiles config", async () => { @@ -529,9 +544,9 @@ describe("WorkspaceTracker", () => { } ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) - await workspaceTracker.getRipgrepFileList() + let args = await (workspaceTracker as any).getRipgrepArgs() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore-parent"]) + expect(args.includes("--no-ignore-parent")).toBe(true) }) it("should combine multiple ignore options", async () => { @@ -551,163 +566,247 @@ describe("WorkspaceTracker", () => { } ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) - await workspaceTracker.getRipgrepFileList() + let args = await (workspaceTracker as any).getRipgrepArgs() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/workspace", 500000, ["--no-ignore"]) + expect(args.includes("--no-ignore")).toBe(true) }) - }) - describe("configuration change listeners", () => { - it("should clear ripgrep cache when search configuration changes", async () => { - // First get and cache file list - const initialFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(initialFiles) - await workspaceTracker.getRipgrepFileList() + it("should clear ripgrep cache when roo-cline configuration changes", async () => { + // Initialize cache by calling getRipgrepFileTree to trigger cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) - // Simulate configuration change event + // Simulate configuration change event for roo-cline config const mockEvent = { - affectsConfiguration: vitest.fn((section: string) => section === "search.useIgnoreFiles"), + affectsConfiguration: vitest.fn( + (section: string) => section === "roo-cline.maximumIndexedFilesForFileSearch", + ), } + // This should trigger cache clearing registeredConfigChangeCallback!(mockEvent) - // After configuration change should re-call executeRipgrepForFiles - const newFiles = [{ path: "src/newtest.ts", type: "file", label: "newtest.ts" }] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(newFiles) + // Verify all related configurations were checked + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useGlobalIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useParentIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("roo-cline.maximumIndexedFilesForFileSearch") - const result = await workspaceTracker.getRipgrepFileList() - - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledTimes(2) - expect(result).toEqual(newFiles) + // Call getRipgrepFileTree again to trigger new cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) }) - it("should clear cache when useGlobalIgnoreFiles configuration changes", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() + it("should clear ripgrep cache when search configuration changes", async () => { + // Initialize cache by calling getRipgrepFileTree to trigger cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) + // Simulate configuration change event for search config + // Note: the OR condition will short-circuit on the first true result const mockEvent = { - affectsConfiguration: vitest.fn((section: string) => section === "search.useGlobalIgnoreFiles"), + affectsConfiguration: vitest.fn((section: string) => section === "search.useIgnoreFiles"), } + // This should trigger cache clearing registeredConfigChangeCallback!(mockEvent) - // Next fetch should re-call executeRipgrepForFiles - mockExecuteRipgrepForFiles.mockClear() - await workspaceTracker.getRipgrepFileList() + // Since OR conditions short-circuit, only the first matching config is checked + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useIgnoreFiles") + // The following calls won't happen due to short-circuit evaluation + // expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useGlobalIgnoreFiles") + // expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useParentIgnoreFiles") + // expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("roo-cline.maximumIndexedFilesForFileSearch") - expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + // Call getRipgrepFileTree again to trigger new cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) }) - it("should clear cache when useParentIgnoreFiles configuration changes", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() + it("should not clear cache for non-search related configuration changes", async () => { + // Initialize cache by calling getRipgrepFileTree to trigger cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) + // Simulate configuration change event for non-search config const mockEvent = { - affectsConfiguration: vitest.fn((section: string) => section === "search.useParentIgnoreFiles"), + affectsConfiguration: vitest.fn((section: string) => section === "editor.fontSize"), } + // This should not trigger cache clearing registeredConfigChangeCallback!(mockEvent) - // Next fetch should re-call executeRipgrepForFiles - mockExecuteRipgrepForFiles.mockClear() - await workspaceTracker.getRipgrepFileList() + // All related configurations should be checked since none match + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useGlobalIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("search.useParentIgnoreFiles") + expect(mockEvent.affectsConfiguration).toHaveBeenCalledWith("roo-cline.maximumIndexedFilesForFileSearch") - expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + // Call getRipgrepFileTree again - should not create new cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) // No additional calls }) - it("should not clear cache for non-search related configuration changes", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() - - const mockEvent = { - affectsConfiguration: vitest.fn((section: string) => section === "editor.fontSize"), + it("should handle configuration change during ripgrep tree building", async () => { + let triggersConfigChange = true + + const mockDelayedRipgrepCache = { + getTree: vitest.fn().mockImplementation(() => { + if (triggersConfigChange) { + // Trigger configuration change while ripgrep is building + const mockEvent = { + affectsConfiguration: vitest.fn((section: string) => section === "search.useIgnoreFiles"), + } + registeredConfigChangeCallback!(mockEvent) + } + return { src: { "file1.ts": true } } + }), + fileAdded: vitest.fn(), + fileRemoved: vitest.fn(), + targetPath: "/test/workspace", } + ;(RipgrepResultCache as Mock).mockImplementation(() => mockDelayedRipgrepCache) - registeredConfigChangeCallback!(mockEvent) + // Start getRipgrepFileTree (this will hang until we resolve it) + const result = await workspaceTracker.getRipgrepFileTree() - // Next fetch should use cache, not re-call - mockExecuteRipgrepForFiles.mockClear() - await workspaceTracker.getRipgrepFileList() + // Should return the result from the ongoing build + expect(result).toEqual({ src: { "file1.ts": true } }) + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) - expect(mockExecuteRipgrepForFiles).not.toHaveBeenCalled() + // Next call should create a new cache because config changed + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) }) - }) - describe("cache invalidation on file changes", () => { - it("should clear ripgrep cache when files are created", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() - mockExecuteRipgrepForFiles.mockClear() + it("should create new cache after configuration change even if old cache exists", async () => { + // Initialize first cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) - // Trigger file creation event - const [[createCallback]] = mockOnDidCreate.mock.calls - await createCallback({ fsPath: "/test/workspace/newfile.ts" }) + // Simulate configuration change + const mockEvent = { + affectsConfiguration: vitest.fn( + (section: string) => section === "roo-cline.maximumIndexedFilesForFileSearch", + ), + } + registeredConfigChangeCallback!(mockEvent) - // Next fetch should re-call executeRipgrepForFiles - await workspaceTracker.getRipgrepFileList() + // Verify cache was cleared (access private property for testing) + expect((workspaceTracker as any).ripgrepCache).toBeNull() - expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + // Next call should create new cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) }) + }) - it("should clear ripgrep cache when files are deleted", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() - mockExecuteRipgrepForFiles.mockClear() - - // Trigger file deletion event - const [[deleteCallback]] = mockOnDidDelete.mock.calls - await deleteCallback({ fsPath: "/test/workspace/oldfile.ts" }) + describe("isPathIgnoredByRipgrep", () => { + beforeEach(() => { + // Reset mocks for clean test state + vitest.clearAllMocks() + mockProvider = { + postMessageToWebview: vitest.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider & { postMessageToWebview: Mock } + workspaceTracker = new WorkspaceTracker(mockProvider) + }) - // Next fetch should re-call executeRipgrepForFiles - await workspaceTracker.getRipgrepFileList() + it("should ignore node_modules directory", async () => { + const testPaths = [ + "/test/workspace/node_modules/package/file.js", + "/test/workspace/src/node_modules/file.ts", + "/test/workspace/deep/nested/node_modules/lib/index.js", + ] - expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } }) - it("should clear ripgrep cache when workspace resets", async () => { - // First get file list to establish cache - await workspaceTracker.getRipgrepFileList() - - // Change workspace path and trigger reset - ;(getWorkspacePath as Mock).mockReturnValue("/test/new-workspace") - await registeredTabChangeCallback!() + it("should ignore .git directory", async () => { + const testPaths = [ + "/test/workspace/.git/config", + "/test/workspace/subproject/.git/hooks/pre-commit", + "/test/workspace/nested/.git/objects/abc123", + ] - // Advance reset timer - vitest.advanceTimersByTime(300) + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } + }) - // Next fetch should use new workspace path - mockExecuteRipgrepForFiles.mockClear() - await workspaceTracker.getRipgrepFileList() + it("should ignore out and dist directories", async () => { + const testPaths = [ + "/test/workspace/out/main.js", + "/test/workspace/dist/bundle.js", + "/test/workspace/packages/lib/out/index.js", + "/test/workspace/apps/web/dist/assets/main.css", + ] - expect(mockExecuteRipgrepForFiles).toHaveBeenCalledWith("/test/new-workspace", 500000, []) + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } }) - }) - - describe("file list preheating", () => { - it("should preheat ripgrep file list during initialization", async () => { - const mockFiles = [["/test/workspace/file1.ts", "/test/workspace/file2.ts"], false] - ;(listFiles as Mock).mockResolvedValue(mockFiles) - await workspaceTracker.initializeFilePaths() + it("should not ignore files with ignored directory names in filename", async () => { + const testPaths = [ + "/test/workspace/src/node_modules.ts", // file named node_modules + "/test/workspace/git_utils.js", // file containing git + "/test/workspace/output.txt", // file containing out + "/test/workspace/distant.js", // file containing dist + ] - // Should have called getRipgrepFileList to preheat cache - expect(mockExecuteRipgrepForFiles).toHaveBeenCalled() + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(false) + } }) - }) - describe("public getRipgrepFileList method", () => { - it("should be accessible as a public method", () => { - expect(typeof workspaceTracker.getRipgrepFileList).toBe("function") + it("should handle Windows-style paths correctly", async () => { + const testPaths = [ + "C:\\test\\workspace\\node_modules\\package\\file.js", + "C:\\test\\workspace\\src\\.git\\config", + "C:\\test\\workspace\\out\\main.js", + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } }) - it("should return a promise that resolves to FileResult array", async () => { - const expectedFiles = [{ path: "src/test.ts", type: "file", label: "test.ts" }] - mockExecuteRipgrepForFiles.mockResolvedValueOnce(expectedFiles) + it("should not ignore legitimate paths", async () => { + const testPaths = [ + "/test/workspace/src/components/Button.tsx", + "/test/workspace/lib/utils/helper.ts", + "/test/workspace/docs/README.md", + "/test/workspace/tests/unit/parser.spec.ts", + "/test/workspace/scripts/build.sh", + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(false) + } + }) - const result = await workspaceTracker.getRipgrepFileList() + it("should handle edge cases correctly", async () => { + const testCases = [ + { path: "/test/workspace/node_modules", expected: false }, // directory itself, not inside + { path: "/test/workspace/.git", expected: false }, // directory itself, not inside + { path: "/test/workspace/", expected: false }, // root workspace + { path: "", expected: false }, // empty path + { path: "/test/workspace/node_modules/", expected: true }, // with trailing slash + { path: "/test/workspace/.git/", expected: true }, // with trailing slash + ] - expect(Array.isArray(result)).toBe(true) - expect(result).toEqual(expectedFiles) + for (const testCase of testCases) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(testCase.path) + expect(isIgnored).toBe(testCase.expected) + } }) }) }) diff --git a/src/package.json b/src/package.json index 9db6acde013f..f8bdf346278f 100644 --- a/src/package.json +++ b/src/package.json @@ -373,6 +373,13 @@ "type": "string", "default": "", "description": "%settings.autoImportSettingsPath.description%" + }, + "roo-cline.maximumIndexedFilesForFileSearch": { + "type": "number", + "default": 200000, + "minimum": 5000, + "maximum": 500000, + "description": "%settings.maximumIndexedFilesForFileSearch.description%" } } } diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 339e635f0dd0..da5b0130c550 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "La família del model de llenguatge (p. ex. gpt-4)", "settings.customStoragePath.description": "Ruta d'emmagatzematge personalitzada. Deixeu-la buida per utilitzar la ubicació predeterminada. Admet rutes absolutes (p. ex. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correccions ràpides de Roo Code.", - "settings.autoImportSettingsPath.description": "Ruta a un fitxer de configuració de RooCode per importar automàticament en iniciar l'extensió. Admet rutes absolutes i rutes relatives al directori d'inici (per exemple, '~/Documents/roo-code-settings.json'). Deixeu-ho en blanc per desactivar la importació automàtica." + "settings.autoImportSettingsPath.description": "Ruta a un fitxer de configuració de RooCode per importar automàticament en iniciar l'extensió. Admet rutes absolutes i rutes relatives al directori d'inici (per exemple, '~/Documents/roo-code-settings.json'). Deixeu-ho en blanc per desactivar la importació automàtica.", + "settings.maximumIndexedFilesForFileSearch.description": "Especifica el nombre màxim de fitxers a indexar quan es crea un índex per a la funcionalitat de cerca de fitxers a la caixa d'entrada. Un nombre més gran proporciona millors resultats de cerca en projectes grans però pot consumir més memòria." } diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 5a6fe65b119f..cc6022940eb6 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Die Familie des Sprachmodells (z.B. gpt-4)", "settings.customStoragePath.description": "Benutzerdefinierter Speicherpfad. Leer lassen, um den Standardspeicherort zu verwenden. Unterstützt absolute Pfade (z.B. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code Schnelle Problembehebung aktivieren.", - "settings.autoImportSettingsPath.description": "Pfad zu einer RooCode-Konfigurationsdatei, die beim Start der Erweiterung automatisch importiert wird. Unterstützt absolute Pfade und Pfade relativ zum Home-Verzeichnis (z.B. '~/Documents/roo-code-settings.json'). Leer lassen, um den automatischen Import zu deaktivieren." + "settings.autoImportSettingsPath.description": "Pfad zu einer RooCode-Konfigurationsdatei, die beim Start der Erweiterung automatisch importiert wird. Unterstützt absolute Pfade und Pfade relativ zum Home-Verzeichnis (z.B. '~/Documents/roo-code-settings.json'). Leer lassen, um den automatischen Import zu deaktivieren.", + "settings.maximumIndexedFilesForFileSearch.description": "Gibt die maximale Anzahl von Dateien an, die beim Erstellen eines Index für die Dateisuchfunktion im Eingabefeld indiziert werden sollen. Eine größere Anzahl bietet bessere Suchergebnisse in großen Projekten, kann jedoch mehr Speicher verbrauchen." } diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 3e480550d8d7..80aaad5cc99c 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "La familia del modelo de lenguaje (ej. gpt-4)", "settings.customStoragePath.description": "Ruta de almacenamiento personalizada. Dejar vacío para usar la ubicación predeterminada. Admite rutas absolutas (ej. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correcciones rápidas de Roo Code.", - "settings.autoImportSettingsPath.description": "Ruta a un archivo de configuración de RooCode para importar automáticamente al iniciar la extensión. Admite rutas absolutas y rutas relativas al directorio de inicio (por ejemplo, '~/Documents/roo-code-settings.json'). Dejar vacío para desactivar la importación automática." + "settings.autoImportSettingsPath.description": "Ruta a un archivo de configuración de RooCode para importar automáticamente al iniciar la extensión. Admite rutas absolutas y rutas relativas al directorio de inicio (por ejemplo, '~/Documents/roo-code-settings.json'). Dejar vacío para desactivar la importación automática.", + "settings.maximumIndexedFilesForFileSearch.description": "Especifica el número máximo de archivos a indexar al crear un índice para la funcionalidad de búsqueda de archivos en el cuadro de entrada. Un número mayor proporciona mejores resultados de búsqueda en proyectos grandes, pero puede consumir más memoria." } diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 9e8fb83cc353..0ff832d6c8cb 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "La famille du modèle de langage (ex: gpt-4)", "settings.customStoragePath.description": "Chemin de stockage personnalisé. Laisser vide pour utiliser l'emplacement par défaut. Prend en charge les chemins absolus (ex: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Activer les correctifs rapides de Roo Code.", - "settings.autoImportSettingsPath.description": "Chemin d'accès à un fichier de configuration RooCode à importer automatiquement au démarrage de l'extension. Prend en charge les chemins absolus et les chemins relatifs au répertoire de base (par exemple, '~/Documents/roo-code-settings.json'). Laisser vide pour désactiver l'importation automatique." + "settings.autoImportSettingsPath.description": "Chemin d'accès à un fichier de configuration RooCode à importer automatiquement au démarrage de l'extension. Prend en charge les chemins absolus et les chemins relatifs au répertoire de base (par exemple, '~/Documents/roo-code-settings.json'). Laisser vide pour désactiver l'importation automatique.", + "settings.maximumIndexedFilesForFileSearch.description": "Spécifie le nombre maximum de fichiers à indexer lors de la création d'un index pour la fonctionnalité de recherche de fichiers dans la zone de saisie. Un nombre plus élevé offre de meilleurs résultats de recherche dans les grands projets mais peut consommer plus de mémoire." } diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 88bc84596968..802e6c51dda6 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "भाषा मॉडल का परिवार (उदा. gpt-4)", "settings.customStoragePath.description": "कस्टम स्टोरेज पाथ। डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ें। पूर्ण पथ का समर्थन करता है (उदा. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code त्वरित सुधार सक्षम करें", - "settings.autoImportSettingsPath.description": "RooCode कॉन्फ़िगरेशन फ़ाइल का पथ जिसे एक्सटेंशन स्टार्टअप पर स्वचालित रूप से आयात किया जाएगा। होम डायरेक्टरी के सापेक्ष पूर्ण पथ और पथों का समर्थन करता है (उदाहरण के लिए '~/Documents/roo-code-settings.json')। ऑटो-इंपोर्ट को अक्षम करने के लिए खाली छोड़ दें।" + "settings.autoImportSettingsPath.description": "RooCode कॉन्फ़िगरेशन फ़ाइल का पथ जिसे एक्सटेंशन स्टार्टअप पर स्वचालित रूप से आयात किया जाएगा। होम डायरेक्टरी के सापेक्ष पूर्ण पथ और पथों का समर्थन करता है (उदाहरण के लिए '~/Documents/roo-code-settings.json')। ऑटो-इंपोर्ट को अक्षम करने के लिए खाली छोड़ दें।", + "settings.maximumIndexedFilesForFileSearch.description": "इनपुट बॉक्स में फाइल खोज कार्यक्षमता के लिए इंडेक्स बनाते समय इंडेक्स की जाने वाली फाइलों की अधिकतम संख्या निर्दिष्ट करता है। बड़ी संख्या बड़ी परियोजनाओं में बेहतर खोज परिणाम प्रदान करती है लेकिन अधिक मेमोरी का उपयोग कर सकती है।" } diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 1a2e038547b0..132d91f30f91 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Keluarga dari model bahasa (misalnya gpt-4)", "settings.customStoragePath.description": "Path penyimpanan kustom. Biarkan kosong untuk menggunakan lokasi default. Mendukung path absolut (misalnya 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Aktifkan perbaikan cepat Roo Code.", - "settings.autoImportSettingsPath.description": "Path ke file konfigurasi RooCode untuk diimpor secara otomatis saat ekstensi dimulai. Mendukung path absolut dan path relatif terhadap direktori home (misalnya '~/Documents/roo-code-settings.json'). Biarkan kosong untuk menonaktifkan impor otomatis." + "settings.autoImportSettingsPath.description": "Path ke file konfigurasi RooCode untuk diimpor secara otomatis saat ekstensi dimulai. Mendukung path absolut dan path relatif terhadap direktori home (misalnya '~/Documents/roo-code-settings.json'). Biarkan kosong untuk menonaktifkan impor otomatis.", + "settings.maximumIndexedFilesForFileSearch.description": "Menentukan jumlah maksimum file yang akan diindeks saat membuat indeks untuk fungsionalitas pencarian file dalam kotak input. Angka yang lebih besar memberikan hasil pencarian yang lebih baik di proyek besar tetapi mungkin menggunakan lebih banyak memori." } diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 4d5ac4895d62..65bebe42eab6 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "La famiglia del modello linguistico (es. gpt-4)", "settings.customStoragePath.description": "Percorso di archiviazione personalizzato. Lasciare vuoto per utilizzare la posizione predefinita. Supporta percorsi assoluti (es. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Abilita correzioni rapide di Roo Code.", - "settings.autoImportSettingsPath.description": "Percorso di un file di configurazione di RooCode da importare automaticamente all'avvio dell'estensione. Supporta percorsi assoluti e percorsi relativi alla directory home (ad es. '~/Documents/roo-code-settings.json'). Lasciare vuoto per disabilitare l'importazione automatica." + "settings.autoImportSettingsPath.description": "Percorso di un file di configurazione di RooCode da importare automaticamente all'avvio dell'estensione. Supporta percorsi assoluti e percorsi relativi alla directory home (ad es. '~/Documents/roo-code-settings.json'). Lasciare vuoto per disabilitare l'importazione automatica.", + "settings.maximumIndexedFilesForFileSearch.description": "Specifica il numero massimo di file da indicizzare durante la creazione di un indice per la funzionalità di ricerca file nella casella di input. Un numero maggiore fornisce migliori risultati di ricerca in progetti grandi ma può consumare più memoria." } diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index dcbc01d16409..3e90e50ade06 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "言語モデルのファミリー(例:gpt-4)", "settings.customStoragePath.description": "カスタムストレージパス。デフォルトの場所を使用する場合は空のままにします。絶対パスをサポートします(例:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Codeのクイック修正を有効にする。", - "settings.autoImportSettingsPath.description": "拡張機能の起動時に自動的にインポートするRooCode設定ファイルへのパス。絶対パスとホームディレクトリからの相対パスをサポートします(例:'~/Documents/roo-code-settings.json')。自動インポートを無効にするには、空のままにします。" + "settings.autoImportSettingsPath.description": "拡張機能の起動時に自動的にインポートするRooCode設定ファイルへのパス。絶対パスとホームディレクトリからの相対パスをサポートします(例:'~/Documents/roo-code-settings.json')。自動インポートを無効にするには、空のままにします。", + "settings.maximumIndexedFilesForFileSearch.description": "入力ボックスのファイル検索機能のためのインデックス作成時に、インデックス化するファイルの最大数を指定します。数値が大きいほど大規模プロジェクトでの検索結果が向上しますが、より多くのメモリを消費する可能性があります。" } diff --git a/src/package.nls.json b/src/package.nls.json index c5225c45c8ad..a292c3ac8f67 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)", "settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Enable Roo Code quick fixes", - "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import." + "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.", + "settings.maximumIndexedFilesForFileSearch.description": "Specifies the maximum number of files to index when creating an index for file search functionality in the input field. A larger value provides better search results in large projects but may consume more memory." } diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 6cb839e793c3..3f8137c3b826 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "언어 모델 계열 (예: gpt-4)", "settings.customStoragePath.description": "사용자 지정 저장소 경로. 기본 위치를 사용하려면 비워두세요. 절대 경로를 지원합니다 (예: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code 빠른 수정 사용 설정", - "settings.autoImportSettingsPath.description": "확장 프로그램 시작 시 자동으로 가져올 RooCode 구성 파일의 경로입니다. 절대 경로 및 홈 디렉토리에 대한 상대 경로를 지원합니다(예: '~/Documents/roo-code-settings.json'). 자동 가져오기를 비활성화하려면 비워 둡니다." + "settings.autoImportSettingsPath.description": "확장 프로그램 시작 시 자동으로 가져올 RooCode 구성 파일의 경로입니다. 절대 경로 및 홈 디렉토리에 대한 상대 경로를 지원합니다(예: '~/Documents/roo-code-settings.json'). 자동 가져오기를 비활성화하려면 비워 둡니다.", + "settings.maximumIndexedFilesForFileSearch.description": "입력 상자의 파일 검색 기능을 위한 인덱스 생성 시 최대 인덱스할 파일 수를 지정합니다. 숫자가 클수록 대형 프로젝트에서 더 나은 검색 결과를 제공하지만 더 많은 메모리를 사용할 수 있습니다." } diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 51b23ec1a652..13471472834c 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "De familie van het taalmodel (bijv. gpt-4)", "settings.customStoragePath.description": "Aangepast opslagpad. Laat leeg om de standaardlocatie te gebruiken. Ondersteunt absolute paden (bijv. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Snelle correcties van Roo Code inschakelen.", - "settings.autoImportSettingsPath.description": "Pad naar een RooCode-configuratiebestand om automatisch te importeren bij het opstarten van de extensie. Ondersteunt absolute paden en paden ten opzichte van de thuismap (bijv. '~/Documents/roo-code-settings.json'). Laat leeg om automatisch importeren uit te schakelen." + "settings.autoImportSettingsPath.description": "Pad naar een RooCode-configuratiebestand om automatisch te importeren bij het opstarten van de extensie. Ondersteunt absolute paden en paden ten opzichte van de thuismap (bijv. '~/Documents/roo-code-settings.json'). Laat leeg om automatisch importeren uit te schakelen.", + "settings.maximumIndexedFilesForFileSearch.description": "Specificeert het maximale aantal bestanden dat geïndexeerd moet worden bij het maken van een index voor de bestandszoekfunctionaliteit in het invoerveld. Een hoger getal biedt betere zoekresultaten in grote projecten maar kan meer geheugen verbruiken." } diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 62daaae24b39..8d6c215674f7 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Rodzina modelu językowego (np. gpt-4)", "settings.customStoragePath.description": "Niestandardowa ścieżka przechowywania. Pozostaw puste, aby użyć domyślnej lokalizacji. Obsługuje ścieżki bezwzględne (np. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Włącz szybkie poprawki Roo Code.", - "settings.autoImportSettingsPath.description": "Ścieżka do pliku konfiguracyjnego RooCode, który ma być automatycznie importowany podczas uruchamiania rozszerzenia. Obsługuje ścieżki bezwzględne i ścieżki względne do katalogu domowego (np. '~/Documents/roo-code-settings.json'). Pozostaw puste, aby wyłączyć automatyczne importowanie." + "settings.autoImportSettingsPath.description": "Ścieżka do pliku konfiguracyjnego RooCode, który ma być automatycznie importowany podczas uruchamiania rozszerzenia. Obsługuje ścieżki bezwzględne i ścieżki względne do katalogu domowego (np. '~/Documents/roo-code-settings.json'). Pozostaw puste, aby wyłączyć automatyczne importowanie.", + "settings.maximumIndexedFilesForFileSearch.description": "Określa maksymalną liczbę plików do indeksowania podczas tworzenia indeksu dla funkcji wyszukiwania plików w polu wprowadzania. Większa liczba zapewnia lepsze wyniki wyszukiwania w dużych projektach, ale może zużywać więcej pamięci." } diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 7f3f7aece3cb..f9fe86135d1d 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "A família do modelo de linguagem (ex: gpt-4)", "settings.customStoragePath.description": "Caminho de armazenamento personalizado. Deixe vazio para usar o local padrão. Suporta caminhos absolutos (ex: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Habilitar correções rápidas do Roo Code.", - "settings.autoImportSettingsPath.description": "Caminho para um arquivo de configuração do RooCode para importar automaticamente na inicialização da extensão. Suporta caminhos absolutos e caminhos relativos ao diretório inicial (por exemplo, '~/Documents/roo-code-settings.json'). Deixe em branco para desativar a importação automática." + "settings.autoImportSettingsPath.description": "Caminho para um arquivo de configuração do RooCode para importar automaticamente na inicialização da extensão. Suporta caminhos absolutos e caminhos relativos ao diretório inicial (por exemplo, '~/Documents/roo-code-settings.json'). Deixe em branco para desativar a importação automática.", + "settings.maximumIndexedFilesForFileSearch.description": "Especifica o número máximo de arquivos a serem indexados ao criar um índice para a funcionalidade de pesquisa de arquivos na caixa de entrada. Um número maior fornece melhores resultados de pesquisa em projetos grandes, mas pode consumir mais memória." } diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index c1872a759e26..819c0c3ff531 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Семейство языковой модели (например, gpt-4)", "settings.customStoragePath.description": "Пользовательский путь хранения. Оставьте пустым для использования пути по умолчанию. Поддерживает абсолютные пути (например, 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Включить быстрые исправления Roo Code.", - "settings.autoImportSettingsPath.description": "Путь к файлу конфигурации RooCode для автоматического импорта при запуске расширения. Поддерживает абсолютные пути и пути относительно домашнего каталога (например, '~/Documents/roo-code-settings.json'). Оставьте пустым, чтобы отключить автоматический импорт." + "settings.autoImportSettingsPath.description": "Путь к файлу конфигурации RooCode для автоматического импорта при запуске расширения. Поддерживает абсолютные пути и пути относительно домашнего каталога (например, '~/Documents/roo-code-settings.json'). Оставьте пустым, чтобы отключить автоматический импорт.", + "settings.maximumIndexedFilesForFileSearch.description": "Указывает максимальное количество файлов для индексации при создании индекса для функции поиска файлов в поле ввода. Большее число обеспечивает лучшие результаты поиска в крупных проектах, но может потреблять больше памяти." } diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 589ce6191209..532334fbc500 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Dil modelinin ailesi (örn: gpt-4)", "settings.customStoragePath.description": "Özel depolama yolu. Varsayılan konumu kullanmak için boş bırakın. Mutlak yolları destekler (örn: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code hızlı düzeltmeleri etkinleştir.", - "settings.autoImportSettingsPath.description": "Uzantı başlangıcında otomatik olarak içe aktarılacak bir RooCode yapılandırma dosyasının yolu. Mutlak yolları ve ana dizine göreli yolları destekler (ör. '~/Documents/roo-code-settings.json'). Otomatik içe aktarmayı devre dışı bırakmak için boş bırakın." + "settings.autoImportSettingsPath.description": "Uzantı başlangıcında otomatik olarak içe aktarılacak bir RooCode yapılandırma dosyasının yolu. Mutlak yolları ve ana dizine göreli yolları destekler (ör. '~/Documents/roo-code-settings.json'). Otomatik içe aktarmayı devre dışı bırakmak için boş bırakın.", + "settings.maximumIndexedFilesForFileSearch.description": "Giriş kutusundaki dosya arama işlevi için dizin oluştururken dizinlenecek maksimum dosya sayısını belirtir. Daha büyük bir sayı, büyük projelerde daha iyi arama sonuçları sağlar ancak daha fazla bellek tüketebilir." } diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 067738892d74..ec0fea486dc3 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "Họ mô hình ngôn ngữ (ví dụ: gpt-4)", "settings.customStoragePath.description": "Đường dẫn lưu trữ tùy chỉnh. Để trống để sử dụng vị trí mặc định. Hỗ trợ đường dẫn tuyệt đối (ví dụ: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Bật sửa lỗi nhanh Roo Code.", - "settings.autoImportSettingsPath.description": "Đường dẫn đến tệp cấu hình RooCode để tự động nhập khi khởi động tiện ích mở rộng. Hỗ trợ đường dẫn tuyệt đối và đường dẫn tương đối đến thư mục chính (ví dụ: '~/Documents/roo-code-settings.json'). Để trống để tắt tính năng tự động nhập." + "settings.autoImportSettingsPath.description": "Đường dẫn đến tệp cấu hình RooCode để tự động nhập khi khởi động tiện ích mở rộng. Hỗ trợ đường dẫn tuyệt đối và đường dẫn tương đối đến thư mục chính (ví dụ: '~/Documents/roo-code-settings.json'). Để trống để tắt tính năng tự động nhập.", + "settings.maximumIndexedFilesForFileSearch.description": "Chỉ định số lượng tệp tối đa để lập chỉ mục khi tạo chỉ mục cho chức năng tìm kiếm tệp trong hộp nhập. Số lượng lớn hơn cung cấp kết quả tìm kiếm tốt hơn trong các dự án lớn nhưng có thể tiêu thụ nhiều bộ nhớ hơn." } diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 3a69340f8123..b0fa683f79dd 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "语言模型的系列(例如:gpt-4)", "settings.customStoragePath.description": "自定义存储路径。留空以使用默认位置。支持绝对路径(例如:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "启用 Roo Code 快速修复", - "settings.autoImportSettingsPath.description": "RooCode 配置文件的路径,用于在扩展启动时自动导入。支持绝对路径和相对于主目录的路径(例如 '~/Documents/roo-code-settings.json')。留空以禁用自动导入。" + "settings.autoImportSettingsPath.description": "RooCode 配置文件的路径,用于在扩展启动时自动导入。支持绝对路径和相对于主目录的路径(例如 '~/Documents/roo-code-settings.json')。留空以禁用自动导入。", + "settings.maximumIndexedFilesForFileSearch.description": "指定为输入框的文件搜索功能创建索引时,最多索引的文件个数,数量越大在大项目中的搜索效果越好,但可能占用更多内存。" } diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index d6314420f171..da2888dbe96a 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -34,5 +34,6 @@ "settings.vsCodeLmModelSelector.family.description": "語言模型系列(例如:gpt-4)", "settings.customStoragePath.description": "自訂儲存路徑。留空以使用預設位置。支援絕對路徑(例如:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "啟用 Roo Code 快速修復。", - "settings.autoImportSettingsPath.description": "RooCode 設定檔案的路徑,用於在擴充功能啟動時自動匯入。支援絕對路徑和相對於主目錄的路徑(例如 '~/Documents/roo-code-settings.json')。留空以停用自動匯入。" + "settings.autoImportSettingsPath.description": "RooCode 設定檔案的路徑,用於在擴充功能啟動時自動匯入。支援絕對路徑和相對於主目錄的路徑(例如 '~/Documents/roo-code-settings.json')。留空以停用自動匯入。", + "settings.maximumIndexedFilesForFileSearch.description": "指定為輸入框的檔案搜尋功能建立索引時,最多索引的檔案個數,數量越大在大專案中的搜尋效果越好,但可能佔用更多記憶體。" } diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index 5dd1d3c7d0ec..e60205d00940 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -5,6 +5,7 @@ import * as childProcess from "child_process" import * as readline from "readline" import { byLengthAsc, Fzf } from "fzf" import { getBinPath } from "../ripgrep" +import { SimpleTreeNode } from "../../integrations/workspace/RipgrepResultCache" export type FileResult = { path: string; type: "file" | "folder"; label?: string } @@ -85,80 +86,37 @@ export async function executeRipgrep({ }) } -export async function executeRipgrepForFiles( - workspacePath: string, - limit: number = 5000, - extraOptions: string[] = [], -): Promise { - const args = [ - "--files", - "--follow", - "--hidden", - ...extraOptions, - "-g", - "!**/node_modules/**", - "-g", - "!**/.git/**", - "-g", - "!**/out/**", - "-g", - "!**/dist/**", - workspacePath, - ] - - return executeRipgrep({ args, workspacePath, limit }) -} - export async function searchWorkspaceFiles( query: string, workspacePath: string, - workspaceFiles: FileResult[], + workspaceList: FileResult[], limit: number = 20, ): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> { try { - // Use the provided workspace files - const allItems = workspaceFiles + const allItems = workspaceList // If no query, just return the top items if (!query.trim()) { - return allItems.slice(0, limit) + return allItems.slice(0, limit).map((item) => ({ + ...item, + label: path.basename(item.path), + })) } - // Create search items for all files AND directories - const searchItems = allItems.map((item) => ({ - original: item, - searchStr: `${item.path} ${item.label || ""}`, - })) - // Run fzf search on all items - const fzf = new Fzf(searchItems, { - selector: (item) => item.searchStr, + const fzf = new Fzf(allItems, { + selector: (item) => item.path, tiebreakers: [byLengthAsc], limit: limit, }) - // Get all matching results from fzf - const fzfResults = fzf.find(query).map((result) => result.item.original) - - // Verify types of the shortest results - const verifiedResults = await Promise.all( - fzfResults.map(async (result) => { - const fullPath = path.join(workspacePath, result.path) - // Verify if the path exists and is actually a directory - if (fs.existsSync(fullPath)) { - const isDirectory = fs.lstatSync(fullPath).isDirectory() - return { - ...result, - path: result.path.toPosix(), - type: isDirectory ? ("folder" as const) : ("file" as const), - } - } - // If path doesn't exist, keep original type - return result - }), - ) + // Get all matching results from fzf and generate labels + const fzfResults = fzf.find(query).map((result) => ({ + ...result.item, + label: path.basename(result.item.path), + })) - return verifiedResults + return fzfResults } catch (error) { console.error("Error in searchWorkspaceFiles:", error) return [] From 4c9698e2ec167d33c9f28569f0e09dc1f6c2e401 Mon Sep 17 00:00:00 2001 From: Naituw Date: Thu, 17 Jul 2025 01:39:11 +0800 Subject: [PATCH 3/4] fix prototype-polluting function & path.sep --- .../workspace/RipgrepResultCache.ts | 30 +++++++++++++------ src/services/search/file-search.ts | 20 ++++++++++++- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/integrations/workspace/RipgrepResultCache.ts b/src/integrations/workspace/RipgrepResultCache.ts index c0df63746600..9a8fac74dee5 100644 --- a/src/integrations/workspace/RipgrepResultCache.ts +++ b/src/integrations/workspace/RipgrepResultCache.ts @@ -1,5 +1,5 @@ import { spawn } from "child_process" -import { dirname, resolve as pathResolve, relative } from "path" +import { dirname, resolve as pathResolve, relative, sep } from "path" // Simplified tree structure - files represented by true, directories by nested objects export type SimpleTreeNode = { @@ -21,7 +21,7 @@ export class RipgrepResultCache { constructor(rgPath: string, targetPath: string, rgArgs: string[] = [], fileLimit: number = 5000) { this.rgPath = rgPath - this._targetPath = pathResolve(targetPath) + this._targetPath = pathResolve(targetPath).split(sep).join("/") this.fileLimit = fileLimit this.rgArgs = rgArgs.length > 0 ? rgArgs : ["--files"] } @@ -99,7 +99,10 @@ export class RipgrepResultCache { } private fileAddedOrRemoved(filePath: string): void { - const relativePath = relative(this._targetPath, pathResolve(this._targetPath, filePath)) + const normalizedFilePath = filePath.split(sep).join("/") + const normalizedTargetPath = this._targetPath + + const relativePath = relative(normalizedTargetPath, pathResolve(normalizedTargetPath, normalizedFilePath)) const parentDir = dirname(relativePath) if (parentDir !== "." && parentDir !== "") { @@ -230,8 +233,9 @@ export class RipgrepResultCache { args.push(...targetPaths) } + const originalPath = this._targetPath.split("/").join(sep) const child = spawn(this.rgPath, args, { - cwd: this._targetPath, + cwd: originalPath, stdio: ["pipe", "pipe", "pipe"], }) @@ -327,19 +331,27 @@ export class RipgrepResultCache { /** * In-place merge two simplified tree nodes (optimized version, reduces object creation) + * Uses Object.hasOwn for safe property checks to prevent prototype pollution */ private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void { - for (const [key, value] of Object.entries(newTree)) { + for (const key of Object.keys(newTree)) { + // skip inherited properties + if (!Object.hasOwn(newTree, key)) { + continue + } + + // skip dangerous property names + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue + } + + const value = newTree[key] if (value === true) { - // New node is file, overwrite directly existing[key] = true } else { - // New node is directory if (!existing[key] || existing[key] === true) { - // Original doesn't exist or is file, replace with directory directly existing[key] = value } else { - // Original is also directory, merge recursively this.mergeSimpleTreeNodesInPlace(existing[key] as SimpleTreeNode, value) } } diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index e60205d00940..fed8305074dd 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -116,7 +116,25 @@ export async function searchWorkspaceFiles( label: path.basename(result.item.path), })) - return fzfResults + // Verify types of the shortest results + const verifiedResults = await Promise.all( + fzfResults.map(async (result) => { + const fullPath = path.join(workspacePath, result.path) + // Verify if the path exists and is actually a directory + if (fs.existsSync(fullPath)) { + const isDirectory = fs.lstatSync(fullPath).isDirectory() + return { + ...result, + path: result.path.toPosix(), + type: isDirectory ? ("folder" as const) : ("file" as const), + } + } + // If path doesn't exist, keep original type + return result + }), + ) + + return verifiedResults } catch (error) { console.error("Error in searchWorkspaceFiles:", error) return [] From 252ff93e7a5a8d8a84f58441842bd132ddb56960 Mon Sep 17 00:00:00 2001 From: wutian Date: Thu, 17 Jul 2025 15:38:29 +0800 Subject: [PATCH 4/4] Handle path properly on Windows, update logic & test cases --- .../workspace/RipgrepResultCache.ts | 14 +- .../__tests__/RipgrepResultCache.spec.ts | 370 ++++++++++++------ .../__tests__/WorkspaceTracker.spec.ts | 50 ++- src/services/search/file-search.ts | 1 - 4 files changed, 299 insertions(+), 136 deletions(-) diff --git a/src/integrations/workspace/RipgrepResultCache.ts b/src/integrations/workspace/RipgrepResultCache.ts index 9a8fac74dee5..48620e84e855 100644 --- a/src/integrations/workspace/RipgrepResultCache.ts +++ b/src/integrations/workspace/RipgrepResultCache.ts @@ -21,7 +21,7 @@ export class RipgrepResultCache { constructor(rgPath: string, targetPath: string, rgArgs: string[] = [], fileLimit: number = 5000) { this.rgPath = rgPath - this._targetPath = pathResolve(targetPath).split(sep).join("/") + this._targetPath = pathResolve(targetPath) this.fileLimit = fileLimit this.rgArgs = rgArgs.length > 0 ? rgArgs : ["--files"] } @@ -99,10 +99,7 @@ export class RipgrepResultCache { } private fileAddedOrRemoved(filePath: string): void { - const normalizedFilePath = filePath.split(sep).join("/") - const normalizedTargetPath = this._targetPath - - const relativePath = relative(normalizedTargetPath, pathResolve(normalizedTargetPath, normalizedFilePath)) + const relativePath = relative(this._targetPath, pathResolve(this._targetPath, filePath)) const parentDir = dirname(relativePath) if (parentDir !== "." && parentDir !== "") { @@ -207,7 +204,7 @@ export class RipgrepResultCache { try { // Stream build subtrees for all invalid directories (pass directory paths directly) - const invalidDirectories = Array.from(this.invalidatedDirectories) + const invalidDirectories = Array.from(this.invalidatedDirectories).map((dir) => dir.split("/").join(sep)) const subtree = await this.buildTreeStreaming(invalidDirectories) // Merge subtrees into main tree (replace original invalid parts) @@ -233,9 +230,8 @@ export class RipgrepResultCache { args.push(...targetPaths) } - const originalPath = this._targetPath.split("/").join(sep) const child = spawn(this.rgPath, args, { - cwd: originalPath, + cwd: this._targetPath, stdio: ["pipe", "pipe", "pipe"], }) @@ -246,7 +242,7 @@ export class RipgrepResultCache { // Stream add file paths to simplified tree structure const addFileToTree = (filePath: string) => { // ripgrep output is already relative path, use directly - const parts = filePath.split("/").filter(Boolean) + const parts = filePath.split(sep).filter(Boolean) let currentNode: SimpleTreeNode = tree for (let i = 0; i < parts.length; i++) { diff --git a/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts b/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts index c40294365133..2a6d847bf9f0 100644 --- a/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts +++ b/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts @@ -2,12 +2,42 @@ import type { Mock } from "vitest" import { RipgrepResultCache, type SimpleTreeNode } from "../RipgrepResultCache" import { spawn } from "child_process" import { EventEmitter } from "events" +import { sep, join, resolve } from "path" // Mock child_process spawn vitest.mock("child_process", () => ({ spawn: vitest.fn(), })) +// Platform-specific path utilities +const isWindows = process.platform === "win32" + +// Test constants +const TEST_PATHS = { + workspace: isWindows ? "C:\\test\\workspace" : "/test/workspace", + rgExecutable: isWindows ? "C:\\tools\\rg.exe" : "/usr/bin/rg", + relativeWorkspace: "test/workspace" +} + +// Path utility functions +const createTestPath = (...parts: string[]) => { + return isWindows ? parts.join("\\") : parts.join("/") +} + +const createAbsolutePath = (workspace: string, ...parts: string[]) => { + return join(workspace, ...parts) +} + +// Mock ripgrep output helper +const createMockRipgrepOutput = (files: string[]) => { + // Convert file paths to platform-specific format for ripgrep output + const platformFiles = files.map(file => { + // ripgrep outputs relative paths with platform-specific separators + return file.replace(/[\/\\]/g, sep) + }) + return platformFiles.join("\n") + (platformFiles.length > 0 ? "\n" : "") +} + // Mock process for testing class MockChildProcess extends EventEmitter { stdout = new EventEmitter() @@ -33,42 +63,46 @@ describe("RipgrepResultCache", () => { describe("constructor", () => { it("should initialize with default parameters", () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) - expect(cache.targetPath).toBe("/test/workspace") + expect(cache.targetPath).toBe(resolve(TEST_PATHS.workspace)) }) it("should resolve target path", () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.relativeWorkspace) - expect(cache.targetPath).toContain("test/workspace") + expect(cache.targetPath).toContain("test" + sep + "workspace") }) it("should use custom file limit", () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 10000) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 10000) - expect(cache.targetPath).toBe("/test/workspace") + expect(cache.targetPath).toBe(resolve(TEST_PATHS.workspace)) expect((cache as any).fileLimit).toBe(10000) }) }) describe("getTree", () => { it("should build tree on first call", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Simulate successful ripgrep output const treePromise = cache.getTree() // Simulate ripgrep output setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/file2.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) const result = await treePromise - expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", ["--files"], { - cwd: "/test/workspace", + expect(mockSpawn).toHaveBeenCalledWith(TEST_PATHS.rgExecutable, ["--files"], { + cwd: resolve(TEST_PATHS.workspace), stdio: ["pipe", "pipe", "pipe"], }) expect(result).toEqual({ @@ -80,12 +114,13 @@ describe("RipgrepResultCache", () => { }) it("should return cached tree on subsequent calls", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // First call const firstPromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await firstPromise @@ -103,7 +138,7 @@ describe("RipgrepResultCache", () => { }) it("should wait for ongoing build if already building", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Start first build const firstPromise = cache.getTree() @@ -113,7 +148,8 @@ describe("RipgrepResultCache", () => { // Complete the build setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -125,7 +161,7 @@ describe("RipgrepResultCache", () => { }) it("should handle ripgrep errors gracefully", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() @@ -138,13 +174,19 @@ describe("RipgrepResultCache", () => { }) it("should respect file limit", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 2) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 2) const treePromise = cache.getTree() setTimeout(() => { // Send more files than the limit - mockChildProcess.stdout.emit("data", Buffer.from("file1.ts\nfile2.ts\nfile3.ts\nfile4.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "file1.ts", + "file2.ts", + "file3.ts", + "file4.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) // Process should be killed when limit reached expect(mockChildProcess.killed).toBe(true) }, 10) @@ -161,12 +203,16 @@ describe("RipgrepResultCache", () => { let cache: RipgrepResultCache beforeEach(async () => { - cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Build initial tree const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/utils/helper.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await treePromise @@ -176,12 +222,18 @@ describe("RipgrepResultCache", () => { // Clear spawn mock to detect new calls mockSpawn.mockClear() - cache.fileAdded("/test/workspace/src/newfile.ts") + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(newFilePath) // Next getTree call should trigger rebuild const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/newfile.ts\nsrc/utils/helper.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -195,12 +247,14 @@ describe("RipgrepResultCache", () => { // Clear spawn mock to detect new calls mockSpawn.mockClear() - cache.fileRemoved("/test/workspace/src/file1.ts") + const removedFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "file1.ts") + cache.fileRemoved(removedFilePath) // Next getTree call should trigger rebuild const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/utils/helper.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/utils/helper.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -219,7 +273,11 @@ describe("RipgrepResultCache", () => { // Next getTree call should trigger rebuild const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/relative.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/relative.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -233,15 +291,17 @@ describe("RipgrepResultCache", () => { let cache: RipgrepResultCache beforeEach(async () => { - cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Build initial tree const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from("src/file1.ts\nsrc/components/Button.tsx\nutils/helper.ts\n"), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/components/Button.tsx", + "utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await treePromise @@ -249,16 +309,19 @@ describe("RipgrepResultCache", () => { it("should perform incremental update for invalidated directories", async () => { // Add file to invalidate src directory - cache.fileAdded("/test/workspace/src/newfile.ts") + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(newFilePath) mockSpawn.mockClear() // Mock incremental update response (only for src directory) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from("src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\n"), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/components/Button.tsx" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -280,15 +343,17 @@ describe("RipgrepResultCache", () => { it("should handle nested directory invalidation correctly", async () => { // Add file to nested directory - cache.fileAdded("/test/workspace/src/components/Icon.tsx") + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "components", "Icon.tsx") + cache.fileAdded(newFilePath) mockSpawn.mockClear() const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from("src/components/Button.tsx\nsrc/components/Icon.tsx\n"), - ) + const mockOutput = createMockRipgrepOutput([ + "src/components/Button.tsx", + "src/components/Icon.tsx" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -299,21 +364,24 @@ describe("RipgrepResultCache", () => { it("should avoid duplicate invalidation for parent directories", async () => { // First invalidate parent directory - cache.fileAdded("/test/workspace/src/newfile.ts") + const parentFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(parentFilePath) // Then try to invalidate child directory (should be ignored) - cache.fileAdded("/test/workspace/src/components/NewComponent.tsx") + const childFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "components", "NewComponent.tsx") + cache.fileAdded(childFilePath) mockSpawn.mockClear() const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from( - "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nsrc/components/NewComponent.tsx\n", - ), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/components/Button.tsx", + "src/components/NewComponent.tsx" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -325,28 +393,35 @@ describe("RipgrepResultCache", () => { it("should handle multiple directory invalidations in single incremental update", async () => { // Invalidate multiple independent directories - cache.fileAdded("/test/workspace/src/newfile.ts") - cache.fileAdded("/test/workspace/utils/newutil.ts") - cache.fileAdded("/test/workspace/lib/helper.ts") + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts")) + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "utils", "newutil.ts")) + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "lib", "helper.ts")) mockSpawn.mockClear() // Mock incremental update response for all invalidated directories const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from( - "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nutils/helper.ts\nutils/newutil.ts\nlib/helper.ts\n", - ), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/components/Button.tsx", + "utils/helper.ts", + "utils/newutil.ts", + "lib/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) const result = await treePromise // Should have called spawn for incremental update - expect(mockSpawn).toHaveBeenCalled() + const expectedPaths = ["src", "utils", "lib"].map(p => isWindows ? p : p) + expect(mockSpawn).toHaveBeenCalledWith(TEST_PATHS.rgExecutable, ["--files", ...expectedPaths], { + cwd: resolve(TEST_PATHS.workspace), + stdio: ["pipe", "pipe", "pipe"], + }) // Verify all directories are updated correctly expect(result).toEqual({ @@ -369,22 +444,26 @@ describe("RipgrepResultCache", () => { it("should handle multiple nested directory invalidations with parent-child relationships", async () => { // Invalidate multiple directories with parent-child relationships - cache.fileAdded("/test/workspace/src/newfile.ts") // Parent directory - cache.fileAdded("/test/workspace/src/components/NewComponent.tsx") // Child directory - cache.fileAdded("/test/workspace/src/components/ui/Button.tsx") // Grandchild directory - cache.fileAdded("/test/workspace/utils/newutil.ts") // Independent directory + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts")) // Parent directory + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "src", "components", "NewComponent.tsx")) // Child directory + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "src", "components", "ui", "Button.tsx")) // Grandchild directory + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "utils", "newutil.ts")) // Independent directory mockSpawn.mockClear() // Mock incremental update response for all invalidated directories const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from( - "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nsrc/components/NewComponent.tsx\nsrc/components/ui/Button.tsx\nutils/helper.ts\nutils/newutil.ts\n", - ), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/components/Button.tsx", + "src/components/NewComponent.tsx", + "src/components/ui/Button.tsx", + "utils/helper.ts", + "utils/newutil.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -415,22 +494,26 @@ describe("RipgrepResultCache", () => { it("should trigger only one ripgrep call when multiple directories are invalidated", async () => { // Invalidate multiple directories in sequence - cache.fileAdded("/test/workspace/src/newfile.ts") - cache.fileAdded("/test/workspace/utils/newutil.ts") - cache.fileAdded("/test/workspace/lib/helper.ts") - cache.fileAdded("/test/workspace/tests/test.ts") + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts")) + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "utils", "newutil.ts")) + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "lib", "helper.ts")) + cache.fileAdded(createAbsolutePath(TEST_PATHS.workspace, "tests", "test.ts")) mockSpawn.mockClear() // Call getTree - should trigger only one ripgrep process const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit( - "data", - Buffer.from( - "src/file1.ts\nsrc/newfile.ts\nsrc/components/Button.tsx\nutils/helper.ts\nutils/newutil.ts\nlib/helper.ts\ntests/test.ts\n", - ), - ) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts", + "src/components/Button.tsx", + "utils/helper.ts", + "utils/newutil.ts", + "lib/helper.ts", + "tests/test.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -444,35 +527,37 @@ describe("RipgrepResultCache", () => { describe("ripgrep arguments", () => { it("should use custom ripgrep arguments", async () => { const customArgs = ["--files", "--follow", "--hidden", "--no-ignore"] - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", customArgs) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, customArgs) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await treePromise - expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", customArgs, { - cwd: "/test/workspace", + expect(mockSpawn).toHaveBeenCalledWith(TEST_PATHS.rgExecutable, customArgs, { + cwd: resolve(TEST_PATHS.workspace), stdio: ["pipe", "pipe", "pipe"], }) }) it("should use default --files argument when no args provided", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", []) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, []) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await treePromise - expect(mockSpawn).toHaveBeenCalledWith("/usr/bin/rg", ["--files"], { - cwd: "/test/workspace", + expect(mockSpawn).toHaveBeenCalledWith(TEST_PATHS.rgExecutable, ["--files"], { + cwd: resolve(TEST_PATHS.workspace), stdio: ["pipe", "pipe", "pipe"], }) }) @@ -480,12 +565,13 @@ describe("RipgrepResultCache", () => { describe("clearCache", () => { it("should clear all cached data", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Build initial cache const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await treePromise @@ -497,7 +583,8 @@ describe("RipgrepResultCache", () => { mockSpawn.mockClear() const newTreePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -509,7 +596,7 @@ describe("RipgrepResultCache", () => { describe("edge cases", () => { it("should handle empty ripgrep output", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { @@ -522,11 +609,14 @@ describe("RipgrepResultCache", () => { }) it("should handle ripgrep output with empty lines", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n\n\nsrc/file2.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts", "", "", "src/file2.ts"]) + // Add some empty lines to simulate real ripgrep output + const outputWithEmptyLines = "\n" + mockOutput + "\n\n" + mockChildProcess.stdout.emit("data", Buffer.from(outputWithEmptyLines)) mockChildProcess.emit("close", 0) }, 10) @@ -541,11 +631,15 @@ describe("RipgrepResultCache", () => { }) it("should handle file paths with special characters", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file-with-dash.ts\nsrc/file with spaces.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file-with-dash.ts", + "src/file with spaces.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -560,11 +654,12 @@ describe("RipgrepResultCache", () => { }) it("should handle deeply nested directory structures", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("a/b/c/d/e/f/deep.ts\n")) + const mockOutput = createMockRipgrepOutput(["a/b/c/d/e/f/deep.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -588,11 +683,15 @@ describe("RipgrepResultCache", () => { }) it("should handle mixed files and directories with same names", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/utils.ts\nsrc/utils/helper.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/utils.ts", + "src/utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -611,14 +710,20 @@ describe("RipgrepResultCache", () => { describe("streaming buffer handling", () => { it("should handle partial data chunks correctly", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - // Send data in partial chunks - mockChildProcess.stdout.emit("data", Buffer.from("src/fi")) - mockChildProcess.stdout.emit("data", Buffer.from("le1.ts\nsrc/file")) - mockChildProcess.stdout.emit("data", Buffer.from("2.ts\n")) + // Send data in partial chunks - using platform-specific separators + const file1 = createTestPath("src", "file1.ts") + const file2 = createTestPath("src", "file2.ts") + const part1 = file1.substring(0, file1.length - 3) // "src/fi" or "src\\fi" + const part2 = file1.substring(file1.length - 3) + "\n" + file2.substring(0, file2.length - 3) // "le1.ts\nsrc/file" or "le1.ts\nsrc\\file" + const part3 = file2.substring(file2.length - 3) + "\n" // "2.ts\n" + + mockChildProcess.stdout.emit("data", Buffer.from(part1)) + mockChildProcess.stdout.emit("data", Buffer.from(part2)) + mockChildProcess.stdout.emit("data", Buffer.from(part3)) mockChildProcess.emit("close", 0) }, 10) @@ -633,11 +738,14 @@ describe("RipgrepResultCache", () => { }) it("should handle final buffer content without newline", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) const treePromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts")) + const file1 = createTestPath("src", "file1.ts") + const file2 = createTestPath("src", "file2.ts") + const outputWithoutFinalNewline = file1 + "\n" + file2 // No trailing newline + mockChildProcess.stdout.emit("data", Buffer.from(outputWithoutFinalNewline)) mockChildProcess.emit("close", 0) }, 10) @@ -661,7 +769,7 @@ describe("RipgrepResultCache", () => { it("should handle multiple concurrent successful calls", async () => { mockSpawn.mockReturnValue(mockChildProcess) - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Start multiple concurrent getTree calls const promise1 = cache.getTree() @@ -673,7 +781,12 @@ describe("RipgrepResultCache", () => { // Simulate successful ripgrep output setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/file2.ts\nlib/utils.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/file2.ts", + "lib/utils.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -700,7 +813,7 @@ describe("RipgrepResultCache", () => { it("should handle multiple concurrent calls when first fails", async () => { mockSpawn.mockReturnValue(mockChildProcess) - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Start multiple concurrent getTree calls const promise1 = cache.getTree() @@ -728,7 +841,8 @@ describe("RipgrepResultCache", () => { const retryPromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -737,13 +851,14 @@ describe("RipgrepResultCache", () => { }) it("should handle concurrent calls after invalidation", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace") + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) // Build initial cache mockSpawn.mockReturnValue(mockChildProcess) const initialPromise = cache.getTree() setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\n")) + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) await initialPromise @@ -754,7 +869,8 @@ describe("RipgrepResultCache", () => { mockSpawn.mockReturnValue(mockChildProcess) // Invalidate cache - cache.fileAdded("/test/workspace/src/newfile.ts") + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(newFilePath) // Start multiple concurrent calls after invalidation const promise1 = cache.getTree() @@ -765,7 +881,11 @@ describe("RipgrepResultCache", () => { expect(mockSpawn).toHaveBeenCalledTimes(1) setTimeout(() => { - mockChildProcess.stdout.emit("data", Buffer.from("src/file1.ts\nsrc/newfile.ts\n")) + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -786,7 +906,7 @@ describe("RipgrepResultCache", () => { describe("large file set performance and memory", () => { it("should handle 500k files efficiently", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 500000) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 500000) mockSpawn.mockReturnValue(mockChildProcess) @@ -811,12 +931,12 @@ describe("RipgrepResultCache", () => { } const files = generateLargeFileSet() - const fileData = files.join("\n") + "\n" + const mockOutput = createMockRipgrepOutput(files) // Emit data in chunks to simulate real ripgrep behavior const chunkSize = 10000 - for (let i = 0; i < fileData.length; i += chunkSize) { - const chunk = fileData.slice(i, i + chunkSize) + for (let i = 0; i < mockOutput.length; i += chunkSize) { + const chunk = mockOutput.slice(i, i + chunkSize) mockChildProcess.stdout.emit("data", Buffer.from(chunk)) } @@ -859,7 +979,7 @@ describe("RipgrepResultCache", () => { }) it("should handle extreme case with deep nesting", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 10000) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 10000) mockSpawn.mockReturnValue(mockChildProcess) @@ -891,8 +1011,8 @@ describe("RipgrepResultCache", () => { } } - const fileData = deepPaths.join("\n") + "\n" - mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + const mockOutput = createMockRipgrepOutput(deepPaths) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) @@ -919,7 +1039,7 @@ describe("RipgrepResultCache", () => { it("should respect file limit and stop processing when reached", async () => { const fileLimit = 1000 - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], fileLimit) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], fileLimit) mockSpawn.mockReturnValue(mockChildProcess) @@ -928,10 +1048,10 @@ describe("RipgrepResultCache", () => { setTimeout(() => { // Generate more files than the limit const files = Array.from({ length: 2000 }, (_, i) => `file${i}.ts`) - const fileData = files.join("\n") + "\n" + const mockOutput = createMockRipgrepOutput(files) // Emit all data at once - mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) // Process should be killed when limit is reached expect(mockChildProcess.killed).toBe(true) @@ -957,7 +1077,7 @@ describe("RipgrepResultCache", () => { }) it("should handle memory-intensive file name patterns", async () => { - const cache = new RipgrepResultCache("/usr/bin/rg", "/test/workspace", [], 50000) + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 50000) mockSpawn.mockReturnValue(mockChildProcess) @@ -985,8 +1105,8 @@ describe("RipgrepResultCache", () => { memoryIntensivePaths.push(`unicode/${char}${i}.ts`) } - const fileData = memoryIntensivePaths.join("\n") + "\n" - mockChildProcess.stdout.emit("data", Buffer.from(fileData)) + const mockOutput = createMockRipgrepOutput(memoryIntensivePaths) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) mockChildProcess.emit("close", 0) }, 10) diff --git a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts index 3dfb0e9aabf7..fba56ee99d0a 100644 --- a/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts +++ b/src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts @@ -4,7 +4,7 @@ import WorkspaceTracker from "../WorkspaceTracker" import { ClineProvider } from "../../../core/webview/ClineProvider" import { listFiles } from "../../../services/glob/list-files" import { getWorkspacePath } from "../../../utils/path" -import { RipgrepResultCache } from "../RipgrepResultCache" +import { RipgrepResultCache, SimpleTreeNode } from "../RipgrepResultCache" // Mock functions - must be defined before vitest.mock calls const mockOnDidCreate = vitest.fn() @@ -477,6 +477,54 @@ describe("WorkspaceTracker", () => { // Since the file is ignored, fileRemoved should not be called expect(mockRipgrepCache.fileRemoved).not.toHaveBeenCalled() }) + + it("should handle deep tree to list conversion without stack overflow", async () => { + let deepTree: SimpleTreeNode = {} + let currentNode = deepTree + let LEVELS = 1000 + for (let i = 0; i < LEVELS; i++) { + currentNode["dir"] = {} + currentNode = currentNode["dir"] + } + let fileList = (workspaceTracker as any).treeToFileResults(deepTree) + expect(fileList.length).toBe(LEVELS) // we have 1000 levels, one folder entry for each level + + let longestPath = fileList.reduce((max: string, file: any) => { + return max.length > file.path.length ? max : file.path + }, "") + expect(longestPath).toBe(`dir${"/dir".repeat(LEVELS - 1)}`) + }) + + it("should handle platform-specific ripgrep paths correctly", async () => { + const isWindows = process.platform === "win32" + const mockTree = { + src: { + "file1.ts": true, + components: { + "Button.tsx": true + } + } + } + + // Mock the ripgrep cache to return our test tree + mockRipgrepCache.getTree.mockResolvedValue(mockTree) + + // Get the tree through WorkspaceTracker + const result = await workspaceTracker.getRipgrepFileTree() + + // Verify the tree structure is preserved + expect(result).toEqual(mockTree) + + // Test file notifications with platform-specific paths + const testPath = isWindows + ? "C:\\test\\workspace\\src\\components\\NewFile.tsx" + : "/test/workspace/src/components/NewFile.tsx" + + await createCallback({ fsPath: testPath }) + + // Verify the path is normalized before being passed to ripgrep cache + expect(mockRipgrepCache.fileAdded).toHaveBeenCalledWith(testPath) + }) }) describe("VSCode configuration support", () => { diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index fed8305074dd..d5e7d079a52c 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -5,7 +5,6 @@ import * as childProcess from "child_process" import * as readline from "readline" import { byLengthAsc, Fzf } from "fzf" import { getBinPath } from "../ripgrep" -import { SimpleTreeNode } from "../../integrations/workspace/RipgrepResultCache" export type FileResult = { path: string; type: "file" | "folder"; label?: string }