diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/atCommandProcessor.test.ts | 4 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/atCommandProcessor.ts | 138 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.test.ts | 41 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.ts | 145 |
4 files changed, 198 insertions, 130 deletions
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index de05667e..2b4c81a3 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -57,6 +57,10 @@ describe('handleAtCommand', () => { respectGeminiIgnore: true, }), getEnableRecursiveFileSearch: vi.fn(() => true), + getWorkspaceContext: () => ({ + isPathWithinWorkspace: () => true, + getDirectories: () => [testRootDir], + }), } as unknown as Config; const registry = new ToolRegistry(mockConfig); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 237d983f..7b9005fa 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -188,6 +188,14 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options + const workspaceContext = config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(pathName)) { + onDebugMessage( + `Path ${pathName} is not in the workspace and will be skipped.`, + ); + continue; + } + const gitIgnored = respectFileIgnore.respectGitIgnore && fileDiscovery.shouldIgnoreFile(pathName, { @@ -215,90 +223,88 @@ export async function handleAtCommand({ continue; } - let currentPathSpec = pathName; - let resolvedSuccessfully = false; - - try { - const absolutePath = path.resolve(config.getTargetDir(), pathName); - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = - pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); - onDebugMessage( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, - ); - } else { - onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); - } - resolvedSuccessfully = true; - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (config.getEnableRecursiveFileSearch() && globTool) { + for (const dir of config.getWorkspaceContext().getDirectories()) { + let currentPathSpec = pathName; + let resolvedSuccessfully = false; + try { + const absolutePath = path.resolve(dir, pathName); + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = + pathName + (pathName.endsWith(path.sep) ? `**` : `/**`); onDebugMessage( - `Path ${pathName} not found directly, attempting glob search.`, + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, ); - try { - const globResult = await globTool.execute( - { - pattern: `**/*${pathName}*`, - path: config.getTargetDir(), - }, - signal, + } else { + onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`); + } + resolvedSuccessfully = true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (config.getEnableRecursiveFileSearch() && globTool) { + onDebugMessage( + `Path ${pathName} not found directly, attempting glob search.`, ); - if ( - globResult.llmContent && - typeof globResult.llmContent === 'string' && - !globResult.llmContent.startsWith('No files found') && - !globResult.llmContent.startsWith('Error:') - ) { - const lines = globResult.llmContent.split('\n'); - if (lines.length > 1 && lines[1]) { - const firstMatchAbsolute = lines[1].trim(); - currentPathSpec = path.relative( - config.getTargetDir(), - firstMatchAbsolute, - ); - onDebugMessage( - `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, - ); - resolvedSuccessfully = true; + try { + const globResult = await globTool.execute( + { + pattern: `**/*${pathName}*`, + path: dir, + }, + signal, + ); + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + currentPathSpec = path.relative(dir, firstMatchAbsolute); + onDebugMessage( + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + ); + resolvedSuccessfully = true; + } else { + onDebugMessage( + `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + ); + } } else { onDebugMessage( - `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, ); } - } else { + } catch (globError) { + console.error( + `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, + ); onDebugMessage( - `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, + `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, ); } - } catch (globError) { - console.error( - `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, - ); + } else { onDebugMessage( - `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`, + `Glob tool not found. Path ${pathName} will be skipped.`, ); } } else { + console.error( + `Error stating path ${pathName}: ${getErrorMessage(error)}`, + ); onDebugMessage( - `Glob tool not found. Path ${pathName} will be skipped.`, + `Error stating path ${pathName}. Path ${pathName} will be skipped.`, ); } - } else { - console.error( - `Error stating path ${pathName}: ${getErrorMessage(error)}`, - ); - onDebugMessage( - `Error stating path ${pathName}. Path ${pathName} will be skipped.`, - ); } - } - - if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); - atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec); - contentLabelsForDisplay.push(pathName); + if (resolvedSuccessfully) { + pathSpecsToRead.push(currentPathSpec); + atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec); + contentLabelsForDisplay.push(pathName); + break; + } } } diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index da6a7ab3..f876eea1 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -22,6 +22,7 @@ describe('useCompletion', () => { // A minimal mock is sufficient for these tests. const mockCommandContext = {} as CommandContext; + let testDirs: string[]; async function createEmptyDir(...pathSegments: string[]) { const fullPath = path.join(testRootDir, ...pathSegments); @@ -51,8 +52,12 @@ describe('useCompletion', () => { testRootDir = await fs.mkdtemp( path.join(os.tmpdir(), 'completion-unit-test-'), ); + testDirs = [testRootDir]; mockConfig = { getTargetDir: () => testRootDir, + getWorkspaceContext: () => ({ + getDirectories: () => testDirs, + }), getProjectRoot: () => testRootDir, getFileFilteringOptions: vi.fn(() => ({ respectGitIgnore: true, @@ -79,6 +84,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -108,6 +114,7 @@ describe('useCompletion', () => { const textBuffer = useTextBufferForTest(text); return useCompletion( textBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -138,6 +145,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/help'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -170,6 +178,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -191,6 +200,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(''), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -215,6 +225,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/h'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -242,6 +253,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/h'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -270,6 +282,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -315,6 +328,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/command'), + testDirs, testRootDir, largeMockCommands, mockCommandContext, @@ -372,6 +386,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -394,6 +409,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/mem'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -417,6 +433,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/usag'), // part of the word "usage" + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -443,6 +460,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/clear'), // No trailing space + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -474,6 +492,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest(query), + testDirs, testRootDir, mockSlashCommands, mockCommandContext, @@ -494,6 +513,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/clear '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -514,6 +534,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/unknown-command'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -547,6 +568,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory'), // Note: no trailing space + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -584,6 +606,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -619,6 +642,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory a'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -650,6 +674,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/memory dothisnow'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -692,6 +717,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume my-ch'), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -735,6 +761,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -769,6 +796,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('/chat resume '), + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -796,6 +824,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@s'), + testDirs, testRootDir, [], mockCommandContext, @@ -829,6 +858,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@src/comp'), + testDirs, testRootDir, [], mockCommandContext, @@ -854,6 +884,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@.'), + testDirs, testRootDir, [], mockCommandContext, @@ -885,6 +916,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@d'), + testDirs, testRootDir, [], mockCommandContext, @@ -910,6 +942,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -944,6 +977,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -974,6 +1008,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@d'), + testDirs, testRootDir, [], mockCommandContext, @@ -1007,6 +1042,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@'), + testDirs, testRootDir, [], mockCommandContext, @@ -1039,6 +1075,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( useTextBufferForTest('@t'), + testDirs, testRootDir, [], mockCommandContext, @@ -1085,6 +1122,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1128,6 +1166,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1173,6 +1212,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, @@ -1221,6 +1261,7 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion( mockBuffer, + testDirs, testRootDir, slashCommands, mockCommandContext, diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 10724c21..4b106c1b 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -43,6 +43,7 @@ export interface UseCompletionReturn { export function useCompletion( buffer: TextBuffer, + dirs: readonly string[], cwd: string, slashCommands: readonly SlashCommand[], commandContext: CommandContext, @@ -328,8 +329,6 @@ export function useCompletion( : partialPath.substring(lastSlashIndex + 1), ); - const baseDirAbsolute = path.resolve(cwd, baseDirRelative); - let isMounted = true; const findFilesRecursively = async ( @@ -358,7 +357,7 @@ export function useCompletion( const entryPathRelative = path.join(currentRelativePath, entry.name); const entryPathFromRoot = path.relative( - cwd, + startDir, path.join(startDir, entry.name), ); @@ -417,29 +416,31 @@ export function useCompletion( respectGitIgnore?: boolean; respectGeminiIgnore?: boolean; }, + searchDir: string, maxResults = 50, ): Promise<Suggestion[]> => { const globPattern = `**/${searchPrefix}*`; const files = await glob(globPattern, { - cwd, + cwd: searchDir, dot: searchPrefix.startsWith('.'), nocase: true, }); const suggestions: Suggestion[] = files - .map((file: string) => ({ - label: file, - value: escapePath(file), - })) - .filter((s) => { + .filter((file) => { if (fileDiscoveryService) { - return !fileDiscoveryService.shouldIgnoreFile( - s.label, - filterOptions, - ); // relative path + return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions); } return true; }) + .map((file: string) => { + const absolutePath = path.resolve(searchDir, file); + const label = path.relative(cwd, absolutePath); + return { + label, + value: escapePath(label), + }; + }) .slice(0, maxResults); return suggestions; @@ -456,63 +457,78 @@ export function useCompletion( config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS; try { - // If there's no slash, or it's the root, do a recursive search from cwd - if ( - partialPath.indexOf('/') === -1 && - prefix && - enableRecursiveSearch - ) { - if (fileDiscoveryService) { - fetchedSuggestions = await findFilesWithGlob( - prefix, - fileDiscoveryService, - filterOptions, - ); + // If there's no slash, or it's the root, do a recursive search from workspace directories + for (const dir of dirs) { + let fetchedSuggestionsPerDir: Suggestion[] = []; + if ( + partialPath.indexOf('/') === -1 && + prefix && + enableRecursiveSearch + ) { + if (fileDiscoveryService) { + fetchedSuggestionsPerDir = await findFilesWithGlob( + prefix, + fileDiscoveryService, + filterOptions, + dir, + ); + } else { + fetchedSuggestionsPerDir = await findFilesRecursively( + dir, + prefix, + null, + filterOptions, + ); + } } else { - fetchedSuggestions = await findFilesRecursively( - cwd, - prefix, - null, - filterOptions, - ); - } - } else { - // Original behavior: list files in the specific directory - const lowerPrefix = prefix.toLowerCase(); - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, - }); + // Original behavior: list files in the specific directory + const lowerPrefix = prefix.toLowerCase(); + const baseDirAbsolute = path.resolve(dir, baseDirRelative); + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); - // Filter entries using git-aware filtering - const filteredEntries = []; - for (const entry of entries) { - // Conditionally ignore dotfiles - if (!prefix.startsWith('.') && entry.name.startsWith('.')) { - continue; - } - if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; + // Filter entries using git-aware filtering + const filteredEntries = []; + for (const entry of entries) { + // Conditionally ignore dotfiles + if (!prefix.startsWith('.') && entry.name.startsWith('.')) { + continue; + } + if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; - const relativePath = path.relative( - cwd, - path.join(baseDirAbsolute, entry.name), - ); - if ( - fileDiscoveryService && - fileDiscoveryService.shouldIgnoreFile(relativePath, filterOptions) - ) { - continue; + const relativePath = path.relative( + dir, + path.join(baseDirAbsolute, entry.name), + ); + if ( + fileDiscoveryService && + fileDiscoveryService.shouldIgnoreFile( + relativePath, + filterOptions, + ) + ) { + continue; + } + + filteredEntries.push(entry); } - filteredEntries.push(entry); + fetchedSuggestionsPerDir = filteredEntries.map((entry) => { + const absolutePath = path.resolve(baseDirAbsolute, entry.name); + const label = + cwd === dir ? entry.name : path.relative(cwd, absolutePath); + const suggestionLabel = entry.isDirectory() ? label + '/' : label; + return { + label: suggestionLabel, + value: escapePath(suggestionLabel), + }; + }); } - - fetchedSuggestions = filteredEntries.map((entry) => { - const label = entry.isDirectory() ? entry.name + '/' : entry.name; - return { - label, - value: escapePath(label), // Value for completion should be just the name part - }; - }); + fetchedSuggestions = [ + ...fetchedSuggestions, + ...fetchedSuggestionsPerDir, + ]; } // Like glob, we always return forwardslashes, even in windows. @@ -585,6 +601,7 @@ export function useCompletion( }; }, [ buffer.text, + dirs, cwd, isActive, resetCompletionState, |
