mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
feat: detect new files in @ recommendations with watcher based updates (#25256)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1471,6 +1471,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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
}, []);
|
||||
|
||||
const resetFileSearchState = useCallback(() => {
|
||||
void disposeFileSearchers();
|
||||
dispatch({ type: 'RESET' });
|
||||
};
|
||||
}, [disposeFileSearchers]);
|
||||
|
||||
useEffect(() => {
|
||||
resetFileSearchState();
|
||||
}, [cwd, config]);
|
||||
}, [cwd, config, resetFileSearchState]);
|
||||
|
||||
useEffect(() => {
|
||||
const workspaceContext = config?.getWorkspaceContext?.();
|
||||
@@ -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(() => {
|
||||
@@ -295,6 +319,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
|
||||
),
|
||||
cache: true,
|
||||
cacheTtl: 30,
|
||||
enableFileWatcher:
|
||||
config?.getFileFilteringOptions()?.enableFileWatcher ?? false,
|
||||
enableRecursiveFileSearch:
|
||||
config?.getEnableRecursiveFileSearch() ?? true,
|
||||
enableFuzzySearch:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -616,6 +616,7 @@ export interface ConfigParameters {
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableFileWatcher?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
enableFuzzySearch?: boolean;
|
||||
maxFileCount?: number;
|
||||
@@ -799,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly fileFiltering: {
|
||||
respectGitIgnore: boolean;
|
||||
respectGeminiIgnore: boolean;
|
||||
enableFileWatcher: boolean;
|
||||
enableRecursiveFileSearch: boolean;
|
||||
enableFuzzySearch: boolean;
|
||||
maxFileCount: number;
|
||||
@@ -1080,6 +1082,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,
|
||||
@@ -2831,6 +2837,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,
|
||||
|
||||
@@ -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: false,
|
||||
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: false,
|
||||
maxFileCount: 20000,
|
||||
searchTimeout: 5000,
|
||||
customIgnoreFilePaths: [],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
import { debugLogger } from '../debugLogger.js';
|
||||
|
||||
// Tiebreaker: Prefers shorter paths.
|
||||
const byLengthAsc = (a: { item: string }, b: { item: string }) =>
|
||||
@@ -57,6 +59,7 @@ export interface FileSearchOptions {
|
||||
fileDiscoveryService: FileDiscoveryService;
|
||||
cache: boolean;
|
||||
cacheTtl: number;
|
||||
enableFileWatcher?: boolean;
|
||||
enableRecursiveFileSearch: boolean;
|
||||
enableFuzzySearch: boolean;
|
||||
maxDepth?: number;
|
||||
@@ -126,13 +129,16 @@ export interface SearchOptions {
|
||||
export interface FileSearch {
|
||||
initialize(): Promise<void>;
|
||||
search(pattern: string, options?: SearchOptions): Promise<string[]>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
class RecursiveFileSearch implements FileSearch {
|
||||
private ignore: Ignore | undefined;
|
||||
private resultCache: ResultCache | undefined;
|
||||
private allFiles: string[] = [];
|
||||
private allFiles: Set<string> = new Set();
|
||||
private fzf: AsyncFzf<string[]> | undefined;
|
||||
private fileWatcher: FileWatcher | undefined;
|
||||
private rebuildTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(private readonly options: FileSearchOptions) {}
|
||||
|
||||
@@ -142,17 +148,112 @@ 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}/`),
|
||||
onError(error) {
|
||||
debugLogger.error('File search watcher error: ', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
this.fileWatcher.start();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRebuild(): void {
|
||||
if (this.rebuildTimer) {
|
||||
clearTimeout(this.rebuildTimer);
|
||||
}
|
||||
|
||||
this.rebuildTimer = setTimeout(() => {
|
||||
this.rebuildTimer = undefined;
|
||||
this.buildResultCache();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
case 'unlink': {
|
||||
changed = this.allFiles.delete(normalizedPath);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
this.scheduleRebuild();
|
||||
}
|
||||
}
|
||||
|
||||
async search(
|
||||
@@ -222,14 +323,24 @@ class RecursiveFileSearch implements FileSearch {
|
||||
return results;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.fileWatcher?.close();
|
||||
this.fileWatcher = undefined;
|
||||
if (this.rebuildTimer) {
|
||||
clearTimeout(this.rebuildTimer);
|
||||
this.rebuildTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @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, vi } 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;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await watcher.close();
|
||||
|
||||
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);
|
||||
|
||||
await watcher.close();
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
await watcher.close();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await watcher.close();
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
await watcher.close();
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
await watcher.close();
|
||||
await watcher.close();
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.watcher?.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user