From 012392ad0a3400829a11058f97059c168dbf1c04 Mon Sep 17 00:00:00 2001 From: Jasmeet Bhatia Date: Wed, 18 Feb 2026 14:38:35 -0800 Subject: [PATCH] feat(cli): include `/dir add` directories in @ autocomplete suggestions (#19246) --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 117 +++++++++++++++++ packages/cli/src/ui/hooks/useAtCompletion.ts | 123 ++++++++++++++---- 2 files changed, 212 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 472ae7f053..02eb4c47f8 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { act, useState } from 'react'; +import * as path from 'node:path'; import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { useAtCompletion } from './useAtCompletion.js'; @@ -589,4 +590,120 @@ describe('useAtCompletion', () => { ]); }); }); + + describe('Multi-directory workspace support', () => { + const multiDirTmpDirs: string[] = []; + + afterEach(async () => { + await Promise.all(multiDirTmpDirs.map((dir) => cleanupTmpDir(dir))); + multiDirTmpDirs.length = 0; + }); + + it('should include files from workspace directories beyond cwd', async () => { + const cwdStructure: FileSystemStructure = { 'main.txt': '' }; + const addedDirStructure: FileSystemStructure = { 'added-file.txt': '' }; + const cwdDir = await createTmpDir(cwdStructure); + multiDirTmpDirs.push(cwdDir); + const addedDir = await createTmpDir(addedDirStructure); + multiDirTmpDirs.push(addedDir); + + const multiDirConfig = { + ...mockConfig, + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: () => [cwdDir, addedDir], + onDirectoriesChanged: vi.fn(() => () => {}), + }), + } as unknown as Config; + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', multiDirConfig, cwdDir), + ); + + await waitFor(() => { + const values = result.current.suggestions.map((s) => s.value); + expect(values).toContain('main.txt'); + expect(values).toContain( + escapePath(path.join(addedDir, 'added-file.txt')), + ); + }); + }); + + it('should pick up newly added directories via onDirectoriesChanged', async () => { + const cwdStructure: FileSystemStructure = { 'original.txt': '' }; + const addedStructure: FileSystemStructure = { 'new-file.txt': '' }; + const cwdDir = await createTmpDir(cwdStructure); + multiDirTmpDirs.push(cwdDir); + const addedDir = await createTmpDir(addedStructure); + multiDirTmpDirs.push(addedDir); + + let dirChangeListener: (() => void) | null = null; + const directories = [cwdDir]; + + const dynamicConfig = { + ...mockConfig, + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: () => [...directories], + onDirectoriesChanged: vi.fn((listener: () => void) => { + dirChangeListener = listener; + return () => { + dirChangeListener = null; + }; + }), + }), + } as unknown as Config; + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', dynamicConfig, cwdDir), + ); + + await waitFor(() => { + const values = result.current.suggestions.map((s) => s.value); + expect(values).toContain('original.txt'); + expect(values.every((v) => !v.includes('new-file.txt'))).toBe(true); + }); + + directories.push(addedDir); + act(() => { + dirChangeListener?.(); + }); + + await waitFor(() => { + const values = result.current.suggestions.map((s) => s.value); + expect(values).toContain( + escapePath(path.join(addedDir, 'new-file.txt')), + ); + }); + }); + + it('should show same-named files from different directories without false deduplication', async () => { + const dir1Structure: FileSystemStructure = { 'readme.md': '' }; + const dir2Structure: FileSystemStructure = { 'readme.md': '' }; + const dir1 = await createTmpDir(dir1Structure); + multiDirTmpDirs.push(dir1); + const dir2 = await createTmpDir(dir2Structure); + multiDirTmpDirs.push(dir2); + + const multiDirConfig = { + ...mockConfig, + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: () => [dir1, dir2], + onDirectoriesChanged: vi.fn(() => () => {}), + }), + } as unknown as Config; + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, 'readme', multiDirConfig, dir1), + ); + + await waitFor(() => { + const values = result.current.suggestions.map((s) => s.value); + const readmeEntries = values.filter((v) => v.includes('readme.md')); + expect(readmeEntries.length).toBe(2); + expect(readmeEntries).toContain('readme.md'); + expect(readmeEntries).toContain( + escapePath(path.join(dir2, 'readme.md')), + ); + }); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 6b07691719..a4c5317de8 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -6,6 +6,7 @@ import { useEffect, useReducer, useRef } from 'react'; import { setTimeout as setTimeoutPromise } from 'node:timers/promises'; +import * as path from 'node:path'; import type { Config, FileSearch } from '@google/gemini-cli-core'; import { FileSearchFactory, @@ -203,7 +204,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void { setIsLoadingSuggestions, } = props; const [state, dispatch] = useReducer(atCompletionReducer, initialState); - const fileSearch = useRef(null); + const fileSearchMap = useRef>(new Map()); + const initEpoch = useRef(0); const searchAbortController = useRef(null); const slowSearchTimer = useRef(null); @@ -215,10 +217,26 @@ export function useAtCompletion(props: UseAtCompletionProps): void { setIsLoadingSuggestions(state.isLoading); }, [state.isLoading, setIsLoadingSuggestions]); - useEffect(() => { + const resetFileSearchState = () => { + fileSearchMap.current.clear(); + initEpoch.current += 1; dispatch({ type: 'RESET' }); + }; + + useEffect(() => { + resetFileSearchState(); }, [cwd, config]); + useEffect(() => { + const workspaceContext = config?.getWorkspaceContext?.(); + if (!workspaceContext) return; + + const unsubscribe = + workspaceContext.onDirectoriesChanged(resetFileSearchState); + + return unsubscribe; + }, [config]); + // Reacts to user input (`pattern`) ONLY. useEffect(() => { if (!enabled) { @@ -250,38 +268,64 @@ export function useAtCompletion(props: UseAtCompletionProps): void { // The "Worker" that performs async operations based on status. useEffect(() => { const initialize = async () => { + const currentEpoch = initEpoch.current; try { - const searcher = FileSearchFactory.create({ - projectRoot: cwd, - ignoreDirs: [], - fileDiscoveryService: new FileDiscoveryService( - cwd, - config?.getFileFilteringOptions(), - ), - cache: true, - cacheTtl: 30, // 30 seconds - enableRecursiveFileSearch: - config?.getEnableRecursiveFileSearch() ?? true, - enableFuzzySearch: - config?.getFileFilteringEnableFuzzySearch() ?? true, - maxFiles: config?.getFileFilteringOptions()?.maxFileCount, - }); - await searcher.initialize(); - fileSearch.current = searcher; + const directories = config + ?.getWorkspaceContext?.() + ?.getDirectories() ?? [cwd]; + + const initPromises: Array> = []; + + for (const dir of directories) { + if (fileSearchMap.current.has(dir)) continue; + + const searcher = FileSearchFactory.create({ + projectRoot: dir, + ignoreDirs: [], + fileDiscoveryService: new FileDiscoveryService( + dir, + config?.getFileFilteringOptions(), + ), + cache: true, + cacheTtl: 30, + enableRecursiveFileSearch: + config?.getEnableRecursiveFileSearch() ?? true, + enableFuzzySearch: + config?.getFileFilteringEnableFuzzySearch() ?? true, + maxFiles: config?.getFileFilteringOptions()?.maxFileCount, + }); + + initPromises.push( + searcher.initialize().then(() => { + if (initEpoch.current === currentEpoch) { + fileSearchMap.current.set(dir, searcher); + } + }), + ); + } + + await Promise.all(initPromises); + + if (initEpoch.current !== currentEpoch) return; + dispatch({ type: 'INITIALIZE_SUCCESS' }); if (state.pattern !== null) { dispatch({ type: 'SEARCH', payload: state.pattern }); } } catch (_) { - dispatch({ type: 'ERROR' }); + if (initEpoch.current === currentEpoch) { + dispatch({ type: 'ERROR' }); + } } }; const search = async () => { - if (!fileSearch.current || state.pattern === null) { + if (fileSearchMap.current.size === 0 || state.pattern === null) { return; } + const currentPattern = state.pattern; + if (slowSearchTimer.current) { clearTimeout(slowSearchTimer.current); } @@ -310,10 +354,26 @@ export function useAtCompletion(props: UseAtCompletionProps): void { })(); try { - const results = await fileSearch.current.search(state.pattern, { - signal: controller.signal, - maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, - }); + const directories = config + ?.getWorkspaceContext?.() + ?.getDirectories() ?? [cwd]; + const cwdRealpath = directories[0]; + + const allSearchPromises = [...fileSearchMap.current.entries()].map( + async ([dir, searcher]): Promise => { + const results = await searcher.search(currentPattern, { + signal: controller.signal, + maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, + }); + + if (dir !== cwdRealpath) { + return results.map((p: string) => path.join(dir, p)); + } + return results; + }, + ); + + const allResults = await Promise.all(allSearchPromises); if (slowSearchTimer.current) { clearTimeout(slowSearchTimer.current); @@ -323,7 +383,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { return; } - const fileSuggestions = results.map((p) => ({ + const mergedResults = allResults.flat(); + + const fileSuggestions = mergedResults.map((p) => ({ label: p, value: escapePath(p), })); @@ -331,7 +393,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { const resourceCandidates = buildResourceCandidates(config); const resourceSuggestions = ( await searchResourceCandidates( - state.pattern ?? '', + currentPattern ?? '', resourceCandidates, ) ).map((suggestion) => ({ @@ -342,10 +404,15 @@ export function useAtCompletion(props: UseAtCompletionProps): void { const agentCandidates = buildAgentCandidates(config); const agentSuggestions = await searchAgentCandidates( - state.pattern ?? '', + currentPattern ?? '', agentCandidates, ); + // Re-check after resource/agent searches which are not abort-aware + if (controller.signal.aborted) { + return; + } + const combinedSuggestions = [ ...agentSuggestions, ...fileSuggestions,