summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDeWitt Clinton <[email protected]>2025-06-12 07:09:38 -0700
committerGitHub <[email protected]>2025-06-12 07:09:38 -0700
commitf2ab6d08c4cd0ca9a5b3900a6bb66a083c0577ee (patch)
treedb2083848fc070f0de6ac4fe385f59e71a04fc46
parent9072a4e5ee5d6a8b524be40b0465b66e5d3dceba (diff)
Improve the performance of filename completion over large repositories. (#938)
-rw-r--r--eslint.config.js1
-rw-r--r--packages/cli/package.json4
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts24
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts52
-rw-r--r--packages/core/src/services/fileDiscoveryService.ts12
-rw-r--r--packages/core/src/utils/gitIgnoreParser.ts2
6 files changed, 84 insertions, 11 deletions
diff --git a/eslint.config.js b/eslint.config.js
index 9bf13cde..443bd9ae 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -32,6 +32,7 @@ export default tseslint.config(
'eslint.config.js',
'packages/cli/dist/**',
'packages/core/dist/**',
+ 'packages/server/dist/**',
'eslint-rules/*',
'bundle/**',
],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 03eca1c7..1e1c86a5 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -30,6 +30,7 @@
},
"dependencies": {
"@gemini-cli/core": "file:../core",
+ "command-exists": "^1.2.9",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"highlight.js": "^11.11.1",
@@ -48,18 +49,17 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
- "command-exists": "^1.2.9",
"yargs": "^17.7.2"
},
"devDependencies": {
"@testing-library/react": "^14.0.0",
+ "@types/command-exists": "^1.2.3",
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
"@types/react": "^18.3.1",
"@types/shell-quote": "^1.7.5",
"@types/yargs": "^17.0.32",
- "@types/command-exists": "^1.2.3",
"ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0",
"typescript": "^5.3.3",
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
index 683d3cb1..c38006c3 100644
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
@@ -42,6 +42,7 @@ describe('useCompletion git-aware filtering integration', () => {
shouldIgnoreFile: vi.fn(),
filterFiles: vi.fn(),
getIgnoreInfo: vi.fn(() => ({ gitIgnored: [], customIgnored: [] })),
+ glob: vi.fn().mockResolvedValue([]),
};
mockConfig = {
@@ -225,4 +226,27 @@ describe('useCompletion git-aware filtering integration', () => {
{ 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' },
+ ]);
+ });
});
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 66457aac..810c6de0 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -13,6 +13,7 @@ import {
unescapePath,
getErrorMessage,
Config,
+ FileDiscoveryService,
} from '@gemini-cli/core';
import {
MAX_SUGGESTIONS_TO_SHOW,
@@ -251,21 +252,53 @@ export function useCompletion(
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 () => {
setIsLoadingSuggestions(true);
let fetchedSuggestions: Suggestion[] = [];
- // Get centralized file discovery service if config is available
- const fileDiscovery = config ? await config.getFileService() : null;
+ const fileDiscoveryService = config
+ ? await config.getFileService()
+ : null;
try {
// If there's no slash, or it's the root, do a recursive search from cwd
if (partialPath.indexOf('/') === -1 && prefix) {
- fetchedSuggestions = await findFilesRecursively(
- cwd,
- prefix,
- fileDiscovery,
- );
+ if (fileDiscoveryService) {
+ fetchedSuggestions = await findFilesWithGlob(
+ prefix,
+ fileDiscoveryService,
+ );
+ } else {
+ fetchedSuggestions = await findFilesRecursively(
+ cwd,
+ prefix,
+ fileDiscoveryService,
+ );
+ }
} else {
// Original behavior: list files in the specific directory
const lowerPrefix = prefix.toLowerCase();
@@ -282,7 +315,10 @@ export function useCompletion(
cwd,
path.join(baseDirAbsolute, entry.name),
);
- if (fileDiscovery && fileDiscovery.shouldIgnoreFile(relativePath)) {
+ if (
+ fileDiscoveryService &&
+ fileDiscoveryService.shouldIgnoreFile(relativePath)
+ ) {
continue;
}
diff --git a/packages/core/src/services/fileDiscoveryService.ts b/packages/core/src/services/fileDiscoveryService.ts
index 3874e752..f117813d 100644
--- a/packages/core/src/services/fileDiscoveryService.ts
+++ b/packages/core/src/services/fileDiscoveryService.ts
@@ -7,6 +7,7 @@
import { GitIgnoreParser, GitIgnoreFilter } from '../utils/gitIgnoreParser.js';
import { isGitRepository } from '../utils/gitUtils.js';
import * as path from 'path';
+import fg from 'fast-glob';
export interface FileDiscoveryOptions {
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
*/
diff --git a/packages/core/src/utils/gitIgnoreParser.ts b/packages/core/src/utils/gitIgnoreParser.ts
index ae1a7a01..d5d013a8 100644
--- a/packages/core/src/utils/gitIgnoreParser.ts
+++ b/packages/core/src/utils/gitIgnoreParser.ts
@@ -6,7 +6,7 @@
import * as fs from 'fs/promises';
import * as path from 'path';
-import ignore, { Ignore } from 'ignore';
+import ignore, { type Ignore } from 'ignore';
import { isGitRepository } from './gitUtils.js';
export interface GitIgnoreFilter {