/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as path from 'node:path'; import * as fs from 'node:fs'; import { opendir } from 'node:fs/promises'; import { homedir, type WorkspaceContext } from '@google/gemini-cli-core'; const MAX_SUGGESTIONS = 50; const MATCH_BUFFER_MULTIPLIER = 3; export function expandHomeDir(p: string): string { if (!p) { return ''; } let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { expandedPath = homedir() + p.substring('%userprofile%'.length); } else if (p === '~' || p.startsWith('~/')) { expandedPath = homedir() + p.substring(1); } 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 = 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 []; } } export interface BatchAddResult { added: string[]; errors: string[]; } /** * Helper to batch add directories to the workspace context. * Handles expansion and error formatting. */ export function batchAddDirectories( workspaceContext: WorkspaceContext, paths: string[], ): BatchAddResult { const result = workspaceContext.addDirectories( paths.map((p) => expandHomeDir(p.trim())), ); const errors: string[] = []; for (const failure of result.failed) { errors.push(`Error adding '${failure.path}': ${failure.error.message}`); } return { added: result.added, errors }; }