diff options
| author | Yuki Okita <[email protected]> | 2025-08-01 04:02:08 +0900 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-31 19:02:08 +0000 |
| commit | f9a05401c1d2d93d1251d3ebf2c079ee1f4ba8df (patch) | |
| tree | 870f1cf4b31ed974ca180bb7068388ba2dfc3788 /packages/cli/src | |
| parent | 9a6422f331294ea2f56d67599ed142d09cc33320 (diff) | |
feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241)
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/services/BuiltinCommandLoader.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/directoryCommand.test.tsx | 172 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/directoryCommand.tsx | 150 |
3 files changed, 324 insertions, 0 deletions
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3ad6cb2..3b54047c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -16,6 +16,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; +import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -56,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader { copyCommand, corgiCommand, docsCommand, + directoryCommand, editorCommand, extensionsCommand, helpCommand, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx new file mode 100644 index 00000000..081083d3 --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { directoryCommand, expandHomeDir } from './directoryCommand.js'; +import { Config, WorkspaceContext } from '@google/gemini-cli-core'; +import { CommandContext } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +describe('directoryCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockWorkspaceContext: WorkspaceContext; + const addCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'add', + ); + const showCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'show', + ); + + beforeEach(() => { + mockWorkspaceContext = { + addDirectory: vi.fn(), + getDirectories: vi + .fn() + .mockReturnValue([ + path.normalize('/home/user/project1'), + path.normalize('/home/user/project2'), + ]), + } as unknown as WorkspaceContext; + + mockConfig = { + getWorkspaceContext: () => mockWorkspaceContext, + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + getGeminiClient: vi.fn().mockReturnValue({ + addDirectoryContext: vi.fn(), + }), + } as unknown as Config; + + mockContext = { + services: { + config: mockConfig, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext; + }); + + describe('show', () => { + it('should display the list of directories', () => { + if (!showCommand?.action) throw new Error('No action'); + showCommand.action(mockContext, ''); + expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Current workspace directories:\n- ${path.normalize( + '/home/user/project1', + )}\n- ${path.normalize('/home/user/project2')}`, + }), + expect.any(Number), + ); + }); + }); + + describe('add', () => { + it('should show an error if no path is provided', () => { + if (!addCommand?.action) throw new Error('No action'); + addCommand.action(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }), + expect.any(Number), + ); + }); + + it('should call addDirectory and show a success message for a single path', async () => { + const newPath = path.normalize('/home/user/new-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath}`, + }), + expect.any(Number), + ); + }); + + it('should call addDirectory for each path and show a success message for multiple paths', async () => { + const newPath1 = path.normalize('/home/user/new-project1'); + const newPath2 = path.normalize('/home/user/new-project2'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${newPath1},${newPath2}`); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, + }), + expect.any(Number), + ); + }); + + it('should show an error if addDirectory throws an exception', async () => { + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => { + throw error; + }); + const newPath = path.normalize('/home/user/invalid-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${newPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + + it('should handle a mix of successful and failed additions', async () => { + const validPath = path.normalize('/home/user/valid-project'); + const invalidPath = path.normalize('/home/user/invalid-project'); + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation( + (p: string) => { + if (p === invalidPath) { + throw error; + } + }, + ); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${validPath},${invalidPath}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${validPath}`, + }), + expect.any(Number), + ); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${invalidPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + }); + it('should correctly expand a Windows-style home directory path', () => { + const windowsPath = '%userprofile%\\Documents'; + const expectedPath = path.win32.join(os.homedir(), 'Documents'); + const result = expandHomeDir(windowsPath); + expect(path.win32.normalize(result)).toBe( + path.win32.normalize(expectedPath), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx new file mode 100644 index 00000000..18f7e78f --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SlashCommand, CommandContext, CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +export function expandHomeDir(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p.startsWith('~')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} + +export const directoryCommand: SlashCommand = { + name: 'directory', + altNames: ['dir'], + description: 'Manage workspace directories', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'add', + description: + 'Add directories to the workspace. Use comma to separate multiple paths', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext, args: string) => { + const { + ui: { addItem }, + services: { config }, + } = context; + const [...rest] = args.split(' '); + + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + + const workspaceContext = config.getWorkspaceContext(); + + const pathsToAdd = rest + .join(' ') + .split(',') + .filter((p) => p); + if (pathsToAdd.length === 0) { + addItem( + { + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }, + Date.now(), + ); + return; + } + + if (config.isRestrictiveSandbox()) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + }; + } + + const added: string[] = []; + const errors: string[] = []; + + for (const pathToAdd of pathsToAdd) { + try { + workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim())); + added.push(pathToAdd.trim()); + } catch (e) { + const error = e as Error; + errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`); + } + } + + if (added.length > 0) { + const gemini = config.getGeminiClient(); + if (gemini) { + await gemini.addDirectoryContext(); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added directories:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } + + if (errors.length > 0) { + addItem( + { + type: MessageType.ERROR, + text: errors.join('\n'), + }, + Date.now(), + ); + } + }, + }, + { + name: 'show', + description: 'Show all directories in the workspace', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { + ui: { addItem }, + services: { config }, + } = context; + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + const workspaceContext = config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); + addItem( + { + type: MessageType.INFO, + text: `Current workspace directories:\n${directoryList}`, + }, + Date.now(), + ); + }, + }, + ], +}; |
