feat: add folder suggestions to /dir add command (#15724)

This commit is contained in:
Jack Wotherspoon
2026-01-02 16:08:11 -05:00
committed by GitHub
parent 006de1dd31
commit 0f3555a4d2
6 changed files with 466 additions and 6 deletions

View File

@@ -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<typeof import('../utils/directoryUtils.js')>();
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', () => {

View File

@@ -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 },

View File

@@ -201,5 +201,11 @@ export interface SlashCommand {
partialArg: string,
) => Promise<string[]> | 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[];
}

View File

@@ -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);

View File

@@ -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}`,
]);
});
},
);
});

View File

@@ -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<string[]> {
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 [];
}
}