feat(filesearch): Introduce non-recursive file search strategy (#6087)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Bryant Chandler <bryantchandler@chromium.org>
This commit is contained in:
Bryant Chandler
2025-08-18 13:43:24 -07:00
committed by GitHub
parent d66ddcd82e
commit 465ac9f547
8 changed files with 1033 additions and 430 deletions

View File

@@ -9,7 +9,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useAtCompletion } from './useAtCompletion.js';
import { Config, FileSearch } from '@google/gemini-cli-core';
import { Config, FileSearch, FileSearchFactory } from '@google/gemini-cli-core';
import {
createTmpDir,
cleanupTmpDir,
@@ -190,14 +190,25 @@ describe('useAtCompletion', () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure);
// Spy on the search method to introduce an artificial delay
const originalSearch = FileSearch.prototype.search;
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
async function (...args) {
const realFileSearch = FileSearchFactory.create({
projectRoot: testRootDir,
ignoreDirs: [],
useGitignore: true,
useGeminiignore: true,
cache: false,
cacheTtl: 0,
enableRecursiveFileSearch: true,
});
await realFileSearch.initialize();
const mockFileSearch: FileSearch = {
initialize: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockImplementation(async (...args) => {
await new Promise((resolve) => setTimeout(resolve, 300));
return originalSearch.apply(this, args);
},
);
return realFileSearch.search(...args);
}),
};
vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch);
const { result, rerender } = renderHook(
({ pattern }) =>
@@ -241,14 +252,15 @@ describe('useAtCompletion', () => {
testRootDir = await createTmpDir(structure);
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const searchSpy = vi
.spyOn(FileSearch.prototype, 'search')
.mockImplementation(async (...args) => {
const delay = args[0] === 'a' ? 500 : 50;
const mockFileSearch: FileSearch = {
initialize: vi.fn().mockResolvedValue(undefined),
search: vi.fn().mockImplementation(async (pattern: string) => {
const delay = pattern === 'a' ? 500 : 50;
await new Promise((resolve) => setTimeout(resolve, delay));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [args[0] as any];
});
return [pattern];
}),
};
vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch);
const { result, rerender } = renderHook(
({ pattern }) =>
@@ -258,7 +270,10 @@ describe('useAtCompletion', () => {
// Wait for the hook to be ready (initialization is complete)
await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object));
expect(mockFileSearch.search).toHaveBeenCalledWith(
'a',
expect.any(Object),
);
});
// Now that the first search is in-flight, trigger the second one.
@@ -278,9 +293,10 @@ describe('useAtCompletion', () => {
);
// The search spy should have been called for both patterns.
expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object));
vi.restoreAllMocks();
expect(mockFileSearch.search).toHaveBeenCalledWith(
'b',
expect.any(Object),
);
});
});
@@ -313,9 +329,13 @@ describe('useAtCompletion', () => {
testRootDir = await createTmpDir({});
// Force an error during initialization
vi.spyOn(FileSearch.prototype, 'initialize').mockRejectedValueOnce(
new Error('Initialization failed'),
);
const mockFileSearch: FileSearch = {
initialize: vi
.fn()
.mockRejectedValue(new Error('Initialization failed')),
search: vi.fn(),
};
vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch);
const { result, rerender } = renderHook(
({ enabled }) =>

View File

@@ -5,7 +5,12 @@
*/
import { useEffect, useReducer, useRef } from 'react';
import { Config, FileSearch, escapePath } from '@google/gemini-cli-core';
import {
Config,
FileSearch,
FileSearchFactory,
escapePath,
} from '@google/gemini-cli-core';
import {
Suggestion,
MAX_SUGGESTIONS_TO_SHOW,
@@ -156,7 +161,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
useEffect(() => {
const initialize = async () => {
try {
const searcher = new FileSearch({
const searcher = FileSearchFactory.create({
projectRoot: cwd,
ignoreDirs: [],
useGitignore:
@@ -165,9 +170,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
cache: true,
cacheTtl: 30, // 30 seconds
maxDepth: !(config?.getEnableRecursiveFileSearch() ?? true)
? 0
: undefined,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
});
await searcher.initialize();
fileSearch.current = searcher;