Improve the performance of filename completion over large repositories. (#938)

This commit is contained in:
DeWitt Clinton 2025-06-12 07:09:38 -07:00 committed by GitHub
parent 9072a4e5ee
commit f2ab6d08c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 84 additions and 11 deletions

View File

@ -32,6 +32,7 @@ export default tseslint.config(
'eslint.config.js', 'eslint.config.js',
'packages/cli/dist/**', 'packages/cli/dist/**',
'packages/core/dist/**', 'packages/core/dist/**',
'packages/server/dist/**',
'eslint-rules/*', 'eslint-rules/*',
'bundle/**', 'bundle/**',
], ],

View File

@ -30,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@gemini-cli/core": "file:../core", "@gemini-cli/core": "file:../core",
"command-exists": "^1.2.9",
"diff": "^7.0.0", "diff": "^7.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@ -48,18 +49,17 @@
"string-width": "^7.1.0", "string-width": "^7.1.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1", "strip-json-comments": "^3.1.1",
"command-exists": "^1.2.9",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/command-exists": "^1.2.3",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1", "@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.32",
"@types/command-exists": "^1.2.3",
"ink-testing-library": "^4.0.0", "ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",

View File

@ -42,6 +42,7 @@ describe('useCompletion git-aware filtering integration', () => {
shouldIgnoreFile: vi.fn(), shouldIgnoreFile: vi.fn(),
filterFiles: vi.fn(), filterFiles: vi.fn(),
getIgnoreInfo: vi.fn(() => ({ gitIgnored: [], customIgnored: [] })), getIgnoreInfo: vi.fn(() => ({ gitIgnored: [], customIgnored: [] })),
glob: vi.fn().mockResolvedValue([]),
}; };
mockConfig = { mockConfig = {
@ -225,4 +226,27 @@ describe('useCompletion git-aware filtering integration', () => {
{ label: 'component.tsx', value: 'component.tsx' }, { label: 'component.tsx', value: 'component.tsx' },
]); ]);
}); });
it('should use glob for top-level @ completions when available', async () => {
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
mockFileDiscoveryService.glob.mockResolvedValue(globResults);
const { result } = renderHook(() =>
useCompletion('@s', testCwd, true, slashCommands, mockConfig),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(mockFileDiscoveryService.glob).toHaveBeenCalledWith('**/s*', {
cwd: testCwd,
dot: true,
});
expect(fs.readdir).not.toHaveBeenCalled(); // Ensure glob is used instead of readdir
expect(result.current.suggestions).toEqual([
{ label: 'README.md', value: 'README.md' },
{ label: 'src/index.ts', value: 'src/index.ts' },
]);
});
}); });

View File

@ -13,6 +13,7 @@ import {
unescapePath, unescapePath,
getErrorMessage, getErrorMessage,
Config, Config,
FileDiscoveryService,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { import {
MAX_SUGGESTIONS_TO_SHOW, MAX_SUGGESTIONS_TO_SHOW,
@ -251,21 +252,53 @@ export function useCompletion(
return foundSuggestions.slice(0, maxResults); return foundSuggestions.slice(0, maxResults);
}; };
const findFilesWithGlob = async (
searchPrefix: string,
fileDiscoveryService: FileDiscoveryService,
maxResults = 50,
): Promise<Suggestion[]> => {
const globPattern = `**/${searchPrefix}*`;
const files = await fileDiscoveryService.glob(globPattern, {
cwd,
dot: true,
});
const suggestions: Suggestion[] = files
.map((file: string) => {
const relativePath = path.relative(cwd, file);
return {
label: relativePath,
value: escapePath(relativePath),
};
})
.slice(0, maxResults);
return suggestions;
};
const fetchSuggestions = async () => { const fetchSuggestions = async () => {
setIsLoadingSuggestions(true); setIsLoadingSuggestions(true);
let fetchedSuggestions: Suggestion[] = []; let fetchedSuggestions: Suggestion[] = [];
// Get centralized file discovery service if config is available const fileDiscoveryService = config
const fileDiscovery = config ? await config.getFileService() : null; ? await config.getFileService()
: null;
try { try {
// If there's no slash, or it's the root, do a recursive search from cwd // If there's no slash, or it's the root, do a recursive search from cwd
if (partialPath.indexOf('/') === -1 && prefix) { if (partialPath.indexOf('/') === -1 && prefix) {
fetchedSuggestions = await findFilesRecursively( if (fileDiscoveryService) {
cwd, fetchedSuggestions = await findFilesWithGlob(
prefix, prefix,
fileDiscovery, fileDiscoveryService,
); );
} else {
fetchedSuggestions = await findFilesRecursively(
cwd,
prefix,
fileDiscoveryService,
);
}
} else { } else {
// Original behavior: list files in the specific directory // Original behavior: list files in the specific directory
const lowerPrefix = prefix.toLowerCase(); const lowerPrefix = prefix.toLowerCase();
@ -282,7 +315,10 @@ export function useCompletion(
cwd, cwd,
path.join(baseDirAbsolute, entry.name), path.join(baseDirAbsolute, entry.name),
); );
if (fileDiscovery && fileDiscovery.shouldIgnoreFile(relativePath)) { if (
fileDiscoveryService &&
fileDiscoveryService.shouldIgnoreFile(relativePath)
) {
continue; continue;
} }

View File

@ -7,6 +7,7 @@
import { GitIgnoreParser, GitIgnoreFilter } from '../utils/gitIgnoreParser.js'; import { GitIgnoreParser, GitIgnoreFilter } from '../utils/gitIgnoreParser.js';
import { isGitRepository } from '../utils/gitUtils.js'; import { isGitRepository } from '../utils/gitUtils.js';
import * as path from 'path'; import * as path from 'path';
import fg from 'fast-glob';
export interface FileDiscoveryOptions { export interface FileDiscoveryOptions {
respectGitIgnore?: boolean; respectGitIgnore?: boolean;
@ -32,6 +33,17 @@ export class FileDiscoveryService {
} }
} }
async glob(
pattern: string | string[],
options: fg.Options = {},
): Promise<string[]> {
const files = await fg(pattern, {
...options,
caseSensitiveMatch: false,
});
return this.filterFiles(files);
}
/** /**
* Filters a list of file paths based on git ignore rules * Filters a list of file paths based on git ignore rules
*/ */

View File

@ -6,7 +6,7 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import ignore, { Ignore } from 'ignore'; import ignore, { type Ignore } from 'ignore';
import { isGitRepository } from './gitUtils.js'; import { isGitRepository } from './gitUtils.js';
export interface GitIgnoreFilter { export interface GitIgnoreFilter {