Skip to content
47 changes: 47 additions & 0 deletions packages/cli/src/ui/hooks/useAtCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { act, useState } from 'react';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as crawlCache from '../../../../core/src/utils/filesearch/crawlCache.js';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useAtCompletion } from './useAtCompletion.js';
Expand Down Expand Up @@ -69,7 +71,9 @@ describe('useAtCompletion', () => {
if (testRootDir) {
await cleanupTmpDir(testRootDir);
}
crawlCache.clear();
vi.restoreAllMocks();
vi.useRealTimers();
});

describe('File Search Logic', () => {
Expand Down Expand Up @@ -186,6 +190,49 @@ describe('useAtCompletion', () => {
]);
});
});

it('should pick up newly created files when re-triggered (@ menu re-opened)', async () => {
vi.useFakeTimers();
const structure: FileSystemStructure = { 'initial.txt': '' };
testRootDir = await createTmpDir(structure);

const { result, rerender } = await renderHook(
({ enabled }) =>
useTestHarnessForAtCompletion(enabled, '', mockConfig, testRootDir),
{ initialProps: { enabled: true } },
);

// Wait for initial suggestions
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toContain(
'initial.txt',
);
});

// Add a new file to the disk
const newFile = path.join(testRootDir, 'newfile.txt');
await fs.promises.writeFile(newFile, '');

// Advance timers by >5s to expire the crawler cache
act(() => {
vi.advanceTimersByTime(5100);
});

// Toggle enabled to re-trigger initialize (close and open @ menu)
act(() => {
rerender({ enabled: false });
});
act(() => {
rerender({ enabled: true });
});

// Wait for the new file to appear in suggestions
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toContain(
'newfile.txt',
);
});
});
});

describe('MCP resource suggestions', () => {
Expand Down
41 changes: 24 additions & 17 deletions packages/cli/src/ui/hooks/useAtCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,27 +284,34 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
const initPromises: Array<Promise<void>> = [];

for (const dir of directories) {
if (fileSearchMap.current.has(dir)) continue;

const searcher = FileSearchFactory.create({
projectRoot: dir,
ignoreDirs: [],
fileDiscoveryService: new FileDiscoveryService(
dir,
config?.getFileFilteringOptions(),
),
cache: true,
cacheTtl: 30,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
config?.getFileFilteringEnableFuzzySearch() ?? true,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
});
let searcher = fileSearchMap.current.get(dir);
if (!searcher) {
searcher = FileSearchFactory.create({
projectRoot: dir,
ignoreDirs: [],
fileDiscoveryService: new FileDiscoveryService(
dir,
config?.getFileFilteringOptions(),
),
cache: true,
// use a short 5s TTL for the @ menu to ensure newly created files
// (by the agent or user) are discovered quickly, while still
// protecting against redundant crawls in very large repositories.
cacheTtl: 5,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
config?.getFileFilteringEnableFuzzySearch() ?? true,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
});
fileSearchMap.current.set(dir, searcher);
}
Comment thread
prassamin marked this conversation as resolved.

initPromises.push(
searcher.initialize().then(() => {
if (initEpoch.current === currentEpoch) {
// Ensure we still have the latest version in the map
// (though set() above already did it, this handles potential concurrent resets)
fileSearchMap.current.set(dir, searcher);
}
}),
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/utils/filesearch/fileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,81 @@ describe('FileSearch', () => {
]);
});

it('should skip re-building the Fzf index if files have not changed (referential equality)', async () => {
tmpDir = await createTmpDir({
'file1.js': '',
});

const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
fileDiscoveryService: new FileDiscoveryService(tmpDir, {
respectGitIgnore: false,
respectGeminiIgnore: false,
}),
ignoreDirs: [],
cache: true,
cacheTtl: 10000,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;

await fileSearch.initialize();
const firstFzf = fileSearch.fzf;
const firstResultCache = fileSearch.resultCache;

expect(firstFzf).toBeDefined();
expect(firstResultCache).toBeDefined();

// Mock crawl to return the exact same array instance
const crawlSpy = vi
.spyOn(crawler, 'crawl')
.mockResolvedValue(fileSearch.allFiles);

await fileSearch.initialize();

expect(fileSearch.resultCache).toBe(firstResultCache);
expect(crawlSpy).toHaveBeenCalled();
});

it('should prevent concurrent initialization calls', async () => {
tmpDir = await createTmpDir({});
const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
fileDiscoveryService: new FileDiscoveryService(tmpDir, {}),
ignoreDirs: [],
cache: true,
cacheTtl: 10000,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
});

let crawlResolve: (value: string[]) => void;
const crawlPromise = new Promise<string[]>((resolve) => {
crawlResolve = resolve;
});

const crawlSpy = vi.spyOn(crawler, 'crawl').mockReturnValue(crawlPromise);

// Start first initialization
const init1 = fileSearch.initialize();
expect(crawlSpy).toHaveBeenCalledTimes(1);

// Start second initialization while first is still "Initializing"
const init2 = fileSearch.initialize();

// Should NOT have called crawl again
expect(crawlSpy).toHaveBeenCalledTimes(1);

// Resolve the first one
crawlResolve!(['file1.js']);
await Promise.all([init1, init2]);

expect(crawlSpy).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((fileSearch as any).allFiles).toEqual(['file1.js']);
});

describe('DirectoryFileSearch', () => {
it('should search for files in the current directory', async () => {
tmpDir = await createTmpDir({
Expand Down
70 changes: 56 additions & 14 deletions packages/core/src/utils/filesearch/fileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface FileSearchOptions {
maxFiles?: number;
}

enum InitializationState {
Uninitialized,
Initializing,
Initialized,
}

export class AbortError extends Error {
constructor(message = 'Search aborted') {
super(message);
Expand Down Expand Up @@ -133,26 +139,62 @@ class RecursiveFileSearch implements FileSearch {
private resultCache: ResultCache | undefined;
private allFiles: string[] = [];
private fzf: AsyncFzf<string[]> | undefined;
private initializationState = InitializationState.Uninitialized;
Comment thread
prassamin marked this conversation as resolved.
private initPromise: Promise<void> | null = null;

constructor(private readonly options: FileSearchOptions) {}

async initialize(): Promise<void> {
this.ignore = loadIgnoreRules(
this.options.fileDiscoveryService,
this.options.ignoreDirs,
);
if (this.initializationState === InitializationState.Initializing) {
return this.initPromise || Promise.resolve();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When managing the state of asynchronous operations, rely on the explicit state variable initializationState rather than checking for the existence of the initPromise object. Promise objects may be cleared in finally blocks upon completion, making them unreliable for state checks. The logic should ensure that if the state is Initializing, the initPromise is returned directly without fallback checks, as the state enum should be the single source of truth for the operation's status.

Suggested change
return this.initPromise || Promise.resolve();
return this.initPromise!;
References
  1. When managing the state of asynchronous operations, rely on an explicit state variable (e.g., a state enum) rather than checking for the existence of a promise object. Promise objects may be cleared in finally blocks upon completion, making them unreliable for state checks after the operation has finished.

}

this.allFiles = await crawl({
crawlDirectory: this.options.projectRoot,
cwd: this.options.projectRoot,
ignore: this.ignore,
cache: this.options.cache,
cacheTtl: this.options.cacheTtl,
maxDepth: this.options.maxDepth,
maxFiles: this.options.maxFiles ?? 20000,
});
this.initPromise = (async () => {
const prevState = this.initializationState;
this.initializationState = InitializationState.Initializing;

try {
const nextIgnore = loadIgnoreRules(
this.options.fileDiscoveryService,
this.options.ignoreDirs,
);

const nextFiles = await crawl({
crawlDirectory: this.options.projectRoot,
cwd: this.options.projectRoot,
ignore: nextIgnore,
cache: this.options.cache,
cacheTtl: this.options.cacheTtl,
maxDepth: this.options.maxDepth,
maxFiles: this.options.maxFiles ?? 20000,
});

if (
nextFiles === this.allFiles &&
this.ignore &&
prevState === InitializationState.Initialized
) {
this.initializationState = InitializationState.Initialized;
// optimization: if the file list is referentially equal (from crawl cache)
// and we already have ignore rules, skip rebuilding the FZF index.
return;
}

this.ignore = nextIgnore;
this.allFiles = nextFiles;
this.buildResultCache();
this.initializationState = InitializationState.Initialized;
} catch (e) {
this.initializationState = prevState;
throw e;
}
})();

this.buildResultCache();
try {
await this.initPromise;
} finally {
this.initPromise = null;
}
}
Comment thread
prassamin marked this conversation as resolved.

async search(
Expand Down