feat: External editor settings (#882)

This commit is contained in:
Leo
2025-06-12 02:21:54 +01:00
committed by GitHub
parent dd53e5c96a
commit 1ef68e0612
23 changed files with 849 additions and 81 deletions

View File

@@ -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', () => {

View File

@@ -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,
@@ -181,6 +182,13 @@ export const useSlashCommandProcessor = (
openThemeDialog();
},
},
{
name: 'editor',
description: 'open the editor',
action: (_mainCommand, _subCommand, _args) => {
openEditorDialog();
},
},
{
name: 'stats',
altName: 'usage',
@@ -745,6 +753,7 @@ Add any other context about the problem here.
setShowHelp,
refreshStatic,
openThemeDialog,
openEditorDialog,
clearItems,
performMemoryRefresh,
showMemoryAction,

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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