diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 13 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 9 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useEditorSettings.test.ts | 283 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useEditorSettings.ts | 75 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 17 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useReactToolScheduler.ts | 4 |
7 files changed, 403 insertions, 1 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 971d7aac..c2873bd6 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -97,6 +97,7 @@ describe('useSlashCommandProcessor', () => { let mockSetShowHelp: ReturnType<typeof vi.fn>; let mockOnDebugMessage: ReturnType<typeof vi.fn>; let mockOpenThemeDialog: ReturnType<typeof vi.fn>; + let mockOpenEditorDialog: ReturnType<typeof vi.fn>; let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>; let mockSetQuittingMessages: ReturnType<typeof vi.fn>; let mockConfig: Config; @@ -111,6 +112,7 @@ describe('useSlashCommandProcessor', () => { mockSetShowHelp = vi.fn(); mockOnDebugMessage = vi.fn(); mockOpenThemeDialog = vi.fn(); + mockOpenEditorDialog = vi.fn(); mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined); mockSetQuittingMessages = vi.fn(); mockConfig = { @@ -155,6 +157,7 @@ describe('useSlashCommandProcessor', () => { mockSetShowHelp, mockOnDebugMessage, mockOpenThemeDialog, + mockOpenEditorDialog, mockPerformMemoryRefresh, mockCorgiMode, showToolDescriptions, @@ -322,6 +325,16 @@ describe('useSlashCommandProcessor', () => { expect(mockSetShowHelp).toHaveBeenCalledWith(true); expect(commandResult).toBe(true); }); + + it('/editor should open editor dialog and return true', async () => { + const { handleSlashCommand } = getProcessor(); + let commandResult: SlashCommandActionReturn | boolean = false; + await act(async () => { + commandResult = await handleSlashCommand('/editor'); + }); + expect(mockOpenEditorDialog).toHaveBeenCalled(); + expect(commandResult).toBe(true); + }); }); describe('/bug command', () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d343c6ff..23e34402 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -66,6 +66,7 @@ export const useSlashCommandProcessor = ( setShowHelp: React.Dispatch<React.SetStateAction<boolean>>, onDebugMessage: (message: string) => void, openThemeDialog: () => void, + openEditorDialog: () => void, performMemoryRefresh: () => Promise<void>, toggleCorgiMode: () => void, showToolDescriptions: boolean = false, @@ -182,6 +183,13 @@ export const useSlashCommandProcessor = ( }, }, { + name: 'editor', + description: 'open the editor', + action: (_mainCommand, _subCommand, _args) => { + openEditorDialog(); + }, + }, + { name: 'stats', altName: 'usage', description: 'check session stats', @@ -745,6 +753,7 @@ Add any other context about the problem here. setShowHelp, refreshStatic, openThemeDialog, + openEditorDialog, clearItems, performMemoryRefresh, showMemoryAction, diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.ts b/packages/cli/src/ui/hooks/useEditorSettings.test.ts new file mode 100644 index 00000000..c69c2a31 --- /dev/null +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockedFunction, +} from 'vitest'; +import { act } from 'react'; +import { renderHook } from '@testing-library/react'; +import { useEditorSettings } from './useEditorSettings.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { MessageType, type HistoryItem } from '../types.js'; +import { + type EditorType, + checkHasEditorType, + allowEditorTypeInSandbox, +} from '@gemini-cli/core'; + +vi.mock('@gemini-cli/core', async () => { + const actual = await vi.importActual('@gemini-cli/core'); + return { + ...actual, + checkHasEditorType: vi.fn(() => true), + allowEditorTypeInSandbox: vi.fn(() => true), + }; +}); + +const mockCheckHasEditorType = vi.mocked(checkHasEditorType); +const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox); + +describe('useEditorSettings', () => { + let mockLoadedSettings: LoadedSettings; + let mockSetEditorError: MockedFunction<(error: string | null) => void>; + let mockAddItem: MockedFunction< + (item: Omit<HistoryItem, 'id'>, timestamp: number) => void + >; + + beforeEach(() => { + vi.resetAllMocks(); + + mockLoadedSettings = { + setValue: vi.fn(), + } as unknown as LoadedSettings; + + mockSetEditorError = vi.fn(); + mockAddItem = vi.fn(); + + // Reset mock implementations to default + mockCheckHasEditorType.mockReturnValue(true); + mockAllowEditorTypeInSandbox.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with dialog closed', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + expect(result.current.isEditorDialogOpen).toBe(false); + }); + + it('should open editor dialog when openEditorDialog is called', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + act(() => { + result.current.openEditorDialog(); + }); + + expect(result.current.isEditorDialogOpen).toBe(true); + }); + + it('should close editor dialog when exitEditorDialog is called', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + act(() => { + result.current.openEditorDialog(); + result.current.exitEditorDialog(); + }); + expect(result.current.isEditorDialogOpen).toBe(false); + }); + + it('should handle editor selection successfully', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + const editorType: EditorType = 'vscode'; + const scope = SettingScope.User; + + act(() => { + result.current.openEditorDialog(); + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( + scope, + 'preferredEditor', + editorType, + ); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Editor preference set to "vscode" in User settings.', + }, + expect.any(Number), + ); + + expect(mockSetEditorError).toHaveBeenCalledWith(null); + expect(result.current.isEditorDialogOpen).toBe(false); + }); + + it('should handle clearing editor preference (undefined editor)', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + const scope = SettingScope.Workspace; + + act(() => { + result.current.openEditorDialog(); + result.current.handleEditorSelect(undefined, scope); + }); + + expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( + scope, + 'preferredEditor', + undefined, + ); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Editor preference cleared in Workspace settings.', + }, + expect.any(Number), + ); + + expect(mockSetEditorError).toHaveBeenCalledWith(null); + expect(result.current.isEditorDialogOpen).toBe(false); + }); + + it('should handle different editor types', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim']; + const scope = SettingScope.User; + + editorTypes.forEach((editorType) => { + act(() => { + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( + scope, + 'preferredEditor', + editorType, + ); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Editor preference set to "${editorType}" in User settings.`, + }, + expect.any(Number), + ); + }); + }); + + it('should handle different setting scopes', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + const editorType: EditorType = 'vscode'; + const scopes = [SettingScope.User, SettingScope.Workspace]; + + scopes.forEach((scope) => { + act(() => { + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( + scope, + 'preferredEditor', + editorType, + ); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Editor preference set to "vscode" in ${scope} settings.`, + }, + expect.any(Number), + ); + }); + }); + + it('should not set preference for unavailable editors', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + mockCheckHasEditorType.mockReturnValue(false); + + const editorType: EditorType = 'vscode'; + const scope = SettingScope.User; + + act(() => { + result.current.openEditorDialog(); + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockLoadedSettings.setValue).not.toHaveBeenCalled(); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(result.current.isEditorDialogOpen).toBe(true); + }); + + it('should not set preference for editors not allowed in sandbox', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + mockAllowEditorTypeInSandbox.mockReturnValue(false); + + const editorType: EditorType = 'vscode'; + const scope = SettingScope.User; + + act(() => { + result.current.openEditorDialog(); + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockLoadedSettings.setValue).not.toHaveBeenCalled(); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(result.current.isEditorDialogOpen).toBe(true); + }); + + it('should handle errors during editor selection', () => { + const { result } = renderHook(() => + useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), + ); + + const errorMessage = 'Failed to save settings'; + ( + mockLoadedSettings.setValue as MockedFunction< + typeof mockLoadedSettings.setValue + > + ).mockImplementation(() => { + throw new Error(errorMessage); + }); + + const editorType: EditorType = 'vscode'; + const scope = SettingScope.User; + + act(() => { + result.current.openEditorDialog(); + result.current.handleEditorSelect(editorType, scope); + }); + + expect(mockSetEditorError).toHaveBeenCalledWith( + `Failed to set editor preference: Error: ${errorMessage}`, + ); + expect(mockAddItem).not.toHaveBeenCalled(); + expect(result.current.isEditorDialogOpen).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts new file mode 100644 index 00000000..1fe3983e --- /dev/null +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { type HistoryItem, MessageType } from '../types.js'; +import { + allowEditorTypeInSandbox, + checkHasEditorType, + EditorType, +} from '@gemini-cli/core'; + +interface UseEditorSettingsReturn { + isEditorDialogOpen: boolean; + openEditorDialog: () => void; + handleEditorSelect: ( + editorType: EditorType | undefined, + scope: SettingScope, + ) => void; + exitEditorDialog: () => void; +} + +export const useEditorSettings = ( + loadedSettings: LoadedSettings, + setEditorError: (error: string | null) => void, + addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void, +): UseEditorSettingsReturn => { + const [isEditorDialogOpen, setIsEditorDialogOpen] = useState(false); + + const openEditorDialog = useCallback(() => { + setIsEditorDialogOpen(true); + }, []); + + const handleEditorSelect = useCallback( + (editorType: EditorType | undefined, scope: SettingScope) => { + if ( + editorType && + (!checkHasEditorType(editorType) || + !allowEditorTypeInSandbox(editorType)) + ) { + return; + } + + try { + loadedSettings.setValue(scope, 'preferredEditor', editorType); + addItem( + { + type: MessageType.INFO, + text: `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`, + }, + Date.now(), + ); + setEditorError(null); + setIsEditorDialogOpen(false); + } catch (error) { + setEditorError(`Failed to set editor preference: ${error}`); + } + }, + [loadedSettings, setEditorError, addItem], + ); + + const exitEditorDialog = useCallback(() => { + setIsEditorDialogOpen(false); + }, []); + + return { + isEditorDialogOpen, + openEditorDialog, + handleEditorSelect, + exitEditorDialog, + }; +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 81c7f52b..96dd6aef 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -15,11 +15,12 @@ import { TrackedExecutingToolCall, TrackedCancelledToolCall, } from './useReactToolScheduler.js'; -import { Config } from '@gemini-cli/core'; +import { Config, EditorType } from '@gemini-cli/core'; import { Part, PartListUnion } from '@google/genai'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { HistoryItem } from '../types.js'; import { Dispatch, SetStateAction } from 'react'; +import { LoadedSettings } from '../../config/settings.js'; // --- MOCKS --- const mockSendMessageStream = vi @@ -309,6 +310,15 @@ describe('useGeminiStream', () => { .mockReturnValue((async function* () {})()); }); + const mockLoadedSettings: LoadedSettings = { + merged: { preferredEditor: 'vscode' }, + user: { path: '/user/settings.json', settings: {} }, + workspace: { path: '/workspace/.gemini/settings.json', settings: {} }, + errors: [], + forScope: vi.fn(), + setValue: vi.fn(), + } as unknown as LoadedSettings; + const renderTestHook = ( initialToolCalls: TrackedToolCall[] = [], geminiClient?: any, @@ -337,6 +347,7 @@ describe('useGeminiStream', () => { | boolean >; shellModeActive: boolean; + loadedSettings: LoadedSettings; }) => useGeminiStream( props.client, @@ -347,6 +358,7 @@ describe('useGeminiStream', () => { props.onDebugMessage, props.handleSlashCommand, props.shellModeActive, + () => 'vscode' as EditorType, ), { initialProps: { @@ -363,6 +375,7 @@ describe('useGeminiStream', () => { | boolean >, shellModeActive: false, + loadedSettings: mockLoadedSettings, }, }, ); @@ -486,6 +499,7 @@ describe('useGeminiStream', () => { handleSlashCommand: mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand, shellModeActive: false, + loadedSettings: mockLoadedSettings, }); }); @@ -541,6 +555,7 @@ describe('useGeminiStream', () => { handleSlashCommand: mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand, shellModeActive: false, + loadedSettings: mockLoadedSettings, }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 4d6bbcba..56e87fc3 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -19,6 +19,7 @@ import { ToolCallRequestInfo, logUserPrompt, GitService, + EditorType, } from '@gemini-cli/core'; import { type Part, type PartListUnion } from '@google/genai'; import { @@ -83,6 +84,7 @@ export const useGeminiStream = ( import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean >, shellModeActive: boolean, + getPreferredEditor: () => EditorType | undefined, ) => { const [initError, setInitError] = useState<string | null>(null); const abortControllerRef = useRef<AbortController | null>(null); @@ -115,6 +117,7 @@ export const useGeminiStream = ( }, config, setPendingHistoryItem, + getPreferredEditor, ); const pendingToolCallGroupDisplay = useMemo( diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 8ae7ebfb..0faccb2a 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -21,6 +21,7 @@ import { ToolCall, Status as CoreStatus, logToolCall, + EditorType, } from '@gemini-cli/core'; import { useCallback, useState, useMemo } from 'react'; import { @@ -69,6 +70,7 @@ export function useReactToolScheduler( setPendingHistoryItem: React.Dispatch< React.SetStateAction<HistoryItemWithoutId | null> >, + getPreferredEditor: () => EditorType | undefined, ): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] { const [toolCallsForDisplay, setToolCallsForDisplay] = useState< TrackedToolCall[] @@ -162,12 +164,14 @@ export function useReactToolScheduler( onAllToolCallsComplete: allToolCallsCompleteHandler, onToolCallsUpdate: toolCallsUpdateHandler, approvalMode: config.getApprovalMode(), + getPreferredEditor, }), [ config, outputUpdateHandler, allToolCallsCompleteHandler, toolCallsUpdateHandler, + getPreferredEditor, ], ); |
