diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 40bf24cbe4..1c43149440 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { directoryCommand } from './directoryCommand.js'; -import { expandHomeDir } from '../utils/directoryUtils.js'; +import { + expandHomeDir, + getDirectorySuggestions, +} from '../utils/directoryUtils.js'; import type { Config, WorkspaceContext } from '@google/gemini-cli-core'; import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js'; import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; @@ -17,6 +20,15 @@ import * as path from 'node:path'; import * as trustedFolders from '../../config/trustedFolders.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; +vi.mock('../utils/directoryUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getDirectorySuggestions: vi.fn(), + }; +}); + describe('directoryCommand', () => { let mockContext: CommandContext; let mockConfig: Config; @@ -217,6 +229,47 @@ describe('directoryCommand', () => { expect.any(Number), ); }); + + describe('completion', () => { + const completion = addCommand!.completion!; + + it('should return empty suggestions for an empty path', async () => { + const results = await completion(mockContext, ''); + expect(results).toEqual([]); + }); + + it('should return empty suggestions for whitespace only path', async () => { + const results = await completion(mockContext, ' '); + expect(results).toEqual([]); + }); + + it('should return suggestions for a single path', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']); + + const results = await completion(mockContext, 'd'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('d'); + expect(results).toEqual(['docs/', 'src/']); + }); + + it('should return suggestions for multiple paths', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/,s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/,src/']); + }); + + it('should handle leading whitespace in suggestions', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/, s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/, src/']); + }); + }); }); describe('add with folder trust enabled', () => { diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index f1aa62b800..872945ecea 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -14,7 +14,10 @@ import type { SlashCommand, CommandContext } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItem } from '../types.js'; import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; -import { expandHomeDir } from '../utils/directoryUtils.js'; +import { + expandHomeDir, + getDirectorySuggestions, +} from '../utils/directoryUtils.js'; import type { Config } from '@google/gemini-cli-core'; async function finishAddingDirectories( @@ -80,6 +83,27 @@ export const directoryCommand: SlashCommand = { 'Add directories to the workspace. Use comma to separate multiple paths', kind: CommandKind.BUILT_IN, autoExecute: false, + showCompletionLoading: false, + completion: async (context: CommandContext, partialArg: string) => { + // Support multiple paths separated by commas + const parts = partialArg.split(','); + const lastPart = parts[parts.length - 1]; + const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? ''; + const trimmedLastPart = lastPart.trimStart(); + + if (trimmedLastPart === '') { + return []; + } + + const suggestions = await getDirectorySuggestions(trimmedLastPart); + + if (parts.length > 1) { + const prefix = parts.slice(0, -1).join(',') + ','; + return suggestions.map((s) => prefix + leadingWhitespace + s); + } + + return suggestions.map((s) => leadingWhitespace + s); + }, action: async (context: CommandContext, args: string) => { const { ui: { addItem }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6f00695dc4..2165ab377a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -201,5 +201,11 @@ export interface SlashCommand { partialArg: string, ) => Promise | string[]; + /** + * Whether to show the loading indicator while fetching completions. + * Defaults to true. Set to false for fast completions to avoid flicker. + */ + showCompletionLoading?: boolean; + subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 5e6631f453..7809d6cf0f 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -212,7 +212,10 @@ function useCommandSuggestions( return; } - setIsLoading(true); + const showLoading = leafCommand.showCompletionLoading !== false; + if (showLoading) { + setIsLoading(true); + } try { const rawParts = [...commandPathParts]; if (partial) rawParts.push(partial); diff --git a/packages/cli/src/ui/utils/directoryUtils.test.ts b/packages/cli/src/ui/utils/directoryUtils.test.ts index e86c44d1fa..b001ece22c 100644 --- a/packages/cli/src/ui/utils/directoryUtils.test.ts +++ b/packages/cli/src/ui/utils/directoryUtils.test.ts @@ -4,10 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect } from 'vitest'; -import { expandHomeDir } from './directoryUtils.js'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { expandHomeDir, getDirectorySuggestions } from './directoryUtils.js'; import type * as osActual from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -33,7 +35,47 @@ vi.mock('node:os', async (importOriginal) => { }; }); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + statSync: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + opendir: vi.fn(), +})); + +interface MockDirent { + name: string; + isDirectory: () => boolean; +} + +function createMockDir(entries: MockDirent[]) { + let index = 0; + const iterator = { + async next() { + if (index < entries.length) { + return { value: entries[index++], done: false }; + } + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + + return { + [Symbol.asyncIterator]() { + return iterator; + }, + close: vi.fn().mockResolvedValue(undefined), + }; +} + describe('directoryUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('expandHomeDir', () => { it('should expand ~ to the home directory', () => { expect(expandHomeDir('~')).toBe(mockHomeDir); @@ -60,4 +102,216 @@ describe('directoryUtils', () => { expect(expandHomeDir('')).toBe(''); }); }); + + describe('getDirectorySuggestions', () => { + it('should return suggestions for an empty path', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'docs', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + { name: 'file.txt', isDirectory: () => false }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toEqual([`docs${path.sep}`, `src${path.sep}`]); + }); + + it('should return suggestions for a partial path', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'docs', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('d'); + expect(suggestions).toEqual([`docs${path.sep}`]); + }); + + it('should return suggestions for a path with trailing slash', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'sub', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('docs/'); + expect(suggestions).toEqual(['docs/sub/']); + }); + + it('should return suggestions for a path with ~', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('~/'); + expect(suggestions).toEqual(['~/Downloads/']); + }); + + it('should return suggestions for a partial path with ~', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('~/Down'); + expect(suggestions).toEqual(['~/Downloads/']); + }); + + it('should return suggestions for ../', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'other-project', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('../'); + expect(suggestions).toEqual(['../other-project/']); + }); + + it('should ignore hidden directories', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: '.git', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toEqual([`src${path.sep}`]); + }); + + it('should show hidden directories when filter starts with .', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: '.git', isDirectory: () => true }, + { name: '.github', isDirectory: () => true }, + { name: '.vscode', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('.g'); + expect(suggestions).toEqual([`.git${path.sep}`, `.github${path.sep}`]); + }); + + it('should return empty array if directory does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const suggestions = await getDirectorySuggestions('nonexistent/'); + expect(suggestions).toEqual([]); + }); + + it('should limit results to 50 suggestions', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + + // Create 200 directories + const manyDirs = Array.from({ length: 200 }, (_, i) => ({ + name: `dir${String(i).padStart(3, '0')}`, + isDirectory: () => true, + })); + + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir(manyDirs) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toHaveLength(50); + }); + + it('should terminate early after 150 matches for performance', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + + // Create 200 directories + const manyDirs = Array.from({ length: 200 }, (_, i) => ({ + name: `dir${String(i).padStart(3, '0')}`, + isDirectory: () => true, + })); + + const mockDir = createMockDir(manyDirs); + vi.mocked(fsPromises.opendir).mockResolvedValue( + mockDir as unknown as fs.Dir, + ); + + await getDirectorySuggestions(''); + + // The close method should be called, indicating early termination + expect(mockDir.close).toHaveBeenCalled(); + }); + }); + + describe.skipIf(process.platform !== 'win32')( + 'getDirectorySuggestions (Windows)', + () => { + it('should handle %userprofile% expansion', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Documents', isDirectory: () => true }, + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + expect(await getDirectorySuggestions('%userprofile%\\')).toEqual([ + `%userprofile%\\Documents${path.sep}`, + `%userprofile%\\Downloads${path.sep}`, + ]); + + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Documents', isDirectory: () => true }, + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + expect(await getDirectorySuggestions('%userprofile%\\Doc')).toEqual([ + `%userprofile%\\Documents${path.sep}`, + ]); + }); + }, + ); }); diff --git a/packages/cli/src/ui/utils/directoryUtils.ts b/packages/cli/src/ui/utils/directoryUtils.ts index e389042c7c..084052525b 100644 --- a/packages/cli/src/ui/utils/directoryUtils.ts +++ b/packages/cli/src/ui/utils/directoryUtils.ts @@ -6,6 +6,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { opendir } from 'node:fs/promises'; + +const MAX_SUGGESTIONS = 50; +const MATCH_BUFFER_MULTIPLIER = 3; export function expandHomeDir(p: string): string { if (!p) { @@ -19,3 +24,118 @@ export function expandHomeDir(p: string): string { } return path.normalize(expandedPath); } + +interface ParsedPath { + searchDir: string; + filter: string; + isHomeExpansion: boolean; + resultPrefix: string; +} + +function parsePartialPath(partialPath: string): ParsedPath { + const isHomeExpansion = partialPath.startsWith('~'); + const expandedPath = expandHomeDir(partialPath || '.'); + + let searchDir: string; + let filter: string; + + if ( + partialPath === '' || + partialPath.endsWith('/') || + partialPath.endsWith(path.sep) + ) { + searchDir = expandedPath; + filter = ''; + } else { + searchDir = path.dirname(expandedPath); + filter = path.basename(expandedPath); + + // Special case for ~ because path.dirname('~') can be '.' + if ( + isHomeExpansion && + !partialPath.includes('/') && + !partialPath.includes(path.sep) + ) { + searchDir = os.homedir(); + filter = partialPath.substring(1); + } + } + + // Calculate result prefix + let resultPrefix = ''; + if ( + partialPath === '' || + partialPath.endsWith('/') || + partialPath.endsWith(path.sep) + ) { + resultPrefix = partialPath; + } else { + const lastSlashIndex = Math.max( + partialPath.lastIndexOf('/'), + partialPath.lastIndexOf(path.sep), + ); + if (lastSlashIndex !== -1) { + resultPrefix = partialPath.substring(0, lastSlashIndex + 1); + } else if (isHomeExpansion) { + resultPrefix = `~${path.sep}`; + } + } + + return { searchDir, filter, isHomeExpansion, resultPrefix }; +} + +/** + * Gets directory suggestions based on a partial path. + * Uses async iteration with fs.opendir for efficient handling of large directories. + * + * @param partialPath The partial path typed by the user. + * @returns A promise resolving to an array of directory path suggestions. + */ +export async function getDirectorySuggestions( + partialPath: string, +): Promise { + try { + const { searchDir, filter, resultPrefix } = parsePartialPath(partialPath); + + if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) { + return []; + } + + const matches: string[] = []; + const filterLower = filter.toLowerCase(); + const showHidden = filter.startsWith('.'); + const dir = await opendir(searchDir); + + try { + for await (const entry of dir) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith('.') && !showHidden) { + continue; + } + + if (entry.name.toLowerCase().startsWith(filterLower)) { + matches.push(entry.name); + + // Early termination with buffer for sorting + if (matches.length >= MAX_SUGGESTIONS * MATCH_BUFFER_MULTIPLIER) { + break; + } + } + } + } finally { + await dir.close().catch(() => {}); + } + + // Use the separator style from user's input for consistency + const userSep = resultPrefix.includes('/') ? '/' : path.sep; + + return matches + .sort() + .slice(0, MAX_SUGGESTIONS) + .map((name) => resultPrefix + name + userSep); + } catch (_) { + return []; + } +}