mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
feat: add folder suggestions to /dir add command (#15724)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user