From bbd840adba889578e00be9fae764c9ec4c5749de Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Tue, 13 Oct 2026 20:01:33 +0800 Subject: [PATCH] fix(files): share File Viewer scan budget across directories --- .changeset/quiet-folders-share.md | 5 + src/web/routes/file-routes.ts | 212 +++++++++++++++------------ test/routes/file-search-mode.test.ts | 26 ++++ 3 files changed, 148 insertions(+), 95 deletions(-) create mode 100644 .changeset/quiet-folders-share.md diff --git a/.changeset/quiet-folders-share.md b/.changeset/quiet-folders-share.md new file mode 100644 index 00000000..967b6b15 --- /dev/null +++ b/.changeset/quiet-folders-share.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Share File Viewer scan budgets across sibling directories so large early folders do not hide later results. diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index a74ece87..f5682798 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -1499,65 +1499,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const matches: FileTreeNode[] = []; const maxMatches = 1000; - const searchDirectory = async (dirPath: string, currentDepth: number): Promise => { - if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) { - truncated = true; - return; - } + interface SearchDirectoryCursor { + dirPath: string; + depth: number; + entries?: import('node:fs').Dirent[]; + index: number; + } - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(dirPath, { withFileTypes: true }); - } catch { - // Can't read directory (permission denied, etc.) - return; - } - entries.sort((a, b) => { - if (a.isDirectory() && !b.isDirectory()) return -1; - if (!a.isDirectory() && b.isDirectory()) return 1; - return a.name.localeCompare(b.name); - }); + const queue: SearchDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, index: 0 }]; + while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) { + // Give each discovered directory a slice before returning to a large sibling. + const directoriesInRound = queue.length; + const nodesPerDirectory = Math.max( + 1, + Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound) + ); - for (const entry of entries) { - if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) { + for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) { + const cursor = queue.shift(); + if (!cursor) break; + if (cursor.depth > maxDepth) { truncated = true; - break; + continue; } - if (!includeHidden && entry.name.startsWith('.')) continue; - if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - const relativePath = relative(workingDir, fullPath); - - if (entry.isDirectory()) { - totalDirectories++; - if (matcher(entry.name, relativePath)) { - matches.push({ name: entry.name, path: relativePath, type: 'directory' }); - } - // Always recurse, even when this directory does not match. - await searchDirectory(fullPath, currentDepth + 1); - } else { - totalFiles++; - if (matcher(entry.name, relativePath)) { - let size: number | undefined; - try { - size = (await fs.stat(fullPath)).size; - } catch { - // Skip size if we can't stat the match. - } - matches.push({ - name: entry.name, - path: relativePath, - type: 'file', - size, - extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined, + if (!cursor.entries) { + try { + cursor.entries = await fs.readdir(cursor.dirPath, { withFileTypes: true }); + cursor.entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); }); + } catch { + // Can't read directory (permission denied, etc.). + continue; } } - } - }; - await searchDirectory(workingDir, 1); + const endIndex = Math.min(cursor.index + nodesPerDirectory, cursor.entries.length); + while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) { + const entry = cursor.entries[cursor.index++]; + if (!includeHidden && entry.name.startsWith('.')) continue; + if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; + + const fullPath = join(cursor.dirPath, entry.name); + const relativePath = relative(workingDir, fullPath); + + if (entry.isDirectory()) { + totalDirectories++; + if (matcher(entry.name, relativePath)) { + matches.push({ name: entry.name, path: relativePath, type: 'directory' }); + } + // Queue siblings together so one large subtree cannot starve later branches. + queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 }); + } else { + totalFiles++; + if (matcher(entry.name, relativePath)) { + let size: number | undefined; + try { + size = (await fs.stat(fullPath)).size; + } catch { + // Skip size if we can't stat the match. + } + matches.push({ + name: entry.name, + path: relativePath, + type: 'file', + size, + extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined, + }); + } + } + } + if (cursor.index < cursor.entries.length) queue.push(cursor); + if (matches.length >= maxMatches) break; + } + } + if (queue.length > 0) truncated = true; return { success: true, @@ -1575,47 +1593,62 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even }; } - const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { - if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { - truncated = true; - return []; - } + interface TreeDirectoryCursor { + dirPath: string; + depth: number; + nodes: FileTreeNode[]; + entries?: import('node:fs').Dirent[]; + index: number; + } - try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const nodes: FileTreeNode[] = []; + const tree: FileTreeNode[] = []; + const queue: TreeDirectoryCursor[] = [{ dirPath: workingDir, depth: 1, nodes: tree, index: 0 }]; + while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) { + const directoriesInRound = queue.length; + const nodesPerDirectory = Math.max( + 1, + Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound) + ); - // Sort: directories first, then alphabetically - entries.sort((a, b) => { - if (a.isDirectory() && !b.isDirectory()) return -1; - if (!a.isDirectory() && b.isDirectory()) return 1; - return a.name.localeCompare(b.name); - }); - - for (const entry of entries) { - if (totalFiles + totalDirectories > maxFiles) { - truncated = true; - break; + for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) { + const cursor = queue.shift(); + if (!cursor) break; + if (cursor.depth > maxDepth) { + truncated = true; + continue; + } + if (!cursor.entries) { + try { + cursor.entries = await fs.readdir(cursor.dirPath, { withFileTypes: true }); + cursor.entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + } catch { + // Can't read directory (permission denied, etc.). + continue; } + } - // Skip hidden files unless requested + const endIndex = Math.min(cursor.index + nodesPerDirectory, cursor.entries.length); + while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) { + const entry = cursor.entries[cursor.index++]; if (!includeHidden && entry.name.startsWith('.')) continue; - - // Skip excluded directories if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; - const fullPath = join(dirPath, entry.name); + const fullPath = join(cursor.dirPath, entry.name); const relativePath = fullPath.slice(workingDir.length + 1); - if (entry.isDirectory()) { totalDirectories++; - const children = await scanDirectory(fullPath, currentDepth + 1); - nodes.push({ + const node: FileTreeNode = { name: entry.name, path: relativePath, type: 'directory', - children, - }); + children: [], + }; + cursor.nodes.push(node); + queue.push({ dirPath: fullPath, depth: cursor.depth + 1, nodes: node.children!, index: 0 }); } else { totalFiles++; const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; @@ -1624,26 +1657,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const stat = await fs.stat(fullPath); size = stat.size; } catch { - // Skip if can't stat + // Skip if can't stat. } - nodes.push({ - name: entry.name, - path: relativePath, - type: 'file', - size, - extension: ext, - }); + cursor.nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext }); } } - - return nodes; - } catch { - // Can't read directory (permission denied, etc.) - return []; + if (cursor.index < cursor.entries.length) queue.push(cursor); } - }; - - const tree = await scanDirectory(workingDir, 1); + } + if (queue.length > 0) truncated = true; return { success: true, diff --git a/test/routes/file-search-mode.test.ts b/test/routes/file-search-mode.test.ts index 52a0b37e..d9833c92 100644 --- a/test/routes/file-search-mode.test.ts +++ b/test/routes/file-search-mode.test.ts @@ -91,4 +91,30 @@ describe('files endpoint search mode', () => { expect(body.data.matches).toHaveLength(1); expect(body.data.matches[0]).toMatchObject({ name: 'nested', type: 'directory' }); }); + + it('shares the scan budget across sibling folders in both tree and search modes', async () => { + const bulk = join(root, 'a-bulk'); + const later = join(root, 'z-later'); + mkdirSync(bulk); + mkdirSync(later); + for (let index = 0; index < 5100; index++) { + writeFileSync(join(bulk, `item-${index}.txt`), ''); + } + writeFileSync(join(later, 'needle.ts'), 'x\n'); + + try { + const tree = await get(''); + const treePaths = tree.data.tree.map((entry: { path: string }) => entry.path); + expect(treePaths).toContain('a-bulk'); + expect(treePaths).toContain('z-later'); + expect(tree.data.truncated).toBe(true); + + const search = await get('?q=needle'); + expect(search.data.matches.map((match: { path: string }) => match.path)).toContain(join('z-later', 'needle.ts')); + expect(search.data.truncated).toBe(true); + } finally { + rmSync(bulk, { recursive: true, force: true }); + rmSync(later, { recursive: true, force: true }); + } + }); });