Skip to content

Commit f44c510

Browse files
committed
tree based RipgrepResultCache implementation
# Conflicts: # src/integrations/workspace/__tests__/WorkspaceTracker.spec.ts
1 parent a67b2dc commit f44c510

24 files changed

+1824
-312
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { spawn } from "child_process"
2+
import { dirname, resolve as pathResolve, relative } from "path"
3+
4+
// Simplified tree structure - files represented by true, directories by nested objects
5+
export type SimpleTreeNode = {
6+
[key: string]: true | SimpleTreeNode
7+
}
8+
9+
/**
10+
* Ripgrep result cache class
11+
* Provides file tree caching functionality with incremental updates
12+
*/
13+
export class RipgrepResultCache {
14+
private rgPath: string
15+
private _targetPath: string
16+
private cachedTree: SimpleTreeNode | null = null
17+
private invalidatedDirectories = new Set<string>()
18+
private rgArgs: string[]
19+
private currentBuildPromise: Promise<SimpleTreeNode> | null = null
20+
private fileLimit: number
21+
22+
constructor(rgPath: string, targetPath: string, rgArgs: string[] = [], fileLimit: number = 5000) {
23+
this.rgPath = rgPath
24+
this._targetPath = pathResolve(targetPath)
25+
this.fileLimit = fileLimit
26+
this.rgArgs = rgArgs.length > 0 ? rgArgs : ["--files"]
27+
}
28+
29+
get targetPath(): string {
30+
return this._targetPath
31+
}
32+
33+
/**
34+
* Asynchronously get file tree
35+
* - If there's valid cache and no invalid directories, return cache
36+
* - If currently building, wait for current build result
37+
* - Otherwise trigger new build
38+
*/
39+
async getTree(): Promise<SimpleTreeNode> {
40+
// If there's valid cache, return directly
41+
if (this.cachedTree && this.invalidatedDirectories.size === 0) {
42+
return this.cachedTree
43+
}
44+
45+
// If already building, wait for current build result
46+
if (this.currentBuildPromise) {
47+
return this.currentBuildPromise
48+
}
49+
50+
// Start new build
51+
try {
52+
this.currentBuildPromise = this.buildTree()
53+
const result = await this.currentBuildPromise
54+
return result
55+
} finally {
56+
// Clear Promise cache after build completion
57+
this.currentBuildPromise = null
58+
}
59+
}
60+
61+
/**
62+
* Internal method: build or update tree
63+
*/
64+
private async buildTree(): Promise<SimpleTreeNode> {
65+
try {
66+
if (this.cachedTree && this.invalidatedDirectories.size > 0) {
67+
// Has cache but has invalid directories, perform incremental update
68+
await this.updateInvalidatedDirectories()
69+
} else {
70+
// No cache, complete rebuild
71+
this.cachedTree = await this.buildTreeStreaming()
72+
}
73+
74+
// Clear invalid directory markers
75+
this.invalidatedDirectories.clear()
76+
return this.cachedTree
77+
} catch (error) {
78+
// Clear cache state on error
79+
this.cachedTree = null
80+
this.invalidatedDirectories.clear()
81+
throw error
82+
}
83+
}
84+
85+
/**
86+
* Called when file is added
87+
* Mark parent directory as invalid and remove corresponding subtree from tree
88+
*/
89+
fileAdded(filePath: string): void {
90+
this.fileAddedOrRemoved(filePath)
91+
}
92+
93+
/**
94+
* Called when file is removed
95+
* Mark parent directory as invalid and remove corresponding subtree from tree
96+
*/
97+
fileRemoved(filePath: string): void {
98+
this.fileAddedOrRemoved(filePath)
99+
}
100+
101+
private fileAddedOrRemoved(filePath: string): void {
102+
const relativePath = relative(this._targetPath, pathResolve(this._targetPath, filePath))
103+
const parentDir = dirname(relativePath)
104+
105+
if (parentDir !== "." && parentDir !== "") {
106+
this.invalidateDirectory(parentDir)
107+
}
108+
}
109+
110+
/**
111+
* Mark directory as invalid
112+
* Check containment relationship with existing invalid directories to avoid duplicate marking
113+
*/
114+
private invalidateDirectory(dirPath: string): void {
115+
if (!this.cachedTree) {
116+
return
117+
}
118+
119+
const normalizedPath = dirPath.replace(/\\/g, "/")
120+
121+
// Check if already contained by larger scope invalid directory
122+
for (const invalidDir of this.invalidatedDirectories) {
123+
if (normalizedPath.startsWith(invalidDir + "/") || normalizedPath === invalidDir) {
124+
// Current directory already contained in invalid directory, no need to mark again
125+
return
126+
}
127+
}
128+
129+
// Remove existing invalid directories contained by current directory
130+
const toRemove: string[] = []
131+
for (const invalidDir of this.invalidatedDirectories) {
132+
if (invalidDir.startsWith(normalizedPath + "/")) {
133+
toRemove.push(invalidDir)
134+
}
135+
}
136+
137+
// Remove contained invalid directories
138+
for (const dir of toRemove) {
139+
this.invalidatedDirectories.delete(dir)
140+
}
141+
142+
// Mark current directory as invalid
143+
this.invalidatedDirectories.add(normalizedPath)
144+
145+
// Remove corresponding subtree from cache tree
146+
this.removeDirectoryFromTree(normalizedPath)
147+
}
148+
149+
/**
150+
* Remove specified directory subtree from simplified tree
151+
*/
152+
private removeDirectoryFromTree(dirPath: string): void {
153+
if (!this.cachedTree) {
154+
return
155+
}
156+
157+
const pathParts = dirPath.split("/").filter(Boolean)
158+
this.removeNodeByPath(this.cachedTree, pathParts, 0)
159+
}
160+
161+
/**
162+
* Recursively remove simplified tree node
163+
*/
164+
private removeNodeByPath(tree: SimpleTreeNode, pathParts: string[], depth: number): boolean {
165+
if (depth >= pathParts.length) {
166+
return false
167+
}
168+
169+
const currentPart = pathParts[depth]
170+
171+
if (!(currentPart in tree)) {
172+
return false
173+
}
174+
175+
if (depth === pathParts.length - 1) {
176+
// Found target node, remove it
177+
delete tree[currentPart]
178+
return true
179+
}
180+
181+
// Continue searching in child nodes
182+
const childNode = tree[currentPart]
183+
if (childNode !== true && typeof childNode === "object") {
184+
const removed = this.removeNodeByPath(childNode, pathParts, depth + 1)
185+
186+
// If child node is removed and current node is empty object, remove current node
187+
if (removed && Object.keys(childNode).length === 0) {
188+
delete tree[currentPart]
189+
return true
190+
}
191+
}
192+
193+
return false
194+
}
195+
196+
/**
197+
* Update directories marked as invalid
198+
* Use ripgrep's multi-path support to update all invalid directories at once
199+
*/
200+
private async updateInvalidatedDirectories(): Promise<void> {
201+
if (this.invalidatedDirectories.size === 0) {
202+
return
203+
}
204+
205+
try {
206+
// Stream build subtrees for all invalid directories (pass directory paths directly)
207+
const invalidDirectories = Array.from(this.invalidatedDirectories)
208+
const subtree = await this.buildTreeStreaming(invalidDirectories)
209+
210+
// Merge subtrees into main tree (replace original invalid parts)
211+
this.mergeInvalidatedSubtrees(subtree)
212+
} catch (error) {
213+
console.warn("Error updating invalid directories:", error)
214+
// If incremental update fails, fallback to complete rebuild
215+
this.cachedTree = await this.buildTreeStreaming()
216+
}
217+
}
218+
219+
/**
220+
* Unified streaming tree building method (simplified version, builds SimpleTreeNode)
221+
* @param targetPaths Array of target paths to scan, scans entire targetPath when empty
222+
*/
223+
private async buildTreeStreaming(targetPaths: string[] = []): Promise<SimpleTreeNode> {
224+
return new Promise((resolve, reject) => {
225+
// Build ripgrep arguments
226+
const args = [...this.rgArgs]
227+
228+
// If target paths specified, use relative paths directly (ripgrep supports multiple paths)
229+
if (targetPaths.length > 0) {
230+
args.push(...targetPaths)
231+
}
232+
233+
const child = spawn(this.rgPath, args, {
234+
cwd: this._targetPath,
235+
stdio: ["pipe", "pipe", "pipe"],
236+
})
237+
238+
const tree: SimpleTreeNode = {}
239+
let buffer = ""
240+
let fileCount = 0
241+
242+
// Stream add file paths to simplified tree structure
243+
const addFileToTree = (filePath: string) => {
244+
// ripgrep output is already relative path, use directly
245+
const parts = filePath.split("/").filter(Boolean)
246+
let currentNode: SimpleTreeNode = tree
247+
248+
for (let i = 0; i < parts.length; i++) {
249+
const part = parts[i]
250+
const isFile = i === parts.length - 1 // Last part is file
251+
252+
if (isFile) {
253+
// Files represented by true
254+
currentNode[part] = true
255+
fileCount++
256+
257+
// Check if file limit reached
258+
if (fileCount >= this.fileLimit) {
259+
child.kill()
260+
return true // Indicate limit reached
261+
}
262+
} else {
263+
// Directories represented by nested objects
264+
if (!currentNode[part] || currentNode[part] === true) {
265+
currentNode[part] = {}
266+
}
267+
currentNode = currentNode[part] as SimpleTreeNode
268+
}
269+
}
270+
return false // Limit not reached
271+
}
272+
273+
child.stdout.on("data", (data: Buffer) => {
274+
buffer += data.toString()
275+
const lines = buffer.split("\n")
276+
buffer = lines.pop() || ""
277+
278+
for (const line of lines) {
279+
const trimmedLine = line.trim()
280+
if (trimmedLine) {
281+
const limitReached = addFileToTree(trimmedLine)
282+
if (limitReached) {
283+
break
284+
}
285+
}
286+
}
287+
})
288+
289+
let errorOutput = ""
290+
291+
child.stderr.on("data", (data: Buffer) => {
292+
errorOutput += data.toString()
293+
})
294+
295+
child.on("close", (code: number | null) => {
296+
// Process final buffer content
297+
if (buffer.trim() && fileCount < this.fileLimit) {
298+
addFileToTree(buffer.trim())
299+
}
300+
301+
if (errorOutput && Object.keys(tree).length === 0) {
302+
reject(new Error(`ripgrep process error: ${errorOutput}`))
303+
} else {
304+
resolve(tree)
305+
}
306+
})
307+
308+
child.on("error", (error: Error) => {
309+
reject(error)
310+
})
311+
})
312+
}
313+
314+
/**
315+
* Merge invalidated subtrees into main tree
316+
* subtree already contains complete content of all invalid directories, merge directly
317+
*/
318+
private mergeInvalidatedSubtrees(subtree: SimpleTreeNode): void {
319+
if (!this.cachedTree) {
320+
this.cachedTree = subtree
321+
return
322+
}
323+
324+
// Modify original object directly to avoid new object creation overhead
325+
this.mergeSimpleTreeNodesInPlace(this.cachedTree, subtree)
326+
}
327+
328+
/**
329+
* In-place merge two simplified tree nodes (optimized version, reduces object creation)
330+
*/
331+
private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void {
332+
for (const [key, value] of Object.entries(newTree)) {
333+
if (value === true) {
334+
// New node is file, overwrite directly
335+
existing[key] = true
336+
} else {
337+
// New node is directory
338+
if (!existing[key] || existing[key] === true) {
339+
// Original doesn't exist or is file, replace with directory directly
340+
existing[key] = value
341+
} else {
342+
// Original is also directory, merge recursively
343+
this.mergeSimpleTreeNodesInPlace(existing[key] as SimpleTreeNode, value)
344+
}
345+
}
346+
}
347+
}
348+
349+
/**
350+
* Clear cache
351+
*/
352+
clearCache(): void {
353+
this.cachedTree = null
354+
this.invalidatedDirectories.clear()
355+
this.currentBuildPromise = null
356+
}
357+
}

0 commit comments

Comments
 (0)