mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-25 13:30:45 -07:00
feat: add folder suggestions to /dir add command (#15724)
This commit is contained in:
@@ -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