diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 116c78d76096..9db712b8bae2 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 080fbbcd9437..81c6e091bb24 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1698,10 +1698,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/RipgrepResultCache.ts b/src/integrations/workspace/RipgrepResultCache.ts new file mode 100644 index 000000000000..48620e84e855 --- /dev/null +++ b/src/integrations/workspace/RipgrepResultCache.ts @@ -0,0 +1,365 @@ +import { spawn } from "child_process" +import { dirname, resolve as pathResolve, relative, sep } 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).map((dir) => dir.split("/").join(sep)) + 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(sep).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) + * Uses Object.hasOwn for safe property checks to prevent prototype pollution + */ + private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void { + 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) { + existing[key] = true + } else { + if (!existing[key] || existing[key] === true) { + existing[key] = value + } else { + 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 546cd97cd17f..c3ac2bc95b52 100644 --- a/src/integrations/workspace/WorkspaceTracker.ts +++ b/src/integrations/workspace/WorkspaceTracker.ts @@ -4,6 +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 { RipgrepResultCache, SimpleTreeNode } from "./RipgrepResultCache" +import { getBinPath } from "../../services/ripgrep" +import { FileResult } from "../../services/search/file-search" const MAX_INITIAL_FILES = 1_000 @@ -16,14 +19,165 @@ class WorkspaceTracker { private prevWorkSpacePath: string | undefined private resetTimer: NodeJS.Timeout | null = null + // RipgrepResultCache related properties + private ripgrepCache: RipgrepResultCache | null = null + private cachedRipgrepPath: string | null = null + get cwd() { return this.providerRef?.deref()?.cwd ?? getWorkspacePath() } + constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) 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 complete ripgrep arguments based on VSCode search configuration + */ + private getRipgrepArgs(): string[] { + const config = vscode.workspace.getConfiguration("search") + const args: string[] = ["--files", "--follow", "--hidden"] + + const useIgnoreFiles = config.get("useIgnoreFiles", true) + + if (!useIgnoreFiles) { + args.push("--no-ignore") + } else { + const useGlobalIgnoreFiles = config.get("useGlobalIgnoreFiles", true) + const useParentIgnoreFiles = config.get("useParentIgnoreFiles", true) + + if (!useGlobalIgnoreFiles) { + args.push("--no-ignore-global") + } + + if (!useParentIgnoreFiles) { + args.push("--no-ignore-parent") + } + } + + // 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 tree using RipgrepResultCache + * This provides a more complete and efficient file structure than the limited filePaths set + */ + async getRipgrepFileTree(): Promise { + const currentWorkspacePath = this.cwd + + if (!currentWorkspacePath) { + return {} + } + + // Check if we need to recreate the cache + await this.ensureRipgrepCache() + + if (!this.ripgrepCache) { + return {} + } + + try { + return await this.ripgrepCache.getTree() + } catch (error) { + return {} + } + } + + async getRipgrepFileList(): Promise { + const tree = await this.getRipgrepFileTree() + return this.treeToFileResults(tree) + } + + /** + * 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]) + } + } + } + + 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, + this.getRipgrepArgs(), + this.getRipgrepFileLimit(), + ) + } + } + + /** + * 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 + } + + /** + * Helper method to compare arrays + */ + 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() { // 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 +190,9 @@ class WorkspaceTracker { } files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file))) this.workspaceDidUpdate() + + // preheat file tree + this.getRipgrepFileTree() } private registerListeners() { @@ -43,7 +200,13 @@ class WorkspaceTracker { this.prevWorkSpacePath = this.cwd this.disposables.push( watcher.onDidCreate(async (uri) => { - await this.addFilePath(uri.fsPath) + const fsPath = uri.fsPath + if (this.ripgrepCache) { + if (!this.isPathIgnoredByRipgrep(fsPath)) { + this.ripgrepCache.fileAdded(fsPath) + } + } + await this.addFilePath(fsPath) this.workspaceDidUpdate() }), ) @@ -51,7 +214,13 @@ class WorkspaceTracker { // Renaming files triggers a delete and create event this.disposables.push( watcher.onDidDelete(async (uri) => { - if (await this.removeFilePath(uri.fsPath)) { + const fsPath = uri.fsPath + if (this.ripgrepCache) { + if (!this.isPathIgnoredByRipgrep(fsPath)) { + this.ripgrepCache.fileRemoved(fsPath) + } + } + if (await this.removeFilePath(fsPath)) { this.workspaceDidUpdate() } }), @@ -71,6 +240,21 @@ 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") || + event.affectsConfiguration("roo-cline.maximumIndexedFilesForFileSearch") + ) { + this.ripgrepCache = null + } + }), + ) } private getOpenedTabsInfo() { @@ -97,6 +281,8 @@ class WorkspaceTracker { } this.resetTimer = setTimeout(async () => { if (this.prevWorkSpacePath !== this.cwd) { + // Clear cache when workspace changes + 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..2a6d847bf9f0 --- /dev/null +++ b/src/integrations/workspace/__tests__/RipgrepResultCache.spec.ts @@ -0,0 +1,1125 @@ +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() + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + expect(cache.targetPath).toBe(resolve(TEST_PATHS.workspace)) + }) + + it("should resolve target path", () => { + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.relativeWorkspace) + + expect(cache.targetPath).toContain("test" + sep + "workspace") + }) + + it("should use custom file limit", () => { + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 10000) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Simulate successful ripgrep output + const treePromise = cache.getTree() + + // Simulate ripgrep output + setTimeout(() => { + 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(TEST_PATHS.rgExecutable, ["--files"], { + cwd: resolve(TEST_PATHS.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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // First call + const firstPromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Start first build + const firstPromise = cache.getTree() + + // Start second build immediately + const secondPromise = cache.getTree() + + // Complete the build + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, [], 2) + + const treePromise = cache.getTree() + + setTimeout(() => { + // Send more files than the limit + 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Build initial tree + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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() + + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(newFilePath) + + // Next getTree call should trigger rebuild + const treePromise = cache.getTree() + setTimeout(() => { + 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) + + 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() + + const removedFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "file1.ts") + cache.fileRemoved(removedFilePath) + + // Next getTree call should trigger rebuild + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/utils/helper.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(() => { + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/relative.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + }) + + describe("incremental updates", () => { + let cache: RipgrepResultCache + + beforeEach(async () => { + cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Build initial tree + const treePromise = cache.getTree() + setTimeout(() => { + 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 + }) + + it("should perform incremental update for invalidated directories", async () => { + // Add file to invalidate src directory + 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(() => { + 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) + + 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 + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "components", "Icon.tsx") + cache.fileAdded(newFilePath) + mockSpawn.mockClear() + + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput([ + "src/components/Button.tsx", + "src/components/Icon.tsx" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + + it("should avoid duplicate invalidation for parent directories", async () => { + // First invalidate parent directory + const parentFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(parentFilePath) + + // Then try to invalidate child directory (should be ignored) + const childFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "components", "NewComponent.tsx") + cache.fileAdded(childFilePath) + + mockSpawn.mockClear() + + const treePromise = cache.getTree() + setTimeout(() => { + 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) + + 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(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(() => { + 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 + 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({ + 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(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(() => { + 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) + + 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(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(() => { + 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, customArgs) + + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace, []) + + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await treePromise + + expect(mockSpawn).toHaveBeenCalledWith(TEST_PATHS.rgExecutable, ["--files"], { + cwd: resolve(TEST_PATHS.workspace), + stdio: ["pipe", "pipe", "pipe"], + }) + }) + }) + + describe("clearCache", () => { + it("should clear all cached data", async () => { + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Build initial cache + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + await treePromise + + // Clear cache + cache.clearCache() + + // Next call should rebuild + mockSpawn.mockClear() + const newTreePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await newTreePromise + + expect(mockSpawn).toHaveBeenCalled() + }) + }) + + describe("edge cases", () => { + it("should handle empty ripgrep output", async () => { + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["a/b/c/d/e/f/deep.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput([ + "src/utils.ts", + "src/utils/helper.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + // 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + const treePromise = cache.getTree() + setTimeout(() => { + 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) + + 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(TEST_PATHS.rgExecutable, TEST_PATHS.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(() => { + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/file2.ts", + "lib/utils.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.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(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + mockChildProcess.emit("close", 0) + }, 10) + + await retryPromise + expect(mockSpawn).toHaveBeenCalledTimes(1) + }) + + it("should handle concurrent calls after invalidation", async () => { + const cache = new RipgrepResultCache(TEST_PATHS.rgExecutable, TEST_PATHS.workspace) + + // Build initial cache + mockSpawn.mockReturnValue(mockChildProcess) + const initialPromise = cache.getTree() + setTimeout(() => { + const mockOutput = createMockRipgrepOutput(["src/file1.ts"]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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 + const newFilePath = createAbsolutePath(TEST_PATHS.workspace, "src", "newfile.ts") + cache.fileAdded(newFilePath) + + // 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(() => { + const mockOutput = createMockRipgrepOutput([ + "src/file1.ts", + "src/newfile.ts" + ]) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.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 mockOutput = createMockRipgrepOutput(files) + + // Emit data in chunks to simulate real ripgrep behavior + const chunkSize = 10000 + for (let i = 0; i < mockOutput.length; i += chunkSize) { + const chunk = mockOutput.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(TEST_PATHS.rgExecutable, TEST_PATHS.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 mockOutput = createMockRipgrepOutput(deepPaths) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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(TEST_PATHS.rgExecutable, TEST_PATHS.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 mockOutput = createMockRipgrepOutput(files) + + // Emit all data at once + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + + // 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(TEST_PATHS.rgExecutable, TEST_PATHS.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 mockOutput = createMockRipgrepOutput(memoryIntensivePaths) + mockChildProcess.stdout.emit("data", Buffer.from(mockOutput)) + 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 b0a617d97096..fba56ee99d0a 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 { RipgrepResultCache, SimpleTreeNode } from "../RipgrepResultCache" // 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,26 @@ vitest.mock("../../../utils/path", () => ({ }), })) +// 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 }), @@ -45,6 +68,9 @@ vitest.mock("vscode", () => ({ }, onDidChangeActiveTextEditor: vitest.fn(() => ({ dispose: vitest.fn() })), }, + env: { + appRoot: "/test/vscode", + }, workspace: { workspaceFolders: [ { @@ -57,6 +83,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 }, })) @@ -75,10 +106,30 @@ describe("WorkspaceTracker", () => { // Reset all mock implementations registeredTabChangeCallback = null + registeredConfigChangeCallback = null // Reset workspace path mock ;(getWorkspacePath as Mock).mockReturnValue("/test/workspace") + // 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 + case "maximumIndexedFilesForFileSearch": + return 200000 + default: + return defaultValue + } + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + // Create provider mock mockProvider = { postMessageToWebview: vitest.fn().mockResolvedValue(undefined), @@ -89,6 +140,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 +400,461 @@ describe("WorkspaceTracker", () => { // No postMessage should be called after dispose expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) + + 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) + + // Get the callbacks before clearing mocks + createCallback = mockOnDidCreate.mock.calls[0][0] + deleteCallback = mockOnDidDelete.mock.calls[0][0] + }) + + it("should provide getRipgrepFileTree method", () => { + expect(typeof workspaceTracker.getRipgrepFileTree).toBe("function") + }) + + it("should return empty tree when no workspace path", async () => { + ;(getWorkspacePath as Mock).mockReturnValue(undefined) + + const result = await workspaceTracker.getRipgrepFileTree() + + expect(result).toEqual({}) + }) + + it("should notify ripgrep cache when non-ignored file is added", async () => { + const testPath = "/test/workspace/src/file.ts" + + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache + + await createCallback({ fsPath: testPath }) + + // Verify the cache method was called + expect(mockRipgrepCache.fileAdded).toHaveBeenCalledWith(testPath) + }) + + 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 + + await createCallback({ fsPath: testPath }) + + // Since the file is ignored, fileAdded should not be called + expect(mockRipgrepCache.fileAdded).not.toHaveBeenCalled() + }) + + it("should notify ripgrep cache when non-ignored file is deleted", async () => { + const testPath = "/test/workspace/src/file.ts" + + ;(workspaceTracker as any).ripgrepCache = mockRipgrepCache + + await deleteCallback({ fsPath: testPath }) + + // Verify the cache method was called + expect(mockRipgrepCache.fileRemoved).toHaveBeenCalledWith(testPath) + }) + + 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 + + await deleteCallback({ fsPath: testPath }) + + // 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", () => { + 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) => { + if (key === "useIgnoreFiles") return false + return defaultValue ?? true + }), + } + ;(vscode.workspace.getConfiguration as Mock).mockReturnValue(mockConfig) + + let args = await (workspaceTracker as any).getRipgrepArgs() + + expect(args.includes("--no-ignore")).toBe(true) + }) + + 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) + + let args = await (workspaceTracker as any).getRipgrepArgs() + + expect(args.includes("--no-ignore-global")).toBe(true) + }) + + 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) + + let args = await (workspaceTracker as any).getRipgrepArgs() + + expect(args.includes("--no-ignore-parent")).toBe(true) + }) + + 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) + + let args = await (workspaceTracker as any).getRipgrepArgs() + + expect(args.includes("--no-ignore")).toBe(true) + }) + + 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 for roo-cline config + const mockEvent = { + affectsConfiguration: vitest.fn( + (section: string) => section === "roo-cline.maximumIndexedFilesForFileSearch", + ), + } + + // This should trigger cache clearing + registeredConfigChangeCallback!(mockEvent) + + // 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") + + // Call getRipgrepFileTree again to trigger new cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) + }) + + 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.useIgnoreFiles"), + } + + // This should trigger cache clearing + registeredConfigChangeCallback!(mockEvent) + + // 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") + + // Call getRipgrepFileTree again to trigger new cache creation + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) + }) + + 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 === "editor.fontSize"), + } + + // This should not trigger cache clearing + registeredConfigChangeCallback!(mockEvent) + + // 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") + + // Call getRipgrepFileTree again - should not create new cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) // No additional calls + }) + + 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) + + // Start getRipgrepFileTree (this will hang until we resolve it) + const result = await workspaceTracker.getRipgrepFileTree() + + // Should return the result from the ongoing build + expect(result).toEqual({ src: { "file1.ts": true } }) + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) + + // Next call should create a new cache because config changed + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) + }) + + it("should create new cache after configuration change even if old cache exists", async () => { + // Initialize first cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(1) + + // Simulate configuration change + const mockEvent = { + affectsConfiguration: vitest.fn( + (section: string) => section === "roo-cline.maximumIndexedFilesForFileSearch", + ), + } + registeredConfigChangeCallback!(mockEvent) + + // Verify cache was cleared (access private property for testing) + expect((workspaceTracker as any).ripgrepCache).toBeNull() + + // Next call should create new cache + await workspaceTracker.getRipgrepFileTree() + expect(RipgrepResultCache).toHaveBeenCalledTimes(2) + }) + }) + + 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) + }) + + 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", + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } + }) + + 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", + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } + }) + + 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", + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(true) + } + }) + + 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 + ] + + for (const path of testPaths) { + const isIgnored = (workspaceTracker as any).isPathIgnoredByRipgrep(path) + expect(isIgnored).toBe(false) + } + }) + + 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 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) + } + }) + + 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 + ] + + 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 34808a28c01a..7bfec9650524 100644 --- a/src/package.json +++ b/src/package.json @@ -384,6 +384,13 @@ "default": "", "description": "%settings.autoImportSettingsPath.description%" }, + "roo-cline.maximumIndexedFilesForFileSearch": { + "type": "number", + "default": 200000, + "minimum": 5000, + "maximum": 500000, + "description": "%settings.maximumIndexedFilesForFileSearch.description%" + }, "roo-cline.useAgentRules": { "type": "boolean", "default": true, diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 537a4522b2d7..afcc7a8c1bfc 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Activa la càrrega de fitxers AGENTS.md per a regles específiques de l'agent (vegeu https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Temps màxim en segons per esperar les respostes de l'API (0 = sense temps d'espera, 1-3600s, per defecte: 600s). Es recomanen valors més alts per a proveïdors locals com LM Studio i Ollama que poden necessitar més temps de processament.", "settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60." diff --git a/src/package.nls.de.json b/src/package.nls.de.json index fb43e2890728..090f6f5b7c91 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Aktiviert das Laden von AGENTS.md-Dateien für agentenspezifische Regeln (siehe https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximale Wartezeit in Sekunden auf API-Antworten (0 = kein Timeout, 1-3600s, Standard: 600s). Höhere Werte werden für lokale Anbieter wie LM Studio und Ollama empfohlen, die möglicherweise mehr Verarbeitungszeit benötigen.", "settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60." diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 95029057a9db..653f87a105f9 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Habilita la carga de archivos AGENTS.md para reglas específicas del agente (ver https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tiempo máximo en segundos de espera para las respuestas de la API (0 = sin tiempo de espera, 1-3600s, por defecto: 600s). Se recomiendan valores más altos para proveedores locales como LM Studio y Ollama que puedan necesitar más tiempo de procesamiento.", "settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60." diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 3939451d673f..0f360cc90f90 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Activer le chargement des fichiers AGENTS.md pour les règles spécifiques à l'agent (voir https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Temps maximum en secondes d'attente pour les réponses de l'API (0 = pas de timeout, 1-3600s, par défaut : 600s). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux comme LM Studio et Ollama qui peuvent nécessiter plus de temps de traitement.", "settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60." diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 25481f425fab..7619c8e29cce 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "कस्टम स्टोरेज पाथ। डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ें। पूर्ण पथ का समर्थन करता है (उदा. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code त्वरित सुधार सक्षम करें", "settings.autoImportSettingsPath.description": "RooCode कॉन्फ़िगरेशन फ़ाइल का पथ जिसे एक्सटेंशन स्टार्टअप पर स्वचालित रूप से आयात किया जाएगा। होम डायरेक्टरी के सापेक्ष पूर्ण पथ और पथों का समर्थन करता है (उदाहरण के लिए '~/Documents/roo-code-settings.json')। ऑटो-इंपोर्ट को अक्षम करने के लिए खाली छोड़ दें।", + "settings.maximumIndexedFilesForFileSearch.description": "इनपुट बॉक्स में फाइल खोज कार्यक्षमता के लिए इंडेक्स बनाते समय इंडेक्स की जाने वाली फाइलों की अधिकतम संख्या निर्दिष्ट करता है। बड़ी संख्या बड़ी परियोजनाओं में बेहतर खोज परिणाम प्रदान करती है लेकिन अधिक मेमोरी का उपयोग कर सकती है।", "settings.useAgentRules.description": "एजेंट-विशिष्ट नियमों के लिए AGENTS.md फ़ाइलों को लोड करना सक्षम करें (देखें https://agent-rules.org/)", "settings.apiRequestTimeout.description": "एपीआई प्रतिक्रियाओं की प्रतीक्षा करने के लिए सेकंड में अधिकतम समय (0 = कोई टाइमआउट नहीं, 1-3600s, डिफ़ॉल्ट: 600s)। एलएम स्टूडियो और ओलामा जैसे स्थानीय प्रदाताओं के लिए उच्च मानों की सिफारिश की जाती है जिन्हें अधिक प्रसंस्करण समय की आवश्यकता हो सकती है।", "settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।" diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 0c69028e9174..a8d5ff7a1daf 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Aktifkan pemuatan file AGENTS.md untuk aturan khusus agen (lihat https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.", "settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60." diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 5ce3a7656685..5e18d19cde79 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Abilita il caricamento dei file AGENTS.md per regole specifiche dell'agente (vedi https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tempo massimo in secondi di attesa per le risposte API (0 = nessun timeout, 1-3600s, predefinito: 600s). Valori più alti sono consigliati per provider locali come LM Studio e Ollama che potrebbero richiedere più tempo di elaborazione.", "settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60." diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index b53b94e6ee48..8a8752a3e97d 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "カスタムストレージパス。デフォルトの場所を使用する場合は空のままにします。絶対パスをサポートします(例:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Codeのクイック修正を有効にする。", "settings.autoImportSettingsPath.description": "拡張機能の起動時に自動的にインポートするRooCode設定ファイルへのパス。絶対パスとホームディレクトリからの相対パスをサポートします(例:'~/Documents/roo-code-settings.json')。自動インポートを無効にするには、空のままにします。", + "settings.maximumIndexedFilesForFileSearch.description": "入力ボックスのファイル検索機能のためのインデックス作成時に、インデックス化するファイルの最大数を指定します。数値が大きいほど大規模プロジェクトでの検索結果が向上しますが、より多くのメモリを消費する可能性があります。", "settings.useAgentRules.description": "エージェント固有のルールのためにAGENTS.mdファイルの読み込みを有効にします(参照:https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API応答を待機する最大時間(秒)(0 = タイムアウトなし、1-3600秒、デフォルト: 600秒)。LM StudioやOllamaのような、より多くの処理時間を必要とする可能性のあるローカルプロバイダーには、より高い値が推奨されます。", "settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。" diff --git a/src/package.nls.json b/src/package.nls.json index b0b7f401f856..ae4c27986651 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -38,6 +38,7 @@ "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.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.", "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index bd03331d4ec4..657336d52e75 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "사용자 지정 저장소 경로. 기본 위치를 사용하려면 비워두세요. 절대 경로를 지원합니다 (예: 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Roo Code 빠른 수정 사용 설정", "settings.autoImportSettingsPath.description": "확장 프로그램 시작 시 자동으로 가져올 RooCode 구성 파일의 경로입니다. 절대 경로 및 홈 디렉토리에 대한 상대 경로를 지원합니다(예: '~/Documents/roo-code-settings.json'). 자동 가져오기를 비활성화하려면 비워 둡니다.", + "settings.maximumIndexedFilesForFileSearch.description": "입력 상자의 파일 검색 기능을 위한 인덱스 생성 시 최대 인덱스할 파일 수를 지정합니다. 숫자가 클수록 대형 프로젝트에서 더 나은 검색 결과를 제공하지만 더 많은 메모리를 사용할 수 있습니다.", "settings.useAgentRules.description": "에이전트별 규칙에 대한 AGENTS.md 파일 로드를 활성화합니다 (참조: https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API 응답을 기다리는 최대 시간(초) (0 = 시간 초과 없음, 1-3600초, 기본값: 600초). 더 많은 처리 시간이 필요할 수 있는 LM Studio 및 Ollama와 같은 로컬 공급자에게는 더 높은 값을 사용하는 것이 좋습니다.", "settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다." diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 683a096c122b..8a9815758b15 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Laden van AGENTS.md-bestanden voor agentspecifieke regels inschakelen (zie https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximale tijd in seconden om te wachten op API-reacties (0 = geen time-out, 1-3600s, standaard: 600s). Hogere waarden worden aanbevolen voor lokale providers zoals LM Studio en Ollama die mogelijk meer verwerkingstijd nodig hebben.", "settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60." diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 76c10ddfe00b..24656a9939f0 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Włącz wczytywanie plików AGENTS.md dla reguł specyficznych dla agenta (zobacz https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maksymalny czas w sekundach oczekiwania na odpowiedzi API (0 = brak limitu czasu, 1-3600s, domyślnie: 600s). Wyższe wartości są zalecane dla lokalnych dostawców, takich jak LM Studio i Ollama, którzy mogą potrzebować więcej czasu na przetwarzanie.", "settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60." diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 85cea4d87001..eaf1ee10688d 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Habilita o carregamento de arquivos AGENTS.md para regras específicas do agente (consulte https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Tempo máximo em segundos de espera pelas respostas da API (0 = sem tempo limite, 1-3600s, padrão: 600s). Valores mais altos são recomendados para provedores locais como LM Studio e Ollama que podem precisar de mais tempo de processamento.", "settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60." diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index 83f32373a910..b24af252aba5 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "Пользовательский путь хранения. Оставьте пустым для использования пути по умолчанию. Поддерживает абсолютные пути (например, 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Включить быстрые исправления Roo Code.", "settings.autoImportSettingsPath.description": "Путь к файлу конфигурации RooCode для автоматического импорта при запуске расширения. Поддерживает абсолютные пути и пути относительно домашнего каталога (например, '~/Documents/roo-code-settings.json'). Оставьте пустым, чтобы отключить автоматический импорт.", + "settings.maximumIndexedFilesForFileSearch.description": "Указывает максимальное количество файлов для индексации при создании индекса для функции поиска файлов в поле ввода. Большее число обеспечивает лучшие результаты поиска в крупных проектах, но может потреблять больше памяти.", "settings.useAgentRules.description": "Включить загрузку файлов AGENTS.md для специфичных для агента правил (см. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Максимальное время в секундах для ожидания ответов API (0 = нет тайм-аута, 1-3600 с, по умолчанию: 600 с). Рекомендуются более высокие значения для локальных провайдеров, таких как LM Studio и Ollama, которым может потребоваться больше времени на обработку.", "settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60." diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index faf520c0d260..c8311ec24311 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Aracıya özgü kurallar için AGENTS.md dosyalarının yüklenmesini etkinleştirin (bkz. https://agent-rules.org/)", "settings.apiRequestTimeout.description": "API yanıtları için beklenecek maksimum süre (saniye cinsinden) (0 = zaman aşımı yok, 1-3600s, varsayılan: 600s). LM Studio ve Ollama gibi daha fazla işlem süresi gerektirebilecek yerel sağlayıcılar için daha yüksek değerler önerilir.", "settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır." diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 672707a111ee..1521f3dfec3f 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -37,6 +37,7 @@ "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.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.", "settings.useAgentRules.description": "Bật tải tệp AGENTS.md cho các quy tắc dành riêng cho tác nhân (xem https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Thời gian tối đa tính bằng giây để đợi phản hồi API (0 = không có thời gian chờ, 1-3600 giây, mặc định: 600 giây). Nên sử dụng các giá trị cao hơn cho các nhà cung cấp cục bộ như LM Studio và Ollama có thể cần thêm thời gian xử lý.", "settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60." diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 94d0ed6c7472..222a3ed7ddba 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "自定义存储路径。留空以使用默认位置。支持绝对路径(例如:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "启用 Roo Code 快速修复", "settings.autoImportSettingsPath.description": "RooCode 配置文件的路径,用于在扩展启动时自动导入。支持绝对路径和相对于主目录的路径(例如 '~/Documents/roo-code-settings.json')。留空以禁用自动导入。", + "settings.maximumIndexedFilesForFileSearch.description": "指定为输入框的文件搜索功能创建索引时,最多索引的文件个数,数量越大在大项目中的搜索效果越好,但可能占用更多内存。", "settings.useAgentRules.description": "为特定于代理的规则启用 AGENTS.md 文件的加载(请参阅 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "等待 API 响应的最长时间(秒)(0 = 无超时,1-3600秒,默认值:600秒)。对于像 LM Studio 和 Ollama 这样可能需要更多处理时间的本地提供商,建议使用更高的值。", "settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。" diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index b4fd9e3cc7ce..46cd76a5b633 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -37,6 +37,7 @@ "settings.customStoragePath.description": "自訂儲存路徑。留空以使用預設位置。支援絕對路徑(例如:'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "啟用 Roo Code 快速修復。", "settings.autoImportSettingsPath.description": "RooCode 設定檔案的路徑,用於在擴充功能啟動時自動匯入。支援絕對路徑和相對於主目錄的路徑(例如 '~/Documents/roo-code-settings.json')。留空以停用自動匯入。", + "settings.maximumIndexedFilesForFileSearch.description": "指定為輸入框的檔案搜尋功能建立索引時,最多索引的檔案個數,數量越大在大專案中的搜尋效果越好,但可能佔用更多記憶體。", "settings.useAgentRules.description": "為特定於代理的規則啟用 AGENTS.md 檔案的載入(請參閱 https://agent-rules.org/)", "settings.apiRequestTimeout.description": "等待 API 回應的最長時間(秒)(0 = 無超時,1-3600秒,預設值:600秒)。對於像 LM Studio 和 Ollama 這樣可能需要更多處理時間的本地提供商,建議使用更高的值。", "settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。" diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts index a25dd4068f97..d5e7d079a52c 100644 --- a/src/services/search/file-search.ts +++ b/src/services/search/file-search.ts @@ -85,57 +85,35 @@ export async function executeRipgrep({ }) } -export async function executeRipgrepForFiles( - workspacePath: string, - limit: number = 5000, -): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> { - const args = [ - "--files", - "--follow", - "--hidden", - "-g", - "!**/node_modules/**", - "-g", - "!**/.git/**", - "-g", - "!**/out/**", - "-g", - "!**/dist/**", - workspacePath, - ] - - return executeRipgrep({ args, workspacePath, limit }) -} - export async function searchWorkspaceFiles( query: string, workspacePath: string, + workspaceList: 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) + 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) + // Get all matching results from fzf and generate labels + const fzfResults = fzf.find(query).map((result) => ({ + ...result.item, + label: path.basename(result.item.path), + })) // Verify types of the shortest results const verifiedResults = await Promise.all(