diff --git a/src/main/ai-vault/session-scanner-discovery-first-match.test.ts b/src/main/ai-vault/session-scanner-discovery-first-match.test.ts new file mode 100644 index 00000000000..9cde6d8083b --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-first-match.test.ts @@ -0,0 +1,49 @@ +import type { Dirent } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { walkSessionFiles } from './session-scanner-discovery' + +function dirent(name: string, directory: boolean): Dirent { + return { name, isDirectory: () => directory, isFile: () => !directory } as Dirent +} + +/** Two project directories, each holding the transcript the caller asked for. */ +function tree(): Record { + return { + '/projects': [dirent('a', true), dirent('b', true)], + '/projects/a': [dirent('other.jsonl', false), dirent('target.jsonl', false)], + '/projects/b': [dirent('target.jsonl', false)] + } +} + +describe('walkSessionFiles stopAfterFirstMatch', () => { + const options = (readDirectory: (path: string) => Promise) => ({ + extensions: new Set(['.jsonl']), + filePredicate: (path: string) => path.endsWith('target.jsonl'), + readDirectory + }) + + it('stops walking once one file matches', async () => { + const entries = tree() + const readDirectory = vi.fn(async (path: string) => entries[path] ?? []) + + const files = await walkSessionFiles('/projects', 'claude', [], { + ...options(readDirectory), + stopAfterFirstMatch: true + }) + + expect(files).toEqual(['/projects/a/target.jsonl']) + // The whole point: the rest of the tree is never read. On a home with + // thousands of transcripts that traversal is most of the call. + expect(readDirectory.mock.calls.map((call) => call[0])).toEqual(['/projects', '/projects/a']) + }) + + it('returns the same file the exhaustive walk would have returned first', async () => { + const entries = tree() + const readDirectory = vi.fn(async (path: string) => entries[path] ?? []) + + const all = await walkSessionFiles('/projects', 'claude', [], options(readDirectory)) + + expect(all[0]).toBe('/projects/a/target.jsonl') + expect(all).toHaveLength(2) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index a5cf386043e..b941e2265f9 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -96,6 +96,11 @@ export async function walkSessionFiles( // rootDir, so pruned subtrees are never stat'd or parsed. directoryPredicate?: (name: string, depth: number) => boolean readDirectory?: (dirPath: string) => Promise + /** Return as soon as one file matches. For a caller that only wants + * `files[0]`, walking the rest of the tree is pure cost — on a home with + * thousands of transcripts it is most of the call. Traversal order is + * unchanged, so the file returned is the same one. */ + stopAfterFirstMatch?: boolean signal?: AbortSignal }, depth = 0 @@ -125,6 +130,9 @@ export async function walkSessionFiles( // avoiding the readdir cost of descending into them. if (options.directoryPredicate?.(entry.name, depth) ?? true) { files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) + if (options.stopAfterFirstMatch && files.length > 0) { + return files + } } continue } @@ -134,6 +142,9 @@ export async function walkSessionFiles( (options.filePredicate?.(fullPath) ?? true) ) { files.push(fullPath) + if (options.stopAfterFirstMatch) { + return files + } } } return files diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 55746b12b2a..c4aa79b3ad3 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -234,6 +234,8 @@ async function resolveClaudeSessionFile( const files = await walkSessionFiles(projectsDir, 'claude', [], { extensions: new Set(['.jsonl']), filePredicate: (path) => basename(path) === targetName, + // Only `files[0]` is ever used, and this runs on every acquisition. + stopAfterFirstMatch: true, signal }) if (files[0]) { @@ -291,6 +293,7 @@ async function findCodexRolloutInDirs( : ( await walkSessionFiles(sessionsDir, 'codex', [], { ...scanOptions, + stopAfterFirstMatch: true, signal }) )[0]