From b0d02eade2e06bec86bbca8658a0c44dfb217a36 Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Sun, 12 Apr 2026 18:35:00 +0600 Subject: [PATCH 1/7] feat(config): add file watcher flag for file filtering --- packages/cli/src/config/settingsSchema.test.ts | 4 ++++ packages/cli/src/config/settingsSchema.ts | 11 +++++++++++ packages/core/src/config/config.ts | 7 +++++++ packages/core/src/config/constants.ts | 3 +++ 4 files changed, 25 insertions(+) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 27639fa0311..94ef5b34ebd 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fcfd604e3a7..d382800c5f2 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1406,6 +1406,17 @@ const SETTINGS_SCHEMA = { description: 'Respect .geminiignore files when searching.', showInDialog: true, }, + enableFileWatcher: { + type: 'boolean', + label: 'Enable File Watcher', + category: 'Context', + requiresRestart: true, + default: true, + description: oneLine` + Enable file watcher updates for @ file suggestions (experimental). + `, + showInDialog: true, + }, enableRecursiveFileSearch: { type: 'boolean', label: 'Enable Recursive File Search', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e8507eba4d..b8cec01d924 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -610,6 +610,7 @@ export interface ConfigParameters { fileFiltering?: { respectGitIgnore?: boolean; respectGeminiIgnore?: boolean; + enableFileWatcher?: boolean; enableRecursiveFileSearch?: boolean; enableFuzzySearch?: boolean; maxFileCount?: number; @@ -790,6 +791,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly fileFiltering: { respectGitIgnore: boolean; respectGeminiIgnore: boolean; + enableFileWatcher: boolean; enableRecursiveFileSearch: boolean; enableFuzzySearch: boolean; maxFileCount: number; @@ -1069,6 +1071,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 ?? + false, enableRecursiveFileSearch: params.fileFiltering?.enableRecursiveFileSearch ?? true, enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true, @@ -2745,6 +2751,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, diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index 4111b469d12..e9d0f3feb55 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -7,6 +7,7 @@ export interface FileFilteringOptions { respectGitIgnore: boolean; respectGeminiIgnore: boolean; + enableFileWatcher?: boolean; maxFileCount?: number; searchTimeout?: number; customIgnoreFilePaths: string[]; @@ -16,6 +17,7 @@ export interface FileFilteringOptions { export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGitIgnore: false, respectGeminiIgnore: true, + enableFileWatcher: true, maxFileCount: 20000, searchTimeout: 5000, customIgnoreFilePaths: [], @@ -25,6 +27,7 @@ export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGitIgnore: true, respectGeminiIgnore: true, + enableFileWatcher: true, maxFileCount: 20000, searchTimeout: 5000, customIgnoreFilePaths: [], From 5c0506cacda29fb4e2eb4dcee92b02b741acf386 Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Mon, 13 Apr 2026 12:32:19 +0600 Subject: [PATCH 2/7] feat(filesearch): add watcher-based @ index refresh --- package-lock.json | 29 +++ .../cli/src/ui/hooks/useAtCompletion.test.ts | 32 +++ packages/cli/src/ui/hooks/useAtCompletion.ts | 5 + packages/core/package.json | 1 + packages/core/src/config/config.ts | 2 +- .../src/utils/filesearch/fileSearch.test.ts | 65 ++++++ .../core/src/utils/filesearch/fileSearch.ts | 138 +++++++++-- .../src/utils/filesearch/fileWatcher.test.ts | 219 ++++++++++++++++++ .../core/src/utils/filesearch/fileWatcher.ts | 103 ++++++++ 9 files changed, 580 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/utils/filesearch/fileWatcher.test.ts create mode 100644 packages/core/src/utils/filesearch/fileWatcher.ts diff --git a/package-lock.json b/package-lock.json index 0c6c449d325..26e73c8665f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18175,6 +18175,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.0", "chardet": "^2.1.0", + "chokidar": "^4.0.0", "diff": "^8.0.3", "dotenv": "^17.2.4", "dotenv-expand": "^12.0.3", @@ -18322,6 +18323,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "packages/core/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/core/node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -18390,6 +18406,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/core/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "packages/core/node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 27e779acef0..00e3bfa71af 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -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); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 4a7b9ebc130..900fc38a689 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -225,6 +225,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { }, [state.isLoading, setIsLoadingSuggestions]); const resetFileSearchState = () => { + for (const searcher of fileSearchMap.current.values()) { + searcher.close?.(); + } fileSearchMap.current.clear(); initEpoch.current += 1; dispatch({ type: 'RESET' }); @@ -295,6 +298,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void { ), cache: true, cacheTtl: 30, + enableFileWatcher: + config?.getFileFilteringOptions()?.enableFileWatcher ?? true, enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true, enableFuzzySearch: diff --git a/packages/core/package.json b/packages/core/package.json index 90010084f76..1fa4ff76175 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b8cec01d924..ec0513d7194 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1074,7 +1074,7 @@ export class Config implements McpContext, AgentLoopContext { enableFileWatcher: params.fileFiltering?.enableFileWatcher ?? DEFAULT_FILE_FILTERING_OPTIONS.enableFileWatcher ?? - false, + true, enableRecursiveFileSearch: params.fileFiltering?.enableRecursiveFileSearch ?? true, enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true, diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index 33906fcb0a2..af5044028b4 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -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'; @@ -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: { diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index e3f608e5088..e6281cf0112 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -12,6 +12,7 @@ import { crawl } from './crawler.js'; import { AsyncFzf, type FzfResultItem } from 'fzf'; import { unescapePath } from '../paths.js'; import type { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; +import { FileWatcher, type FileWatcherEvent } from './fileWatcher.js'; // Tiebreaker: Prefers shorter paths. const byLengthAsc = (a: { item: string }, b: { item: string }) => @@ -57,6 +58,7 @@ export interface FileSearchOptions { fileDiscoveryService: FileDiscoveryService; cache: boolean; cacheTtl: number; + enableFileWatcher?: boolean; enableRecursiveFileSearch: boolean; enableFuzzySearch: boolean; maxDepth?: number; @@ -126,13 +128,17 @@ export interface SearchOptions { export interface FileSearch { initialize(): Promise; search(pattern: string, options?: SearchOptions): Promise; + close?(): void; } class RecursiveFileSearch implements FileSearch { private ignore: Ignore | undefined; private resultCache: ResultCache | undefined; - private allFiles: string[] = []; + private allFiles: Set = new Set(); private fzf: AsyncFzf | undefined; + private fileWatcher: FileWatcher | undefined; + private rebuildTimer: NodeJS.Timeout | undefined; + private rebuildScheduled = false; constructor(private readonly options: FileSearchOptions) {} @@ -142,17 +148,110 @@ class RecursiveFileSearch implements FileSearch { this.options.ignoreDirs, ); - 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.allFiles = new Set( + 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.buildResultCache(); + + if (this.options.enableFileWatcher) { + const directoryFilter = this.ignore.getDirectoryFilter(); + this.fileWatcher = new FileWatcher( + this.options.projectRoot, + (event) => this.handleFileWatcherEvent(event), + { + shouldIgnore: (relativePath) => directoryFilter(`${relativePath}/`), + }, + ); + this.fileWatcher.start(); + } + } + + private scheduleRebuild(): void { + this.rebuildScheduled = true; + if (this.rebuildTimer) { + return; + } + + this.rebuildTimer = setTimeout(() => { + this.rebuildTimer = undefined; + if (this.rebuildScheduled) { + this.rebuildScheduled = false; + this.buildResultCache(); + } + }, 150); + } + + private flushPendingRebuild(): void { + if (!this.rebuildScheduled) { + return; + } + + if (this.rebuildTimer) { + clearTimeout(this.rebuildTimer); + this.rebuildTimer = undefined; + } + + this.rebuildScheduled = false; + this.buildResultCache(); + } + + private handleFileWatcherEvent(event: FileWatcherEvent): void { + const normalizedPath = event.relativePath.replaceAll('\\', '/'); + if (!normalizedPath || normalizedPath === '.') { + return; + } + + const fileFilter = this.ignore?.getFileFilter(); + const directoryFilter = this.ignore?.getDirectoryFilter(); + + let changed = false; + if (event.eventType === 'add') { + if (fileFilter?.(normalizedPath)) { + return; + } + const sizeBefore = this.allFiles.size; + this.allFiles.add(normalizedPath); + changed = this.allFiles.size !== sizeBefore; + } else if (event.eventType === 'unlink') { + changed = this.allFiles.delete(normalizedPath); + } else if (event.eventType === 'addDir') { + const directoryPath = normalizedPath.endsWith('/') + ? normalizedPath + : `${normalizedPath}/`; + if (directoryFilter?.(directoryPath)) { + return; + } + const sizeBefore = this.allFiles.size; + this.allFiles.add(directoryPath); + changed = this.allFiles.size !== sizeBefore; + } else if (event.eventType === 'unlinkDir') { + const directoryPath = normalizedPath.endsWith('/') + ? normalizedPath + : `${normalizedPath}/`; + const toDelete: string[] = []; + for (const file of this.allFiles) { + if (file === directoryPath || file.startsWith(directoryPath)) { + toDelete.push(file); + } + } + changed = toDelete.length > 0; + for (const file of toDelete) { + this.allFiles.delete(file); + } + } + + if (changed) { + this.scheduleRebuild(); + } } async search( @@ -167,6 +266,8 @@ class RecursiveFileSearch implements FileSearch { throw new Error('Engine not initialized. Call initialize() first.'); } + this.flushPendingRebuild(); + pattern = unescapePath(pattern) || '*'; let filteredCandidates; @@ -222,14 +323,25 @@ class RecursiveFileSearch implements FileSearch { return results; } + close(): void { + this.fileWatcher?.stop(); + this.fileWatcher = undefined; + if (this.rebuildTimer) { + clearTimeout(this.rebuildTimer); + this.rebuildTimer = undefined; + } + this.rebuildScheduled = false; + } + private buildResultCache(): void { - this.resultCache = new ResultCache(this.allFiles); + const allFiles = [...this.allFiles]; + this.resultCache = new ResultCache(allFiles); if (this.options.enableFuzzySearch) { // The v1 algorithm is much faster since it only looks at the first // occurrence of the pattern. We use it for search spaces that have >20k // files, because the v2 algorithm is just too slow in those cases. - this.fzf = new AsyncFzf(this.allFiles, { - fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2', + this.fzf = new AsyncFzf(allFiles, { + fuzzy: allFiles.length > 20000 ? 'v1' : 'v2', forward: false, tiebreakers: [byBasenamePrefix, byMatchPosFromEnd, byLengthAsc], }); diff --git a/packages/core/src/utils/filesearch/fileWatcher.test.ts b/packages/core/src/utils/filesearch/fileWatcher.test.ts new file mode 100644 index 00000000000..cb3066901fe --- /dev/null +++ b/packages/core/src/utils/filesearch/fileWatcher.test.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanupTmpDir, createTmpDir } from '@google/gemini-cli-test-utils'; +import { FileWatcher, type FileWatcherEvent } from './fileWatcher.js'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForEvent = async ( + events: FileWatcherEvent[], + predicate: (event: FileWatcherEvent) => boolean, + timeoutMs = 4000, +) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (events.some(predicate)) { + return; + } + await sleep(50); + } + throw new Error('Timed out waiting for watcher event'); +}; + +describe('FileWatcher', () => { + const tmpDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tmpDirs.map((dir) => cleanupTmpDir(dir))); + tmpDirs.length = 0; + }); + + it('should emit relative add and unlink events for files', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher(tmpDir, (event) => { + events.push(event); + }); + + watcher.start(); + await sleep(500); + + const fileName = 'new-file.txt'; + const filePath = path.join(tmpDir, fileName); + + await fs.writeFile(filePath, 'hello'); + await sleep(1200); + + await fs.rm(filePath, { force: true }); + await sleep(1200); + + watcher.stop(); + + expect(events).toContainEqual({ eventType: 'add', relativePath: fileName }); + expect(events).toContainEqual({ + eventType: 'unlink', + relativePath: fileName, + }); + }); + + it('should skip ignored paths', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher( + tmpDir, + (event) => { + events.push(event); + }, + { + shouldIgnore: (relativePath) => relativePath.startsWith('ignored'), + }, + ); + + watcher.start(); + await sleep(500); + + await fs.writeFile(path.join(tmpDir, 'ignored.txt'), 'x'); + await fs.writeFile(path.join(tmpDir, 'kept.txt'), 'x'); + await sleep(1200); + + watcher.stop(); + + expect(events.some((event) => event.relativePath === 'ignored.txt')).toBe( + false, + ); + expect(events).toContainEqual({ + eventType: 'add', + relativePath: 'kept.txt', + }); + }); + + it('should emit addDir and unlinkDir events for directories', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher(tmpDir, (event) => { + events.push(event); + }); + + watcher.start(); + await sleep(500); + + const dirName = 'new-folder'; + const dirPath = path.join(tmpDir, dirName); + + await fs.mkdir(dirPath); + await waitForEvent( + events, + (event) => event.eventType === 'addDir' && event.relativePath === dirName, + ); + + await fs.rm(dirPath, { recursive: true, force: true }); + await waitForEvent( + events, + (event) => + event.eventType === 'unlinkDir' && event.relativePath === dirName, + ); + + watcher.stop(); + }); + + it('should normalize nested paths without leading dot prefix', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher(tmpDir, (event) => { + events.push(event); + }); + + watcher.start(); + await sleep(500); + + await fs.mkdir(path.join(tmpDir, 'nested'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'nested', 'file.txt'), 'data'); + + await waitForEvent( + events, + (event) => + event.eventType === 'add' && event.relativePath === 'nested/file.txt', + ); + + const nestedFileEvent = events.find( + (event) => + event.eventType === 'add' && event.relativePath.endsWith('/file.txt'), + ); + + expect(nestedFileEvent).toBeDefined(); + expect(nestedFileEvent!.relativePath.startsWith('./')).toBe(false); + expect(nestedFileEvent!.relativePath.includes('\\')).toBe(false); + + watcher.stop(); + }); + + it('should not emit new events after stop is called', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher(tmpDir, (event) => { + events.push(event); + }); + + watcher.start(); + await sleep(500); + + const beforeStopFile = path.join(tmpDir, 'before-stop.txt'); + await fs.writeFile(beforeStopFile, 'x'); + await waitForEvent( + events, + (event) => + event.eventType === 'add' && event.relativePath === 'before-stop.txt', + ); + + watcher.stop(); + + const afterStopCount = events.length; + await fs.writeFile(path.join(tmpDir, 'after-stop.txt'), 'x'); + await sleep(600); + + expect(events.length).toBe(afterStopCount); + }); + + it('should be safe to start and stop multiple times', async () => { + const tmpDir = await createTmpDir({}); + tmpDirs.push(tmpDir); + + const events: FileWatcherEvent[] = []; + const watcher = new FileWatcher(tmpDir, (event) => { + events.push(event); + }); + + watcher.start(); + watcher.start(); + await sleep(500); + + await fs.writeFile(path.join(tmpDir, 'idempotent.txt'), 'x'); + await waitForEvent( + events, + (event) => + event.eventType === 'add' && event.relativePath === 'idempotent.txt', + ); + + watcher.stop(); + watcher.stop(); + + expect(events.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/utils/filesearch/fileWatcher.ts b/packages/core/src/utils/filesearch/fileWatcher.ts new file mode 100644 index 00000000000..687ffc2b84a --- /dev/null +++ b/packages/core/src/utils/filesearch/fileWatcher.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { watch, type FSWatcher } from 'chokidar'; +import path from 'node:path'; + +export type FileWatcherEvent = { + eventType: 'add' | 'unlink' | 'addDir' | 'unlinkDir'; + relativePath: string; +}; + +export type FileWatcherCallback = (event: FileWatcherEvent) => void; + +type FileWatcherOptions = { + shouldIgnore?: (relativePath: string) => boolean; + onError?: (error: unknown) => void; +}; + +export class FileWatcher { + private watcher: FSWatcher | null = null; + + constructor( + private readonly projectRoot: string, + private readonly onEvent: FileWatcherCallback, + private readonly options: FileWatcherOptions = {}, + ) {} + + private normalizeRelativePath(filePath: string): string { + const relativeOrOriginal = path.isAbsolute(filePath) + ? path.relative(this.projectRoot, filePath) + : filePath; + + const normalized = relativeOrOriginal.replaceAll('\\', '/'); + if (normalized === '' || normalized === '.') { + return ''; + } + if (normalized.startsWith('./')) { + return normalized.slice(2); + } + return normalized; + } + + start(): void { + if (this.watcher) { + return; + } + + this.watcher = watch(this.projectRoot, { + cwd: this.projectRoot, + ignoreInitial: true, + awaitWriteFinish: false, + followSymlinks: false, + persistent: true, + ignored: (filePath: string) => { + if (!this.options.shouldIgnore) { + return false; + } + const relativePath = this.normalizeRelativePath(filePath); + if (!relativePath) { + return false; + } + return this.options.shouldIgnore(relativePath); + }, + }); + + this.watcher + .on('add', (relativePath: string) => { + this.onEvent({ + eventType: 'add', + relativePath: this.normalizeRelativePath(relativePath), + }); + }) + .on('unlink', (relativePath: string) => { + this.onEvent({ + eventType: 'unlink', + relativePath: this.normalizeRelativePath(relativePath), + }); + }) + .on('addDir', (relativePath: string) => { + this.onEvent({ + eventType: 'addDir', + relativePath: this.normalizeRelativePath(relativePath), + }); + }) + .on('unlinkDir', (relativePath: string) => { + this.onEvent({ + eventType: 'unlinkDir', + relativePath: this.normalizeRelativePath(relativePath), + }); + }) + .on('error', (error: unknown) => { + this.options.onError?.(error); + }); + } + + stop(): void { + void this.watcher?.close(); + this.watcher = null; + } +} From 181db9afb7e28e907c69644fa758f2706b31814d Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Mon, 13 Apr 2026 13:33:47 +0600 Subject: [PATCH 3/7] fix(filesearch): cap watcher added entries to maxFiles limit --- package-lock.json | 2 +- packages/core/src/utils/filesearch/fileSearch.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26e73c8665f..8635aba63c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18175,7 +18175,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.0", "chardet": "^2.1.0", - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "diff": "^8.0.3", "dotenv": "^17.2.4", "dotenv-expand": "^12.0.3", diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index e6281cf0112..a48400a1ebd 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -215,7 +215,10 @@ class RecursiveFileSearch implements FileSearch { let changed = false; if (event.eventType === 'add') { - if (fileFilter?.(normalizedPath)) { + if ( + fileFilter?.(normalizedPath) || + this.allFiles.size >= (this.options.maxFiles ?? 20000) + ) { return; } const sizeBefore = this.allFiles.size; @@ -227,7 +230,10 @@ class RecursiveFileSearch implements FileSearch { const directoryPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; - if (directoryFilter?.(directoryPath)) { + if ( + directoryFilter?.(directoryPath) || + this.allFiles.size >= (this.options.maxFiles ?? 20000) + ) { return; } const sizeBefore = this.allFiles.size; From d7a8134cc190327a3fbc7710b4e28cd3fd76b729 Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Mon, 13 Apr 2026 13:47:49 +0600 Subject: [PATCH 4/7] fix(filesearch): log watcher errors via debugLogger --- packages/core/src/utils/filesearch/fileSearch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index a48400a1ebd..ec60f18626f 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -13,6 +13,7 @@ import { AsyncFzf, type FzfResultItem } from 'fzf'; import { unescapePath } from '../paths.js'; import type { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; import { FileWatcher, type FileWatcherEvent } from './fileWatcher.js'; +import { debugLogger } from '../debugLogger.js'; // Tiebreaker: Prefers shorter paths. const byLengthAsc = (a: { item: string }, b: { item: string }) => @@ -169,6 +170,9 @@ class RecursiveFileSearch implements FileSearch { (event) => this.handleFileWatcherEvent(event), { shouldIgnore: (relativePath) => directoryFilter(`${relativePath}/`), + onError(error) { + debugLogger.error('File search watcher error: ', error); + }, }, ); this.fileWatcher.start(); From ccbd81cfcb6a86e2ba525dae3f5e81532a693493 Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Tue, 14 Apr 2026 02:22:59 +0600 Subject: [PATCH 5/7] refactor(filesearch): make watcher cleanup async and simplify debounce reset logic --- packages/cli/src/config/settingsSchema.ts | 4 +- packages/cli/src/ui/hooks/useAtCompletion.ts | 38 ++++++++++++++----- packages/core/src/config/constants.ts | 4 +- .../core/src/utils/filesearch/fileSearch.ts | 32 +++------------- .../src/utils/filesearch/fileWatcher.test.ts | 14 +++---- .../core/src/utils/filesearch/fileWatcher.ts | 4 +- 6 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d382800c5f2..f6983c8967d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1411,11 +1411,11 @@ const SETTINGS_SCHEMA = { label: 'Enable File Watcher', category: 'Context', requiresRestart: true, - default: true, + default: false, description: oneLine` Enable file watcher updates for @ file suggestions (experimental). `, - showInDialog: true, + showInDialog: false, }, enableRecursiveFileSearch: { type: 'boolean', diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 900fc38a689..5a13aac8d6a 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -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 { @@ -224,18 +224,29 @@ export function useAtCompletion(props: UseAtCompletionProps): void { setIsLoadingSuggestions(state.isLoading); }, [state.isLoading, setIsLoadingSuggestions]); - const resetFileSearchState = () => { - for (const searcher of fileSearchMap.current.values()) { - searcher.close?.(); - } + const disposeFileSearchers = useCallback(async () => { + const searchers = [...fileSearchMap.current.values()]; fileSearchMap.current.clear(); initEpoch.current += 1; + + const closePromises: Array> = []; + for (const searcher of searchers) { + if (searcher.close) { + closePromises.push(searcher.close()); + } + } + await Promise.all(closePromises); + }, []); + + const resetFileSearchState = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + disposeFileSearchers(); dispatch({ type: 'RESET' }); - }; + }, [disposeFileSearchers]); useEffect(() => { resetFileSearchState(); - }, [cwd, config]); + }, [cwd, config, resetFileSearchState]); useEffect(() => { const workspaceContext = config?.getWorkspaceContext?.(); @@ -245,7 +256,16 @@ export function useAtCompletion(props: UseAtCompletionProps): void { workspaceContext.onDirectoriesChanged(resetFileSearchState); return unsubscribe; - }, [config]); + }, [config, resetFileSearchState]); + + useEffect(() => () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + disposeFileSearchers(); + searchAbortController.current?.abort(); + if (slowSearchTimer.current) { + clearTimeout(slowSearchTimer.current); + } + }, [disposeFileSearchers]); // Reacts to user input (`pattern`) ONLY. useEffect(() => { @@ -299,7 +319,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { cache: true, cacheTtl: 30, enableFileWatcher: - config?.getFileFilteringOptions()?.enableFileWatcher ?? true, + config?.getFileFilteringOptions()?.enableFileWatcher ?? false, enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true, enableFuzzySearch: diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index e9d0f3feb55..a3da3f1e882 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -17,7 +17,7 @@ export interface FileFilteringOptions { export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGitIgnore: false, respectGeminiIgnore: true, - enableFileWatcher: true, + enableFileWatcher: false, maxFileCount: 20000, searchTimeout: 5000, customIgnoreFilePaths: [], @@ -27,7 +27,7 @@ export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGitIgnore: true, respectGeminiIgnore: true, - enableFileWatcher: true, + enableFileWatcher: false, maxFileCount: 20000, searchTimeout: 5000, customIgnoreFilePaths: [], diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index ec60f18626f..929c5dcb68e 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -129,7 +129,7 @@ export interface SearchOptions { export interface FileSearch { initialize(): Promise; search(pattern: string, options?: SearchOptions): Promise; - close?(): void; + close?(): Promise; } class RecursiveFileSearch implements FileSearch { @@ -139,7 +139,6 @@ class RecursiveFileSearch implements FileSearch { private fzf: AsyncFzf | undefined; private fileWatcher: FileWatcher | undefined; private rebuildTimer: NodeJS.Timeout | undefined; - private rebuildScheduled = false; constructor(private readonly options: FileSearchOptions) {} @@ -180,34 +179,16 @@ class RecursiveFileSearch implements FileSearch { } private scheduleRebuild(): void { - this.rebuildScheduled = true; if (this.rebuildTimer) { - return; + clearTimeout(this.rebuildTimer); } this.rebuildTimer = setTimeout(() => { this.rebuildTimer = undefined; - if (this.rebuildScheduled) { - this.rebuildScheduled = false; - this.buildResultCache(); - } + this.buildResultCache(); }, 150); } - private flushPendingRebuild(): void { - if (!this.rebuildScheduled) { - return; - } - - if (this.rebuildTimer) { - clearTimeout(this.rebuildTimer); - this.rebuildTimer = undefined; - } - - this.rebuildScheduled = false; - this.buildResultCache(); - } - private handleFileWatcherEvent(event: FileWatcherEvent): void { const normalizedPath = event.relativePath.replaceAll('\\', '/'); if (!normalizedPath || normalizedPath === '.') { @@ -276,8 +257,6 @@ class RecursiveFileSearch implements FileSearch { throw new Error('Engine not initialized. Call initialize() first.'); } - this.flushPendingRebuild(); - pattern = unescapePath(pattern) || '*'; let filteredCandidates; @@ -333,14 +312,13 @@ class RecursiveFileSearch implements FileSearch { return results; } - close(): void { - this.fileWatcher?.stop(); + async close(): Promise { + await this.fileWatcher?.close(); this.fileWatcher = undefined; if (this.rebuildTimer) { clearTimeout(this.rebuildTimer); this.rebuildTimer = undefined; } - this.rebuildScheduled = false; } private buildResultCache(): void { diff --git a/packages/core/src/utils/filesearch/fileWatcher.test.ts b/packages/core/src/utils/filesearch/fileWatcher.test.ts index cb3066901fe..9ecf6066e8c 100644 --- a/packages/core/src/utils/filesearch/fileWatcher.test.ts +++ b/packages/core/src/utils/filesearch/fileWatcher.test.ts @@ -56,7 +56,7 @@ describe('FileWatcher', () => { await fs.rm(filePath, { force: true }); await sleep(1200); - watcher.stop(); + await watcher.close(); expect(events).toContainEqual({ eventType: 'add', relativePath: fileName }); expect(events).toContainEqual({ @@ -87,7 +87,7 @@ describe('FileWatcher', () => { await fs.writeFile(path.join(tmpDir, 'kept.txt'), 'x'); await sleep(1200); - watcher.stop(); + await watcher.close(); expect(events.some((event) => event.relativePath === 'ignored.txt')).toBe( false, @@ -126,7 +126,7 @@ describe('FileWatcher', () => { event.eventType === 'unlinkDir' && event.relativePath === dirName, ); - watcher.stop(); + await watcher.close(); }); it('should normalize nested paths without leading dot prefix', async () => { @@ -159,7 +159,7 @@ describe('FileWatcher', () => { expect(nestedFileEvent!.relativePath.startsWith('./')).toBe(false); expect(nestedFileEvent!.relativePath.includes('\\')).toBe(false); - watcher.stop(); + await watcher.close(); }); it('should not emit new events after stop is called', async () => { @@ -182,7 +182,7 @@ describe('FileWatcher', () => { event.eventType === 'add' && event.relativePath === 'before-stop.txt', ); - watcher.stop(); + await watcher.close(); const afterStopCount = events.length; await fs.writeFile(path.join(tmpDir, 'after-stop.txt'), 'x'); @@ -211,8 +211,8 @@ describe('FileWatcher', () => { event.eventType === 'add' && event.relativePath === 'idempotent.txt', ); - watcher.stop(); - watcher.stop(); + await watcher.close(); + await watcher.close(); expect(events.length).toBeGreaterThan(0); }); diff --git a/packages/core/src/utils/filesearch/fileWatcher.ts b/packages/core/src/utils/filesearch/fileWatcher.ts index 687ffc2b84a..1d503d051c0 100644 --- a/packages/core/src/utils/filesearch/fileWatcher.ts +++ b/packages/core/src/utils/filesearch/fileWatcher.ts @@ -96,8 +96,8 @@ export class FileWatcher { }); } - stop(): void { - void this.watcher?.close(); + async close(): Promise { + await this.watcher?.close(); this.watcher = null; } } From c55f59208de804352157383e13cf6dbc5232968b Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Wed, 15 Apr 2026 23:12:00 +0600 Subject: [PATCH 6/7] refactor(filesearch): switch watcher event handling to switch-case and clean async disposer calls --- packages/cli/src/ui/hooks/useAtCompletion.ts | 13 +-- .../core/src/utils/filesearch/fileSearch.ts | 79 +++++++++++-------- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 5a13aac8d6a..8bec10ed0bd 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -239,8 +239,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { }, []); const resetFileSearchState = useCallback(() => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - disposeFileSearchers(); + void disposeFileSearchers(); dispatch({ type: 'RESET' }); }, [disposeFileSearchers]); @@ -258,14 +257,16 @@ export function useAtCompletion(props: UseAtCompletionProps): void { return unsubscribe; }, [config, resetFileSearchState]); - useEffect(() => () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - disposeFileSearchers(); + useEffect( + () => () => { + void disposeFileSearchers(); searchAbortController.current?.abort(); if (slowSearchTimer.current) { clearTimeout(slowSearchTimer.current); } - }, [disposeFileSearchers]); + }, + [disposeFileSearchers], + ); // Reacts to user input (`pattern`) ONLY. useEffect(() => { diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 929c5dcb68e..3cc2100618b 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -199,45 +199,56 @@ class RecursiveFileSearch implements FileSearch { const directoryFilter = this.ignore?.getDirectoryFilter(); let changed = false; - if (event.eventType === 'add') { - if ( - fileFilter?.(normalizedPath) || - this.allFiles.size >= (this.options.maxFiles ?? 20000) - ) { - return; + switch (event.eventType) { + case 'add': { + if ( + fileFilter?.(normalizedPath) || + this.allFiles.size >= (this.options.maxFiles ?? 20000) + ) { + return; + } + const sizeBefore = this.allFiles.size; + this.allFiles.add(normalizedPath); + changed = this.allFiles.size !== sizeBefore; + break; } - const sizeBefore = this.allFiles.size; - this.allFiles.add(normalizedPath); - changed = this.allFiles.size !== sizeBefore; - } else if (event.eventType === 'unlink') { - changed = this.allFiles.delete(normalizedPath); - } else if (event.eventType === 'addDir') { - const directoryPath = normalizedPath.endsWith('/') - ? normalizedPath - : `${normalizedPath}/`; - if ( - directoryFilter?.(directoryPath) || - this.allFiles.size >= (this.options.maxFiles ?? 20000) - ) { - return; + case 'unlink': { + changed = this.allFiles.delete(normalizedPath); + break; } - const sizeBefore = this.allFiles.size; - this.allFiles.add(directoryPath); - changed = this.allFiles.size !== sizeBefore; - } else if (event.eventType === 'unlinkDir') { - const directoryPath = normalizedPath.endsWith('/') - ? normalizedPath - : `${normalizedPath}/`; - const toDelete: string[] = []; - for (const file of this.allFiles) { - if (file === directoryPath || file.startsWith(directoryPath)) { - toDelete.push(file); + case 'addDir': { + const directoryPath = normalizedPath.endsWith('/') + ? normalizedPath + : `${normalizedPath}/`; + if ( + directoryFilter?.(directoryPath) || + this.allFiles.size >= (this.options.maxFiles ?? 20000) + ) { + return; } + const sizeBefore = this.allFiles.size; + this.allFiles.add(directoryPath); + changed = this.allFiles.size !== sizeBefore; + break; } - changed = toDelete.length > 0; - for (const file of toDelete) { - this.allFiles.delete(file); + case 'unlinkDir': { + const directoryPath = normalizedPath.endsWith('/') + ? normalizedPath + : `${normalizedPath}/`; + const toDelete: string[] = []; + for (const file of this.allFiles) { + if (file === directoryPath || file.startsWith(directoryPath)) { + toDelete.push(file); + } + } + changed = toDelete.length > 0; + for (const file of toDelete) { + this.allFiles.delete(file); + } + break; } + default: + return; } if (changed) { From 8db773af0bc6ae4d47a1b5e75356027f3099599c Mon Sep 17 00:00:00 2001 From: PRASSamin Date: Tue, 21 Apr 2026 12:18:52 +0600 Subject: [PATCH 7/7] feat(core): add enableFileWatcher config for @ file suggestions --- docs/reference/configuration.md | 6 ++++++ packages/core/src/utils/filesearch/fileWatcher.test.ts | 3 ++- schemas/settings.schema.json | 7 +++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a5a6aa1eb25..8f909866155 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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. diff --git a/packages/core/src/utils/filesearch/fileWatcher.test.ts b/packages/core/src/utils/filesearch/fileWatcher.test.ts index 9ecf6066e8c..189b97213b2 100644 --- a/packages/core/src/utils/filesearch/fileWatcher.test.ts +++ b/packages/core/src/utils/filesearch/fileWatcher.test.ts @@ -6,7 +6,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanupTmpDir, createTmpDir } from '@google/gemini-cli-test-utils'; import { FileWatcher, type FileWatcherEvent } from './fileWatcher.js'; @@ -33,6 +33,7 @@ describe('FileWatcher', () => { afterEach(async () => { await Promise.all(tmpDirs.map((dir) => cleanupTmpDir(dir))); tmpDirs.length = 0; + vi.restoreAllMocks(); }); it('should emit relative add and unlink events for files', async () => { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 491db887a40..261dd88cb5e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2370,6 +2370,13 @@ "default": true, "type": "boolean" }, + "enableFileWatcher": { + "title": "Enable File Watcher", + "description": "Enable file watcher updates for @ file suggestions (experimental).", + "markdownDescription": "Enable file watcher updates for @ file suggestions (experimental).\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "enableRecursiveFileSearch": { "title": "Enable Recursive File Search", "description": "Enable recursive file search functionality when completing @ references in the prompt.",