Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 7 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1400,10 +1400,16 @@ export const webviewMessageHandler = async (
break
}
try {
// Call file search service with query from message
// Get cached file list from WorkspaceTracker
const workspaceFiles = provider.workspaceTracker
? await provider.workspaceTracker.getRipgrepFileList()
: []

// Call file search service with query and cached files from message
const results = await searchWorkspaceFiles(
message.query || "",
workspacePath,
workspaceFiles,
20, // Use default limit, as filtering is now done in the backend
)

Expand Down
357 changes: 357 additions & 0 deletions src/integrations/workspace/RipgrepResultCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
import { spawn } from "child_process"
import { dirname, resolve as pathResolve, relative } from "path"

// Simplified tree structure - files represented by true, directories by nested objects
export type SimpleTreeNode = {
[key: string]: true | SimpleTreeNode
}

/**
* Ripgrep result cache class
* Provides file tree caching functionality with incremental updates
*/
export class RipgrepResultCache {
private rgPath: string
private _targetPath: string
private cachedTree: SimpleTreeNode | null = null
private invalidatedDirectories = new Set<string>()
private rgArgs: string[]
private currentBuildPromise: Promise<SimpleTreeNode> | 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<SimpleTreeNode> {
// 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<SimpleTreeNode> {
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<void> {
if (this.invalidatedDirectories.size === 0) {
return
}

try {
// Stream build subtrees for all invalid directories (pass directory paths directly)
const invalidDirectories = Array.from(this.invalidatedDirectories)
const subtree = await this.buildTreeStreaming(invalidDirectories)

// Merge subtrees into main tree (replace original invalid parts)
this.mergeInvalidatedSubtrees(subtree)
} catch (error) {
console.warn("Error updating invalid directories:", error)
// If incremental update fails, fallback to complete rebuild
this.cachedTree = await this.buildTreeStreaming()
}
}

/**
* Unified streaming tree building method (simplified version, builds SimpleTreeNode)
* @param targetPaths Array of target paths to scan, scans entire targetPath when empty
*/
private async buildTreeStreaming(targetPaths: string[] = []): Promise<SimpleTreeNode> {
return new Promise((resolve, reject) => {
// Build ripgrep arguments
const args = [...this.rgArgs]

// If target paths specified, use relative paths directly (ripgrep supports multiple paths)
if (targetPaths.length > 0) {
args.push(...targetPaths)
}

const child = spawn(this.rgPath, args, {
cwd: this._targetPath,
stdio: ["pipe", "pipe", "pipe"],
})

const tree: SimpleTreeNode = {}
let buffer = ""
let fileCount = 0

// Stream add file paths to simplified tree structure
const addFileToTree = (filePath: string) => {
// ripgrep output is already relative path, use directly
const parts = filePath.split("/").filter(Boolean)
let currentNode: SimpleTreeNode = tree

for (let i = 0; i < parts.length; i++) {
const part = parts[i]
const isFile = i === parts.length - 1 // Last part is file

if (isFile) {
// Files represented by true
currentNode[part] = true
fileCount++

// Check if file limit reached
if (fileCount >= this.fileLimit) {
child.kill()
return true // Indicate limit reached
}
} else {
// Directories represented by nested objects
if (!currentNode[part] || currentNode[part] === true) {
currentNode[part] = {}
}
currentNode = currentNode[part] as SimpleTreeNode
}
}
return false // Limit not reached
}

child.stdout.on("data", (data: Buffer) => {
buffer += data.toString()
const lines = buffer.split("\n")
buffer = lines.pop() || ""

for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine) {
const limitReached = addFileToTree(trimmedLine)
if (limitReached) {
break
}
}
}
})

let errorOutput = ""

child.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString()
})

child.on("close", (code: number | null) => {
// Process final buffer content
if (buffer.trim() && fileCount < this.fileLimit) {
addFileToTree(buffer.trim())
}

if (errorOutput && Object.keys(tree).length === 0) {
reject(new Error(`ripgrep process error: ${errorOutput}`))
} else {
resolve(tree)
}
})

child.on("error", (error: Error) => {
reject(error)
})
})
}

/**
* Merge invalidated subtrees into main tree
* subtree already contains complete content of all invalid directories, merge directly
*/
private mergeInvalidatedSubtrees(subtree: SimpleTreeNode): void {
if (!this.cachedTree) {
this.cachedTree = subtree
return
}

// Modify original object directly to avoid new object creation overhead
this.mergeSimpleTreeNodesInPlace(this.cachedTree, subtree)
}

/**
* In-place merge two simplified tree nodes (optimized version, reduces object creation)
*/
private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void {
for (const [key, value] of Object.entries(newTree)) {
if (value === true) {
// New node is file, overwrite directly
existing[key] = true
} else {
// New node is directory
if (!existing[key] || existing[key] === true) {
// Original doesn't exist or is file, replace with directory directly
existing[key] = value
} else {
// Original is also directory, merge recursively
this.mergeSimpleTreeNodesInPlace(existing[key] as SimpleTreeNode, value)
}
}
}
}

/**
* Clear cache
*/
clearCache(): void {
this.cachedTree = null
this.invalidatedDirectories.clear()
this.currentBuildPromise = null
}
}
Loading
Loading