From 1ef68e061213b6b170bd979d31d4805da2357272 Mon Sep 17 00:00:00 2001 From: Leo <45218470+ngleo@users.noreply.github.com> Date: Thu, 12 Jun 2025 02:21:54 +0100 Subject: [PATCH] feat: External editor settings (#882) --- docs/cli/commands.md | 5 + packages/cli/src/config/settings.ts | 1 + packages/cli/src/ui/App.tsx | 38 +++ .../ui/components/EditorSettingsDialog.tsx | 168 +++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 3 + .../messages/ToolConfirmationMessage.tsx | 45 +-- .../components/messages/ToolGroupMessage.tsx | 3 + .../components/shared/RadioButtonSelect.tsx | 4 + .../src/ui/editors/editorSettingsManager.ts | 60 ++++ .../ui/hooks/slashCommandProcessor.test.ts | 13 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 9 + .../src/ui/hooks/useEditorSettings.test.ts | 283 ++++++++++++++++++ .../cli/src/ui/hooks/useEditorSettings.ts | 75 +++++ .../cli/src/ui/hooks/useGeminiStream.test.tsx | 17 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 + .../cli/src/ui/hooks/useReactToolScheduler.ts | 4 + .../core/src/core/coreToolScheduler.test.ts | 2 +- packages/core/src/core/coreToolScheduler.ts | 18 +- packages/core/src/tools/edit.test.ts | 9 +- packages/core/src/tools/edit.ts | 19 +- packages/core/src/tools/tools.ts | 5 +- packages/core/src/utils/editor.test.ts | 118 +++++++- packages/core/src/utils/editor.ts | 28 +- 23 files changed, 849 insertions(+), 81 deletions(-) create mode 100644 packages/cli/src/ui/components/EditorSettingsDialog.tsx create mode 100644 packages/cli/src/ui/editors/editorSettingsManager.ts create mode 100644 packages/cli/src/ui/hooks/useEditorSettings.test.ts create mode 100644 packages/cli/src/ui/hooks/useEditorSettings.ts diff --git a/docs/cli/commands.md b/docs/cli/commands.md index ad091afc..f9e229a0 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -6,6 +6,11 @@ The Gemini CLI supports several built-in commands to help you manage your sessio Slash commands provide meta-level control over the CLI itself. They can typically be executed by typing the command and pressing `Enter`. +- **`/editor`** + + - **Description:** Allows you to configure your external editor for actions such as modifying Gemini's proposed code change. + - **Action:** Opens a dialog for selecting supported editors. + - **`/help`** (or **`/?`**) - **Description:** Displays help information about the Gemini CLI, including available commands and their usage. diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5e48496e..af1278a6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -39,6 +39,7 @@ export interface Settings { accessibility?: AccessibilitySettings; telemetry?: boolean; enableModifyWithExternalEditors?: boolean; + preferredEditor?: string; // Git-aware file filtering settings fileFiltering?: { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index dcd2b7ee..98a27716 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -19,6 +19,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; +import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -29,6 +30,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js'; import { InputPrompt } from './components/InputPrompt.js'; import { Footer } from './components/Footer.js'; import { ThemeDialog } from './components/ThemeDialog.js'; +import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { Colors } from './colors.js'; import { Help } from './components/Help.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -45,6 +47,8 @@ import { type Config, getCurrentGeminiMdFilename, ApprovalMode, + isEditorAvailable, + EditorType, } from '@gemini-cli/core'; import { useLogger } from './hooks/useLogger.js'; import { StreamingContext } from './contexts/StreamingContext.js'; @@ -82,6 +86,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const [debugMessage, setDebugMessage] = useState(''); const [showHelp, setShowHelp] = useState(false); const [themeError, setThemeError] = useState(null); + const [editorError, setEditorError] = useState(null); const [footerHeight, setFooterHeight] = useState(0); const [corgiMode, setCorgiMode] = useState(false); const [shellModeActive, setShellModeActive] = useState(false); @@ -106,6 +111,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { handleThemeHighlight, } = useThemeCommand(settings, setThemeError, addItem); + const { + isEditorDialogOpen, + openEditorDialog, + handleEditorSelect, + exitEditorDialog, + } = useEditorSettings(settings, setEditorError, addItem); + const toggleCorgiMode = useCallback(() => { setCorgiMode((prev) => !prev); }, []); @@ -162,6 +174,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { setShowHelp, setDebugMessage, openThemeDialog, + openEditorDialog, performMemoryRefresh, toggleCorgiMode, showToolDescriptions, @@ -227,6 +240,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { } }, [config]); + const getPreferredEditor = useCallback(() => { + const editorType = settings.merged.preferredEditor; + const isValidEditor = isEditorAvailable(editorType); + if (!isValidEditor) { + openEditorDialog(); + return; + } + return editorType as EditorType; + }, [settings, openEditorDialog]); + const { streamingState, submitQuery, initError, pendingHistoryItems } = useGeminiStream( config.getGeminiClient(), @@ -237,6 +260,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { setDebugMessage, handleSlashCommand, shellModeActive, + getPreferredEditor, ); const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState); @@ -409,6 +433,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { item={{ ...item, id: 0 }} isPending={true} config={config} + isFocused={!isEditorDialogOpen} /> ))} @@ -444,6 +469,19 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => { settings={settings} /> + ) : isEditorDialogOpen ? ( + + {editorError && ( + + {editorError} + + )} + + ) : ( <> void; + settings: LoadedSettings; + onExit: () => void; +} + +export function EditorSettingsDialog({ + onSelect, + settings, + onExit, +}: EditorDialogProps): React.JSX.Element { + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( + 'editor', + ); + useInput((_, key) => { + if (key.tab) { + setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor')); + } + if (key.escape) { + onExit(); + } + }); + + const editorItems: EditorDisplay[] = + editorSettingsManager.getAvailableEditorDisplays(); + + const currentPreference = + settings.forScope(selectedScope).settings.preferredEditor; + let editorIndex = currentPreference + ? editorItems.findIndex( + (item: EditorDisplay) => item.type === currentPreference, + ) + : 0; + if (editorIndex === -1) { + console.error(`Editor is not supported: ${currentPreference}`); + editorIndex = 0; + } + + const scopeItems = [ + { label: 'User Settings', value: SettingScope.User }, + { label: 'Workspace Settings', value: SettingScope.Workspace }, + ]; + + const handleEditorSelect = (editorType: EditorType | 'not_set') => { + if (editorType === 'not_set') { + onSelect(undefined, selectedScope); + return; + } + onSelect(editorType, selectedScope); + }; + + const handleScopeSelect = (scope: SettingScope) => { + setSelectedScope(scope); + setFocusedSection('editor'); + }; + + let otherScopeModifiedMessage = ''; + const otherScope = + selectedScope === SettingScope.User + ? SettingScope.Workspace + : SettingScope.User; + if (settings.forScope(otherScope).settings.preferredEditor !== undefined) { + otherScopeModifiedMessage = + settings.forScope(selectedScope).settings.preferredEditor !== undefined + ? `(Also modified in ${otherScope})` + : `(Modified in ${otherScope})`; + } + + let mergedEditorName = 'None'; + if ( + settings.merged.preferredEditor && + isEditorAvailable(settings.merged.preferredEditor) + ) { + mergedEditorName = + EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType]; + } + + return ( + + + + {focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '} + {otherScopeModifiedMessage} + + ({ + label: item.name, + value: item.type, + disabled: item.disabled, + }))} + initialIndex={editorIndex} + onSelect={handleEditorSelect} + isFocused={focusedSection === 'editor'} + key={selectedScope} + /> + + + + {focusedSection === 'scope' ? '> ' : ' '}Apply To + + + + + + + (Use Enter to select, Tab to change focus) + + + + + + Editor Preference + + + These editors are currently supported. Please note that some editors + cannot be used in sandbox mode. + + + Your preferred editor is:{' '} + + {mergedEditorName} + + . + + + + + ); +} diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 229672ec..fc1b128d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -24,6 +24,7 @@ interface HistoryItemDisplayProps { availableTerminalHeight: number; isPending: boolean; config?: Config; + isFocused?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -31,6 +32,7 @@ export const HistoryItemDisplay: React.FC = ({ availableTerminalHeight, isPending, config, + isFocused = true, }) => ( {/* Render standard message types */} @@ -76,6 +78,7 @@ export const HistoryItemDisplay: React.FC = ({ groupId={item.id} availableTerminalHeight={availableTerminalHeight} config={config} + isFocused={isFocused} /> )} diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index af9aba6a..0de85ba4 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -13,7 +13,6 @@ import { ToolConfirmationOutcome, ToolExecuteConfirmationDetails, ToolMcpConfirmationDetails, - checkHasEditor, Config, } from '@gemini-cli/core'; import { @@ -24,14 +23,16 @@ import { export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; config?: Config; + isFocused?: boolean; } export const ToolConfirmationMessage: React.FC< ToolConfirmationMessageProps -> = ({ confirmationDetails, config }) => { +> = ({ confirmationDetails, config, isFocused = true }) => { const { onConfirm } = confirmationDetails; useInput((_, key) => { + if (!isFocused) return; if (key.escape) { onConfirm(ToolConfirmationOutcome.Cancel); } @@ -86,40 +87,12 @@ export const ToolConfirmationMessage: React.FC< }, ); - // Conditionally add editor options if editors are installed - const notUsingSandbox = !process.env.SANDBOX; const externalEditorsEnabled = config?.getEnableModifyWithExternalEditors() ?? false; - - if (checkHasEditor('vscode') && notUsingSandbox && externalEditorsEnabled) { + if (externalEditorsEnabled) { options.push({ - label: 'Modify with VS Code', - value: ToolConfirmationOutcome.ModifyVSCode, - }); - } - - if ( - checkHasEditor('windsurf') && - notUsingSandbox && - externalEditorsEnabled - ) { - options.push({ - label: 'Modify with Windsurf', - value: ToolConfirmationOutcome.ModifyWindsurf, - }); - } - - if (checkHasEditor('cursor') && notUsingSandbox && externalEditorsEnabled) { - options.push({ - label: 'Modify with Cursor', - value: ToolConfirmationOutcome.ModifyCursor, - }); - } - - if (checkHasEditor('vim') && externalEditorsEnabled) { - options.push({ - label: 'Modify with vim', - value: ToolConfirmationOutcome.ModifyVim, + label: 'Modify with external editor', + value: ToolConfirmationOutcome.ModifyWithEditor, }); } @@ -192,7 +165,11 @@ export const ToolConfirmationMessage: React.FC< {/* Select Input for Options */} - + ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index b01e5f9b..8ce40893 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -17,6 +17,7 @@ interface ToolGroupMessageProps { toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight: number; config?: Config; + isFocused?: boolean; } // Main component renders the border and maps the tools using ToolMessage @@ -24,6 +25,7 @@ export const ToolGroupMessage: React.FC = ({ toolCalls, availableTerminalHeight, config, + isFocused = true, }) => { const hasPending = !toolCalls.every( (t) => t.status === ToolCallStatus.Success, @@ -84,6 +86,7 @@ export const ToolGroupMessage: React.FC = ({ )} diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index 22b5cecd..5430a442 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -19,6 +19,7 @@ import { Colors } from '../../colors.js'; export interface RadioSelectItem { label: string; value: T; + disabled?: boolean; } /** @@ -97,11 +98,14 @@ export function RadioButtonSelect({ const itemWithThemeProps = props as typeof props & { themeNameDisplay?: string; themeTypeDisplay?: string; + disabled?: boolean; }; let textColor = Colors.Foreground; if (isSelected) { textColor = Colors.AccentGreen; + } else if (itemWithThemeProps.disabled === true) { + textColor = Colors.Gray; } if ( diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts new file mode 100644 index 00000000..2c8210e1 --- /dev/null +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + allowEditorTypeInSandbox, + checkHasEditorType, + type EditorType, +} from '@gemini-cli/core'; + +export interface EditorDisplay { + name: string; + type: EditorType | 'not_set'; + disabled: boolean; +} + +export const EDITOR_DISPLAY_NAMES: Record = { + vscode: 'VS Code', + windsurf: 'Windsurf', + cursor: 'Cursor', + vim: 'Vim', +}; + +class EditorSettingsManager { + private readonly availableEditors: EditorDisplay[]; + + constructor() { + const editorTypes: EditorType[] = ['vscode', 'windsurf', 'cursor', 'vim']; + this.availableEditors = [ + { + name: 'None', + type: 'not_set', + disabled: false, + }, + ...editorTypes.map((type) => { + const hasEditor = checkHasEditorType(type); + const isAllowedInSandbox = allowEditorTypeInSandbox(type); + + let labelSuffix = !isAllowedInSandbox + ? ' (Not available in sandbox)' + : ''; + labelSuffix = !hasEditor ? ' (Not installed)' : labelSuffix; + + return { + name: EDITOR_DISPLAY_NAMES[type] + labelSuffix, + type, + disabled: !hasEditor || !isAllowedInSandbox, + }; + }), + ]; + } + + getAvailableEditorDisplays(): EditorDisplay[] { + return this.availableEditors; + } +} + +export const editorSettingsManager = new EditorSettingsManager(); 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; let mockOnDebugMessage: ReturnType; let mockOpenThemeDialog: ReturnType; + let mockOpenEditorDialog: ReturnType; let mockPerformMemoryRefresh: ReturnType; let mockSetQuittingMessages: ReturnType; 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>, onDebugMessage: (message: string) => void, openThemeDialog: () => void, + openEditorDialog: () => void, performMemoryRefresh: () => Promise, 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, 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, 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, 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(null); const abortControllerRef = useRef(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 >, + 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, ], ); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 1e8b2b2a..d9d30f94 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -62,7 +62,6 @@ describe('CoreToolScheduler', () => { getFunctionDeclarations: () => [], tools: new Map(), discovery: {} as any, - config: {} as any, registerTool: () => {}, getToolByName: () => mockTool, getToolByDisplayName: () => mockTool, @@ -79,6 +78,7 @@ describe('CoreToolScheduler', () => { toolRegistry: Promise.resolve(toolRegistry as any), onAllToolCallsComplete, onToolCallsUpdate, + getPreferredEditor: () => 'vscode', }); const abortController = new AbortController(); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index fe409fb1..f8688aeb 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -15,6 +15,7 @@ import { ApprovalMode, EditTool, EditToolParams, + EditorType, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -203,6 +204,7 @@ interface CoreToolSchedulerOptions { onAllToolCallsComplete?: AllToolCallsCompleteHandler; onToolCallsUpdate?: ToolCallsUpdateHandler; approvalMode?: ApprovalMode; + getPreferredEditor: () => EditorType | undefined; } export class CoreToolScheduler { @@ -212,6 +214,7 @@ export class CoreToolScheduler { private onAllToolCallsComplete?: AllToolCallsCompleteHandler; private onToolCallsUpdate?: ToolCallsUpdateHandler; private approvalMode: ApprovalMode; + private getPreferredEditor: () => EditorType | undefined; constructor(options: CoreToolSchedulerOptions) { this.toolRegistry = options.toolRegistry; @@ -219,6 +222,7 @@ export class CoreToolScheduler { this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onToolCallsUpdate = options.onToolCallsUpdate; this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT; + this.getPreferredEditor = options.getPreferredEditor; } private setStatusInternal( @@ -484,15 +488,15 @@ export class CoreToolScheduler { 'cancelled', 'User did not allow tool call', ); - } else if ( - outcome === ToolConfirmationOutcome.ModifyVSCode || - outcome === ToolConfirmationOutcome.ModifyWindsurf || - outcome === ToolConfirmationOutcome.ModifyCursor || - outcome === ToolConfirmationOutcome.ModifyVim - ) { + } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (waitingToolCall?.confirmationDetails?.type === 'edit') { const editTool = waitingToolCall.tool as EditTool; + const editorType = this.getPreferredEditor(); + if (!editorType) { + return; + } + this.setStatusInternal(callId, 'awaiting_approval', { ...waitingToolCall.confirmationDetails, isModifying: true, @@ -501,7 +505,7 @@ export class CoreToolScheduler { const modifyResults = await editTool.onModify( waitingToolCall.request.args as unknown as EditToolParams, signal, - outcome, + editorType, ); if (modifyResults) { diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 8c351929..dd3b481d 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -32,7 +32,6 @@ import fs from 'fs'; import os from 'os'; import { ApprovalMode, Config } from '../config/config.js'; import { Content, Part, SchemaUnion } from '@google/genai'; -import { ToolConfirmationOutcome } from './tools.js'; describe('EditTool', () => { let tool: EditTool; @@ -634,7 +633,7 @@ describe('EditTool', () => { const result = await tool.onModify( params, new AbortController().signal, - ToolConfirmationOutcome.ModifyVSCode, + 'vscode', ); expect(mockOpenDiff).toHaveBeenCalledTimes(1); @@ -678,7 +677,7 @@ describe('EditTool', () => { const result = await tool.onModify( params, new AbortController().signal, - ToolConfirmationOutcome.ModifyVSCode, + 'vscode', ); expect(mockOpenDiff).toHaveBeenCalledTimes(1); @@ -711,7 +710,7 @@ describe('EditTool', () => { const result1 = await tool.onModify( params, new AbortController().signal, - ToolConfirmationOutcome.ModifyVSCode, + 'vscode', ); const firstCall = mockOpenDiff.mock.calls[0]; const firstOldPath = firstCall[0]; @@ -727,7 +726,7 @@ describe('EditTool', () => { const result2 = await tool.onModify( params, new AbortController().signal, - ToolConfirmationOutcome.ModifyVSCode, + 'vscode', ); const secondCall = mockOpenDiff.mock.calls[1]; const secondOldPath = secondCall[0]; diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index a49b8d83..39352121 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -432,19 +432,6 @@ Expectation for required parameters: } } - async getEditor(outcome: ToolConfirmationOutcome): Promise { - switch (outcome) { - case ToolConfirmationOutcome.ModifyVSCode: - return 'vscode'; - case ToolConfirmationOutcome.ModifyWindsurf: - return 'windsurf'; - case ToolConfirmationOutcome.ModifyCursor: - return 'cursor'; - default: - return 'vim'; - } - } - /** * Creates temp files for the current and proposed file contents and opens a diff tool. * When the diff tool is closed, the tool will check if the file has been modified and provide the updated params. @@ -453,7 +440,7 @@ Expectation for required parameters: async onModify( params: EditToolParams, _abortSignal: AbortSignal, - outcome: ToolConfirmationOutcome, + editorType: EditorType, ): Promise< { updatedParams: EditToolParams; updatedDiff: string } | undefined > { @@ -461,9 +448,7 @@ Expectation for required parameters: this.tempOldDiffPath = oldPath; this.tempNewDiffPath = newPath; - const editor = await this.getEditor(outcome); - - await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editor); + await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editorType); return await this.getUpdatedParamsIfModified(params, _abortSignal); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index e80047df..ced53995 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -232,9 +232,6 @@ export enum ToolConfirmationOutcome { ProceedAlways = 'proceed_always', ProceedAlwaysServer = 'proceed_always_server', ProceedAlwaysTool = 'proceed_always_tool', - ModifyVSCode = 'modify_vscode', - ModifyWindsurf = 'modify_windsurf', - ModifyCursor = 'modify_cursor', - ModifyVim = 'modify_vim', + ModifyWithEditor = 'modify_with_editor', Cancel = 'cancel', } diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index e7c2f55a..8bc7a49c 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -4,8 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; -import { checkHasEditor, getDiffCommand, openDiff } from './editor.js'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { + checkHasEditorType, + getDiffCommand, + openDiff, + allowEditorTypeInSandbox, + isEditorAvailable, +} from './editor.js'; import { execSync, spawn } from 'child_process'; vi.mock('child_process', () => ({ @@ -13,14 +27,14 @@ vi.mock('child_process', () => ({ spawn: vi.fn(), })); -describe('checkHasEditor', () => { +describe('checkHasEditorType', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should return true for vscode if "code" command exists', () => { (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); - expect(checkHasEditor('vscode')).toBe(true); + expect(checkHasEditorType('vscode')).toBe(true); const expectedCommand = process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code'; expect(execSync).toHaveBeenCalledWith(expectedCommand, { @@ -32,12 +46,12 @@ describe('checkHasEditor', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); }); - expect(checkHasEditor('vscode')).toBe(false); + expect(checkHasEditorType('vscode')).toBe(false); }); it('should return true for windsurf if "windsurf" command exists', () => { (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/windsurf')); - expect(checkHasEditor('windsurf')).toBe(true); + expect(checkHasEditorType('windsurf')).toBe(true); expect(execSync).toHaveBeenCalledWith('command -v windsurf', { stdio: 'ignore', }); @@ -47,12 +61,12 @@ describe('checkHasEditor', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); }); - expect(checkHasEditor('windsurf')).toBe(false); + expect(checkHasEditorType('windsurf')).toBe(false); }); it('should return true for cursor if "cursor" command exists', () => { (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/cursor')); - expect(checkHasEditor('cursor')).toBe(true); + expect(checkHasEditorType('cursor')).toBe(true); expect(execSync).toHaveBeenCalledWith('command -v cursor', { stdio: 'ignore', }); @@ -62,12 +76,12 @@ describe('checkHasEditor', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); }); - expect(checkHasEditor('cursor')).toBe(false); + expect(checkHasEditorType('cursor')).toBe(false); }); it('should return true for vim if "vim" command exists', () => { (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim')); - expect(checkHasEditor('vim')).toBe(true); + expect(checkHasEditorType('vim')).toBe(true); const expectedCommand = process.platform === 'win32' ? 'where.exe vim' : 'command -v vim'; expect(execSync).toHaveBeenCalledWith(expectedCommand, { @@ -79,7 +93,7 @@ describe('checkHasEditor', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); }); - expect(checkHasEditor('vim')).toBe(false); + expect(checkHasEditorType('vim')).toBe(false); }); }); @@ -153,3 +167,85 @@ describe('openDiff', () => { ); }); }); + +describe('allowEditorTypeInSandbox', () => { + afterEach(() => { + delete process.env.SANDBOX; + }); + + it('should allow vim in sandbox mode', () => { + process.env.SANDBOX = 'sandbox'; + expect(allowEditorTypeInSandbox('vim')).toBe(true); + }); + + it('should allow vim when not in sandbox mode', () => { + delete process.env.SANDBOX; + expect(allowEditorTypeInSandbox('vim')).toBe(true); + }); + + it('should not allow vscode in sandbox mode', () => { + process.env.SANDBOX = 'sandbox'; + expect(allowEditorTypeInSandbox('vscode')).toBe(false); + }); + + it('should allow vscode when not in sandbox mode', () => { + delete process.env.SANDBOX; + expect(allowEditorTypeInSandbox('vscode')).toBe(true); + }); + + it('should not allow windsurf in sandbox mode', () => { + process.env.SANDBOX = 'sandbox'; + expect(allowEditorTypeInSandbox('windsurf')).toBe(false); + }); + + it('should allow windsurf when not in sandbox mode', () => { + delete process.env.SANDBOX; + expect(allowEditorTypeInSandbox('windsurf')).toBe(true); + }); + + it('should not allow cursor in sandbox mode', () => { + process.env.SANDBOX = 'sandbox'; + expect(allowEditorTypeInSandbox('cursor')).toBe(false); + }); + + it('should allow cursor when not in sandbox mode', () => { + delete process.env.SANDBOX; + expect(allowEditorTypeInSandbox('cursor')).toBe(true); + }); +}); + +describe('isEditorAvailable', () => { + afterEach(() => { + delete process.env.SANDBOX; + }); + + it('should return false for undefined editor', () => { + expect(isEditorAvailable(undefined)).toBe(false); + }); + + it('should return false for empty string editor', () => { + expect(isEditorAvailable('')).toBe(false); + }); + + it('should return false for invalid editor type', () => { + expect(isEditorAvailable('invalid-editor')).toBe(false); + }); + + it('should return true for vscode when installed and not in sandbox mode', () => { + (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); + expect(isEditorAvailable('vscode')).toBe(true); + }); + + it('should return false for vscode when not installed and not in sandbox mode', () => { + (execSync as Mock).mockImplementation(() => { + throw new Error(); + }); + expect(isEditorAvailable('vscode')).toBe(false); + }); + + it('should return false for vscode when installed and in sandbox mode', () => { + (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); + process.env.SANDBOX = 'sandbox'; + expect(isEditorAvailable('vscode')).toBe(false); + }); +}); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 4d09e3a9..15c970d0 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -8,6 +8,10 @@ import { execSync, spawn } from 'child_process'; export type EditorType = 'vscode' | 'windsurf' | 'cursor' | 'vim'; +function isValidEditorType(editor: string): editor is EditorType { + return ['vscode', 'windsurf', 'cursor', 'vim'].includes(editor); +} + interface DiffCommand { command: string; args: string[]; @@ -32,13 +36,35 @@ const editorCommands: Record = { vim: { win32: 'vim', default: 'vim' }, }; -export function checkHasEditor(editor: EditorType): boolean { +export function checkHasEditorType(editor: EditorType): boolean { const commandConfig = editorCommands[editor]; const command = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; return commandExists(command); } +export function allowEditorTypeInSandbox(editor: EditorType): boolean { + const notUsingSandbox = !process.env.SANDBOX; + if (['vscode', 'windsurf', 'cursor'].includes(editor)) { + return notUsingSandbox; + } + return true; +} + +/** + * Check if the editor is valid and can be used. + * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox. + */ +export function isEditorAvailable(editor: string | undefined): boolean { + if (editor && isValidEditorType(editor)) { + return ( + checkHasEditorType(editor as EditorType) && + allowEditorTypeInSandbox(editor as EditorType) + ); + } + return false; +} + /** * Get the diff command for a specific editor. */