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 => { + 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 { + 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 {