Skip to content
Merged
6 changes: 6 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes

- **`context.fileFiltering.enableFileWatcher`** (boolean):
- **Description:** Enable file watcher updates for @ file suggestions
(experimental).
- **Default:** `false`
- **Requires restart:** Yes

- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
- **Description:** Enable recursive file search functionality when completing
@ references in the prompt.
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ describe('SettingsSchema', () => {
getSettingsSchema().context.properties.fileFiltering.properties
?.enableRecursiveFileSearch,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.enableFileWatcher,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.customIgnoreFilePaths,
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,17 @@ const SETTINGS_SCHEMA = {
description: 'Respect .geminiignore files when searching.',
showInDialog: true,
},
enableFileWatcher: {
type: 'boolean',
label: 'Enable File Watcher',
category: 'Context',
requiresRestart: true,
default: false,
description: oneLine`
Enable file watcher updates for @ file suggestions (experimental).
`,
showInDialog: false,
},
enableRecursiveFileSearch: {
type: 'boolean',
label: 'Enable Recursive File Search',
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/ui/hooks/useAtCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,38 @@ describe('useAtCompletion', () => {
]);
});

it('should pass enableFileWatcher flag into FileSearchFactory options', async () => {
const structure: FileSystemStructure = {
src: {
'index.ts': '',
},
};
testRootDir = await createTmpDir(structure);

const createSpy = vi.spyOn(FileSearchFactory, 'create');
const configWithWatcher = {
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
enableFileWatcher: true,
})),
getEnableRecursiveFileSearch: () => true,
getFileFilteringEnableFuzzySearch: () => true,
} as unknown as Config;

const { result } = await renderHook(() =>
useTestHarnessForAtCompletion(true, '', configWithWatcher, testRootDir),
);

await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});

expect(createSpy).toHaveBeenCalled();
const firstCallArg = createSpy.mock.calls[0]?.[0];
expect(firstCallArg?.enableFileWatcher).toBe(true);
});

it('should reset and re-initialize when the cwd changes', async () => {
const structure1: FileSystemStructure = { 'file1.txt': '' };
const rootDir1 = await createTmpDir(structure1);
Expand Down
36 changes: 31 additions & 5 deletions packages/cli/src/ui/hooks/useAtCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect, useReducer, useRef } from 'react';
import { useCallback, useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import * as path from 'node:path';
import {
Expand Down Expand Up @@ -224,15 +224,28 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);

const resetFileSearchState = () => {
const disposeFileSearchers = useCallback(async () => {
const searchers = [...fileSearchMap.current.values()];
fileSearchMap.current.clear();
initEpoch.current += 1;

const closePromises: Array<Promise<void>> = [];
for (const searcher of searchers) {
if (searcher.close) {
closePromises.push(searcher.close());
}
}
await Promise.all(closePromises);
}, []);
Comment thread
prassamin marked this conversation as resolved.

const resetFileSearchState = useCallback(() => {
void disposeFileSearchers();
dispatch({ type: 'RESET' });
};
}, [disposeFileSearchers]);

useEffect(() => {
resetFileSearchState();
}, [cwd, config]);
}, [cwd, config, resetFileSearchState]);

useEffect(() => {
const workspaceContext = config?.getWorkspaceContext?.();
Expand All @@ -242,7 +255,18 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
workspaceContext.onDirectoriesChanged(resetFileSearchState);

return unsubscribe;
}, [config]);
}, [config, resetFileSearchState]);

useEffect(
() => () => {
void disposeFileSearchers();
searchAbortController.current?.abort();
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
},
[disposeFileSearchers],
);

// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
Expand Down Expand Up @@ -295,6 +319,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
),
cache: true,
cacheTtl: 30,
enableFileWatcher:
config?.getFileFilteringOptions()?.enableFileWatcher ?? false,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ export interface ConfigParameters {
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableFileWatcher?: boolean;
enableRecursiveFileSearch?: boolean;
enableFuzzySearch?: boolean;
maxFileCount?: number;
Expand Down Expand Up @@ -796,6 +797,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly fileFiltering: {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
enableFileWatcher: boolean;
enableRecursiveFileSearch: boolean;
enableFuzzySearch: boolean;
maxFileCount: number;
Expand Down Expand Up @@ -1075,6 +1077,10 @@ export class Config implements McpContext, AgentLoopContext {
respectGeminiIgnore:
params.fileFiltering?.respectGeminiIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
enableFileWatcher:
params.fileFiltering?.enableFileWatcher ??
DEFAULT_FILE_FILTERING_OPTIONS.enableFileWatcher ??
true,
enableRecursiveFileSearch:
params.fileFiltering?.enableRecursiveFileSearch ?? true,
enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true,
Expand Down Expand Up @@ -2820,6 +2826,7 @@ export class Config implements McpContext, AgentLoopContext {
return {
respectGitIgnore: this.fileFiltering.respectGitIgnore,
respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore,
enableFileWatcher: this.fileFiltering.enableFileWatcher,
maxFileCount: this.fileFiltering.maxFileCount,
searchTimeout: this.fileFiltering.searchTimeout,
customIgnoreFilePaths: this.fileFiltering.customIgnoreFilePaths,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export interface FileFilteringOptions {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
enableFileWatcher?: boolean;
maxFileCount?: number;
searchTimeout?: number;
customIgnoreFilePaths: string[];
Expand All @@ -16,6 +17,7 @@ export interface FileFilteringOptions {
export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: false,
respectGeminiIgnore: true,
enableFileWatcher: false,
maxFileCount: 20000,
searchTimeout: 5000,
customIgnoreFilePaths: [],
Expand All @@ -25,6 +27,7 @@ export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: true,
respectGeminiIgnore: true,
enableFileWatcher: false,
maxFileCount: 20000,
searchTimeout: 5000,
customIgnoreFilePaths: [],
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/utils/filesearch/fileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { describe, it, expect, afterEach, vi } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { FileSearchFactory, AbortError, filter } from './fileSearch.js';
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
import * as crawler from './crawler.js';
Expand Down Expand Up @@ -150,6 +151,70 @@ describe('FileSearch', () => {
]);
});

it('should include newly created directory when watcher is enabled', async () => {
tmpDir = await createTmpDir({
src: ['main.js'],
});

const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
fileDiscoveryService: new FileDiscoveryService(tmpDir, {
respectGitIgnore: false,
respectGeminiIgnore: false,
}),
ignoreDirs: [],
cache: false,
cacheTtl: 0,
enableFileWatcher: true,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
});

await fileSearch.initialize();
await new Promise((resolve) => setTimeout(resolve, 300));
await fs.mkdir(path.join(tmpDir, 'new-folder'));
await new Promise((resolve) => setTimeout(resolve, 1200));

const results = await fileSearch.search('new-folder');
expect(results).toContain('new-folder/');
});

it('should include newly created file and remove it after deletion when watcher is enabled', async () => {
tmpDir = await createTmpDir({
src: ['main.js'],
});

const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
fileDiscoveryService: new FileDiscoveryService(tmpDir, {
respectGitIgnore: false,
respectGeminiIgnore: false,
}),
ignoreDirs: [],
cache: false,
cacheTtl: 0,
enableFileWatcher: true,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
});

await fileSearch.initialize();
await new Promise((resolve) => setTimeout(resolve, 300));

const filePath = path.join(tmpDir, 'watcher-file.txt');
await fs.writeFile(filePath, 'hello');
await new Promise((resolve) => setTimeout(resolve, 1200));

let results = await fileSearch.search('watcher-file');
expect(results).toContain('watcher-file.txt');

await fs.rm(filePath, { force: true });
await new Promise((resolve) => setTimeout(resolve, 1200));

results = await fileSearch.search('watcher-file');
expect(results).not.toContain('watcher-file.txt');
});

it('should filter results with a search pattern', async () => {
tmpDir = await createTmpDir({
src: {
Expand Down
Loading
Loading