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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 849 additions and 81 deletions

View File

@ -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`. 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 **`/?`**) - **`/help`** (or **`/?`**)
- **Description:** Displays help information about the Gemini CLI, including available commands and their usage. - **Description:** Displays help information about the Gemini CLI, including available commands and their usage.

View File

@ -39,6 +39,7 @@ export interface Settings {
accessibility?: AccessibilitySettings; accessibility?: AccessibilitySettings;
telemetry?: boolean; telemetry?: boolean;
enableModifyWithExternalEditors?: boolean; enableModifyWithExternalEditors?: boolean;
preferredEditor?: string;
// Git-aware file filtering settings // Git-aware file filtering settings
fileFiltering?: { fileFiltering?: {

View File

@ -19,6 +19,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@ -29,6 +30,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js'; import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js'; import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js'; import { ThemeDialog } from './components/ThemeDialog.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { Colors } from './colors.js'; import { Colors } from './colors.js';
import { Help } from './components/Help.js'; import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js';
@ -45,6 +47,8 @@ import {
type Config, type Config,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
ApprovalMode, ApprovalMode,
isEditorAvailable,
EditorType,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js'; import { StreamingContext } from './contexts/StreamingContext.js';
@ -82,6 +86,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const [debugMessage, setDebugMessage] = useState<string>(''); const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false); const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null); const [themeError, setThemeError] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
const [footerHeight, setFooterHeight] = useState<number>(0); const [footerHeight, setFooterHeight] = useState<number>(0);
const [corgiMode, setCorgiMode] = useState(false); const [corgiMode, setCorgiMode] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false); const [shellModeActive, setShellModeActive] = useState(false);
@ -106,6 +111,13 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
handleThemeHighlight, handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem); } = useThemeCommand(settings, setThemeError, addItem);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, addItem);
const toggleCorgiMode = useCallback(() => { const toggleCorgiMode = useCallback(() => {
setCorgiMode((prev) => !prev); setCorgiMode((prev) => !prev);
}, []); }, []);
@ -162,6 +174,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setShowHelp, setShowHelp,
setDebugMessage, setDebugMessage,
openThemeDialog, openThemeDialog,
openEditorDialog,
performMemoryRefresh, performMemoryRefresh,
toggleCorgiMode, toggleCorgiMode,
showToolDescriptions, showToolDescriptions,
@ -227,6 +240,16 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
} }
}, [config]); }, [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 } = const { streamingState, submitQuery, initError, pendingHistoryItems } =
useGeminiStream( useGeminiStream(
config.getGeminiClient(), config.getGeminiClient(),
@ -237,6 +260,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
setDebugMessage, setDebugMessage,
handleSlashCommand, handleSlashCommand,
shellModeActive, shellModeActive,
getPreferredEditor,
); );
const { elapsedTime, currentLoadingPhrase } = const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState); useLoadingIndicator(streamingState);
@ -409,6 +433,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
item={{ ...item, id: 0 }} item={{ ...item, id: 0 }}
isPending={true} isPending={true}
config={config} config={config}
isFocused={!isEditorDialogOpen}
/> />
))} ))}
</Box> </Box>
@ -444,6 +469,19 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
settings={settings} settings={settings}
/> />
</Box> </Box>
) : isEditorDialogOpen ? (
<Box flexDirection="column">
{editorError && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={handleEditorSelect}
settings={settings}
onExit={exitEditorDialog}
/>
</Box>
) : ( ) : (
<> <>
<LoadingIndicator <LoadingIndicator

View File

@ -0,0 +1,168 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
editorSettingsManager,
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@gemini-cli/core';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
settings: LoadedSettings;
onExit: () => void;
}
export function EditorSettingsDialog({
onSelect,
settings,
onExit,
}: EditorDialogProps): React.JSX.Element {
const [selectedScope, setSelectedScope] = useState<SettingScope>(
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 (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
>
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={focusedSection === 'editor'}>
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
/>
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
isFocused={focusedSection === 'scope'}
/>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
</Box>
</Box>
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold>Editor Preference</Text>
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color={Colors.Gray}>
These editors are currently supported. Please note that some editors
cannot be used in sandbox mode.
</Text>
<Text color={Colors.Gray}>
Your preferred editor is:{' '}
<Text
color={
mergedEditorName === 'None'
? Colors.AccentRed
: Colors.AccentCyan
}
bold
>
{mergedEditorName}
</Text>
.
</Text>
</Box>
</Box>
</Box>
);
}

View File

@ -24,6 +24,7 @@ interface HistoryItemDisplayProps {
availableTerminalHeight: number; availableTerminalHeight: number;
isPending: boolean; isPending: boolean;
config?: Config; config?: Config;
isFocused?: boolean;
} }
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@ -31,6 +32,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeight, availableTerminalHeight,
isPending, isPending,
config, config,
isFocused = true,
}) => ( }) => (
<Box flexDirection="column" key={item.id}> <Box flexDirection="column" key={item.id}>
{/* Render standard message types */} {/* Render standard message types */}
@ -76,6 +78,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
groupId={item.id} groupId={item.id}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
config={config} config={config}
isFocused={isFocused}
/> />
)} )}
</Box> </Box>

View File

@ -13,7 +13,6 @@ import {
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolExecuteConfirmationDetails, ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails, ToolMcpConfirmationDetails,
checkHasEditor,
Config, Config,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { import {
@ -24,14 +23,16 @@ import {
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails; confirmationDetails: ToolCallConfirmationDetails;
config?: Config; config?: Config;
isFocused?: boolean;
} }
export const ToolConfirmationMessage: React.FC< export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps ToolConfirmationMessageProps
> = ({ confirmationDetails, config }) => { > = ({ confirmationDetails, config, isFocused = true }) => {
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
useInput((_, key) => { useInput((_, key) => {
if (!isFocused) return;
if (key.escape) { if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel); 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 = const externalEditorsEnabled =
config?.getEnableModifyWithExternalEditors() ?? false; config?.getEnableModifyWithExternalEditors() ?? false;
if (externalEditorsEnabled) {
if (checkHasEditor('vscode') && notUsingSandbox && externalEditorsEnabled) {
options.push({ options.push({
label: 'Modify with VS Code', label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyVSCode, value: ToolConfirmationOutcome.ModifyWithEditor,
});
}
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,
}); });
} }
@ -192,7 +165,11 @@ export const ToolConfirmationMessage: React.FC<
{/* Select Input for Options */} {/* Select Input for Options */}
<Box flexShrink={0}> <Box flexShrink={0}>
<RadioButtonSelect items={options} onSelect={handleSelect} /> <RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box> </Box>
</Box> </Box>
); );

View File

@ -17,6 +17,7 @@ interface ToolGroupMessageProps {
toolCalls: IndividualToolCallDisplay[]; toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight: number; availableTerminalHeight: number;
config?: Config; config?: Config;
isFocused?: boolean;
} }
// Main component renders the border and maps the tools using ToolMessage // Main component renders the border and maps the tools using ToolMessage
@ -24,6 +25,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls, toolCalls,
availableTerminalHeight, availableTerminalHeight,
config, config,
isFocused = true,
}) => { }) => {
const hasPending = !toolCalls.every( const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success, (t) => t.status === ToolCallStatus.Success,
@ -84,6 +86,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
<ToolConfirmationMessage <ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
config={config} config={config}
isFocused={isFocused}
/> />
)} )}
</Box> </Box>

View File

@ -19,6 +19,7 @@ import { Colors } from '../../colors.js';
export interface RadioSelectItem<T> { export interface RadioSelectItem<T> {
label: string; label: string;
value: T; value: T;
disabled?: boolean;
} }
/** /**
@ -97,11 +98,14 @@ export function RadioButtonSelect<T>({
const itemWithThemeProps = props as typeof props & { const itemWithThemeProps = props as typeof props & {
themeNameDisplay?: string; themeNameDisplay?: string;
themeTypeDisplay?: string; themeTypeDisplay?: string;
disabled?: boolean;
}; };
let textColor = Colors.Foreground; let textColor = Colors.Foreground;
if (isSelected) { if (isSelected) {
textColor = Colors.AccentGreen; textColor = Colors.AccentGreen;
} else if (itemWithThemeProps.disabled === true) {
textColor = Colors.Gray;
} }
if ( if (

View File

@ -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<EditorType, string> = {
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();

View File

@ -97,6 +97,7 @@ describe('useSlashCommandProcessor', () => {
let mockSetShowHelp: ReturnType<typeof vi.fn>; let mockSetShowHelp: ReturnType<typeof vi.fn>;
let mockOnDebugMessage: ReturnType<typeof vi.fn>; let mockOnDebugMessage: ReturnType<typeof vi.fn>;
let mockOpenThemeDialog: ReturnType<typeof vi.fn>; let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>; let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
let mockSetQuittingMessages: ReturnType<typeof vi.fn>; let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
let mockConfig: Config; let mockConfig: Config;
@ -111,6 +112,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp = vi.fn(); mockSetShowHelp = vi.fn();
mockOnDebugMessage = vi.fn(); mockOnDebugMessage = vi.fn();
mockOpenThemeDialog = vi.fn(); mockOpenThemeDialog = vi.fn();
mockOpenEditorDialog = vi.fn();
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined); mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
mockSetQuittingMessages = vi.fn(); mockSetQuittingMessages = vi.fn();
mockConfig = { mockConfig = {
@ -155,6 +157,7 @@ describe('useSlashCommandProcessor', () => {
mockSetShowHelp, mockSetShowHelp,
mockOnDebugMessage, mockOnDebugMessage,
mockOpenThemeDialog, mockOpenThemeDialog,
mockOpenEditorDialog,
mockPerformMemoryRefresh, mockPerformMemoryRefresh,
mockCorgiMode, mockCorgiMode,
showToolDescriptions, showToolDescriptions,
@ -322,6 +325,16 @@ describe('useSlashCommandProcessor', () => {
expect(mockSetShowHelp).toHaveBeenCalledWith(true); expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(commandResult).toBe(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', () => { describe('/bug command', () => {

View File

@ -66,6 +66,7 @@ export const useSlashCommandProcessor = (
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>, setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
onDebugMessage: (message: string) => void, onDebugMessage: (message: string) => void,
openThemeDialog: () => void, openThemeDialog: () => void,
openEditorDialog: () => void,
performMemoryRefresh: () => Promise<void>, performMemoryRefresh: () => Promise<void>,
toggleCorgiMode: () => void, toggleCorgiMode: () => void,
showToolDescriptions: boolean = false, showToolDescriptions: boolean = false,
@ -181,6 +182,13 @@ export const useSlashCommandProcessor = (
openThemeDialog(); openThemeDialog();
}, },
}, },
{
name: 'editor',
description: 'open the editor',
action: (_mainCommand, _subCommand, _args) => {
openEditorDialog();
},
},
{ {
name: 'stats', name: 'stats',
altName: 'usage', altName: 'usage',
@ -745,6 +753,7 @@ Add any other context about the problem here.
setShowHelp, setShowHelp,
refreshStatic, refreshStatic,
openThemeDialog, openThemeDialog,
openEditorDialog,
clearItems, clearItems,
performMemoryRefresh, performMemoryRefresh,
showMemoryAction, 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, TrackedExecutingToolCall,
TrackedCancelledToolCall, TrackedCancelledToolCall,
} from './useReactToolScheduler.js'; } from './useReactToolScheduler.js';
import { Config } from '@gemini-cli/core'; import { Config, EditorType } from '@gemini-cli/core';
import { Part, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { HistoryItem } from '../types.js'; import { HistoryItem } from '../types.js';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { LoadedSettings } from '../../config/settings.js';
// --- MOCKS --- // --- MOCKS ---
const mockSendMessageStream = vi const mockSendMessageStream = vi
@ -309,6 +310,15 @@ describe('useGeminiStream', () => {
.mockReturnValue((async function* () {})()); .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 = ( const renderTestHook = (
initialToolCalls: TrackedToolCall[] = [], initialToolCalls: TrackedToolCall[] = [],
geminiClient?: any, geminiClient?: any,
@ -337,6 +347,7 @@ describe('useGeminiStream', () => {
| boolean | boolean
>; >;
shellModeActive: boolean; shellModeActive: boolean;
loadedSettings: LoadedSettings;
}) => }) =>
useGeminiStream( useGeminiStream(
props.client, props.client,
@ -347,6 +358,7 @@ describe('useGeminiStream', () => {
props.onDebugMessage, props.onDebugMessage,
props.handleSlashCommand, props.handleSlashCommand,
props.shellModeActive, props.shellModeActive,
() => 'vscode' as EditorType,
), ),
{ {
initialProps: { initialProps: {
@ -363,6 +375,7 @@ describe('useGeminiStream', () => {
| boolean | boolean
>, >,
shellModeActive: false, shellModeActive: false,
loadedSettings: mockLoadedSettings,
}, },
}, },
); );
@ -486,6 +499,7 @@ describe('useGeminiStream', () => {
handleSlashCommand: handleSlashCommand:
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand, mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
shellModeActive: false, shellModeActive: false,
loadedSettings: mockLoadedSettings,
}); });
}); });
@ -541,6 +555,7 @@ describe('useGeminiStream', () => {
handleSlashCommand: handleSlashCommand:
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand, mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
shellModeActive: false, shellModeActive: false,
loadedSettings: mockLoadedSettings,
}); });
}); });

View File

@ -19,6 +19,7 @@ import {
ToolCallRequestInfo, ToolCallRequestInfo,
logUserPrompt, logUserPrompt,
GitService, GitService,
EditorType,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { type Part, type PartListUnion } from '@google/genai'; import { type Part, type PartListUnion } from '@google/genai';
import { import {
@ -83,6 +84,7 @@ export const useGeminiStream = (
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
>, >,
shellModeActive: boolean, shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
) => { ) => {
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -115,6 +117,7 @@ export const useGeminiStream = (
}, },
config, config,
setPendingHistoryItem, setPendingHistoryItem,
getPreferredEditor,
); );
const pendingToolCallGroupDisplay = useMemo( const pendingToolCallGroupDisplay = useMemo(

View File

@ -21,6 +21,7 @@ import {
ToolCall, ToolCall,
Status as CoreStatus, Status as CoreStatus,
logToolCall, logToolCall,
EditorType,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { useCallback, useState, useMemo } from 'react'; import { useCallback, useState, useMemo } from 'react';
import { import {
@ -69,6 +70,7 @@ export function useReactToolScheduler(
setPendingHistoryItem: React.Dispatch< setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null> React.SetStateAction<HistoryItemWithoutId | null>
>, >,
getPreferredEditor: () => EditorType | undefined,
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] { ): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState< const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[] TrackedToolCall[]
@ -162,12 +164,14 @@ export function useReactToolScheduler(
onAllToolCallsComplete: allToolCallsCompleteHandler, onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler, onToolCallsUpdate: toolCallsUpdateHandler,
approvalMode: config.getApprovalMode(), approvalMode: config.getApprovalMode(),
getPreferredEditor,
}), }),
[ [
config, config,
outputUpdateHandler, outputUpdateHandler,
allToolCallsCompleteHandler, allToolCallsCompleteHandler,
toolCallsUpdateHandler, toolCallsUpdateHandler,
getPreferredEditor,
], ],
); );

View File

@ -62,7 +62,6 @@ describe('CoreToolScheduler', () => {
getFunctionDeclarations: () => [], getFunctionDeclarations: () => [],
tools: new Map(), tools: new Map(),
discovery: {} as any, discovery: {} as any,
config: {} as any,
registerTool: () => {}, registerTool: () => {},
getToolByName: () => mockTool, getToolByName: () => mockTool,
getToolByDisplayName: () => mockTool, getToolByDisplayName: () => mockTool,
@ -79,6 +78,7 @@ describe('CoreToolScheduler', () => {
toolRegistry: Promise.resolve(toolRegistry as any), toolRegistry: Promise.resolve(toolRegistry as any),
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
}); });
const abortController = new AbortController(); const abortController = new AbortController();

View File

@ -15,6 +15,7 @@ import {
ApprovalMode, ApprovalMode,
EditTool, EditTool,
EditToolParams, EditToolParams,
EditorType,
} from '../index.js'; } from '../index.js';
import { Part, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
@ -203,6 +204,7 @@ interface CoreToolSchedulerOptions {
onAllToolCallsComplete?: AllToolCallsCompleteHandler; onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler; onToolCallsUpdate?: ToolCallsUpdateHandler;
approvalMode?: ApprovalMode; approvalMode?: ApprovalMode;
getPreferredEditor: () => EditorType | undefined;
} }
export class CoreToolScheduler { export class CoreToolScheduler {
@ -212,6 +214,7 @@ export class CoreToolScheduler {
private onAllToolCallsComplete?: AllToolCallsCompleteHandler; private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
private onToolCallsUpdate?: ToolCallsUpdateHandler; private onToolCallsUpdate?: ToolCallsUpdateHandler;
private approvalMode: ApprovalMode; private approvalMode: ApprovalMode;
private getPreferredEditor: () => EditorType | undefined;
constructor(options: CoreToolSchedulerOptions) { constructor(options: CoreToolSchedulerOptions) {
this.toolRegistry = options.toolRegistry; this.toolRegistry = options.toolRegistry;
@ -219,6 +222,7 @@ export class CoreToolScheduler {
this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate; this.onToolCallsUpdate = options.onToolCallsUpdate;
this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT; this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT;
this.getPreferredEditor = options.getPreferredEditor;
} }
private setStatusInternal( private setStatusInternal(
@ -484,15 +488,15 @@ export class CoreToolScheduler {
'cancelled', 'cancelled',
'User did not allow tool call', 'User did not allow tool call',
); );
} else if ( } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
outcome === ToolConfirmationOutcome.ModifyVSCode ||
outcome === ToolConfirmationOutcome.ModifyWindsurf ||
outcome === ToolConfirmationOutcome.ModifyCursor ||
outcome === ToolConfirmationOutcome.ModifyVim
) {
const waitingToolCall = toolCall as WaitingToolCall; const waitingToolCall = toolCall as WaitingToolCall;
if (waitingToolCall?.confirmationDetails?.type === 'edit') { if (waitingToolCall?.confirmationDetails?.type === 'edit') {
const editTool = waitingToolCall.tool as EditTool; const editTool = waitingToolCall.tool as EditTool;
const editorType = this.getPreferredEditor();
if (!editorType) {
return;
}
this.setStatusInternal(callId, 'awaiting_approval', { this.setStatusInternal(callId, 'awaiting_approval', {
...waitingToolCall.confirmationDetails, ...waitingToolCall.confirmationDetails,
isModifying: true, isModifying: true,
@ -501,7 +505,7 @@ export class CoreToolScheduler {
const modifyResults = await editTool.onModify( const modifyResults = await editTool.onModify(
waitingToolCall.request.args as unknown as EditToolParams, waitingToolCall.request.args as unknown as EditToolParams,
signal, signal,
outcome, editorType,
); );
if (modifyResults) { if (modifyResults) {

View File

@ -32,7 +32,6 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import { ApprovalMode, Config } from '../config/config.js'; import { ApprovalMode, Config } from '../config/config.js';
import { Content, Part, SchemaUnion } from '@google/genai'; import { Content, Part, SchemaUnion } from '@google/genai';
import { ToolConfirmationOutcome } from './tools.js';
describe('EditTool', () => { describe('EditTool', () => {
let tool: EditTool; let tool: EditTool;
@ -634,7 +633,7 @@ describe('EditTool', () => {
const result = await tool.onModify( const result = await tool.onModify(
params, params,
new AbortController().signal, new AbortController().signal,
ToolConfirmationOutcome.ModifyVSCode, 'vscode',
); );
expect(mockOpenDiff).toHaveBeenCalledTimes(1); expect(mockOpenDiff).toHaveBeenCalledTimes(1);
@ -678,7 +677,7 @@ describe('EditTool', () => {
const result = await tool.onModify( const result = await tool.onModify(
params, params,
new AbortController().signal, new AbortController().signal,
ToolConfirmationOutcome.ModifyVSCode, 'vscode',
); );
expect(mockOpenDiff).toHaveBeenCalledTimes(1); expect(mockOpenDiff).toHaveBeenCalledTimes(1);
@ -711,7 +710,7 @@ describe('EditTool', () => {
const result1 = await tool.onModify( const result1 = await tool.onModify(
params, params,
new AbortController().signal, new AbortController().signal,
ToolConfirmationOutcome.ModifyVSCode, 'vscode',
); );
const firstCall = mockOpenDiff.mock.calls[0]; const firstCall = mockOpenDiff.mock.calls[0];
const firstOldPath = firstCall[0]; const firstOldPath = firstCall[0];
@ -727,7 +726,7 @@ describe('EditTool', () => {
const result2 = await tool.onModify( const result2 = await tool.onModify(
params, params,
new AbortController().signal, new AbortController().signal,
ToolConfirmationOutcome.ModifyVSCode, 'vscode',
); );
const secondCall = mockOpenDiff.mock.calls[1]; const secondCall = mockOpenDiff.mock.calls[1];
const secondOldPath = secondCall[0]; const secondOldPath = secondCall[0];

View File

@ -432,19 +432,6 @@ Expectation for required parameters:
} }
} }
async getEditor(outcome: ToolConfirmationOutcome): Promise<EditorType> {
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. * 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. * 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( async onModify(
params: EditToolParams, params: EditToolParams,
_abortSignal: AbortSignal, _abortSignal: AbortSignal,
outcome: ToolConfirmationOutcome, editorType: EditorType,
): Promise< ): Promise<
{ updatedParams: EditToolParams; updatedDiff: string } | undefined { updatedParams: EditToolParams; updatedDiff: string } | undefined
> { > {
@ -461,9 +448,7 @@ Expectation for required parameters:
this.tempOldDiffPath = oldPath; this.tempOldDiffPath = oldPath;
this.tempNewDiffPath = newPath; this.tempNewDiffPath = newPath;
const editor = await this.getEditor(outcome); await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editorType);
await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editor);
return await this.getUpdatedParamsIfModified(params, _abortSignal); return await this.getUpdatedParamsIfModified(params, _abortSignal);
} }

View File

@ -232,9 +232,6 @@ export enum ToolConfirmationOutcome {
ProceedAlways = 'proceed_always', ProceedAlways = 'proceed_always',
ProceedAlwaysServer = 'proceed_always_server', ProceedAlwaysServer = 'proceed_always_server',
ProceedAlwaysTool = 'proceed_always_tool', ProceedAlwaysTool = 'proceed_always_tool',
ModifyVSCode = 'modify_vscode', ModifyWithEditor = 'modify_with_editor',
ModifyWindsurf = 'modify_windsurf',
ModifyCursor = 'modify_cursor',
ModifyVim = 'modify_vim',
Cancel = 'cancel', Cancel = 'cancel',
} }

View File

@ -4,8 +4,22 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import {
import { checkHasEditor, getDiffCommand, openDiff } from './editor.js'; 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'; import { execSync, spawn } from 'child_process';
vi.mock('child_process', () => ({ vi.mock('child_process', () => ({
@ -13,14 +27,14 @@ vi.mock('child_process', () => ({
spawn: vi.fn(), spawn: vi.fn(),
})); }));
describe('checkHasEditor', () => { describe('checkHasEditorType', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should return true for vscode if "code" command exists', () => { it('should return true for vscode if "code" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code')); (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
expect(checkHasEditor('vscode')).toBe(true); expect(checkHasEditorType('vscode')).toBe(true);
const expectedCommand = const expectedCommand =
process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code'; process.platform === 'win32' ? 'where.exe code.cmd' : 'command -v code';
expect(execSync).toHaveBeenCalledWith(expectedCommand, { expect(execSync).toHaveBeenCalledWith(expectedCommand, {
@ -32,12 +46,12 @@ describe('checkHasEditor', () => {
(execSync as Mock).mockImplementation(() => { (execSync as Mock).mockImplementation(() => {
throw new Error(); throw new Error();
}); });
expect(checkHasEditor('vscode')).toBe(false); expect(checkHasEditorType('vscode')).toBe(false);
}); });
it('should return true for windsurf if "windsurf" command exists', () => { it('should return true for windsurf if "windsurf" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/windsurf')); (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', { expect(execSync).toHaveBeenCalledWith('command -v windsurf', {
stdio: 'ignore', stdio: 'ignore',
}); });
@ -47,12 +61,12 @@ describe('checkHasEditor', () => {
(execSync as Mock).mockImplementation(() => { (execSync as Mock).mockImplementation(() => {
throw new Error(); throw new Error();
}); });
expect(checkHasEditor('windsurf')).toBe(false); expect(checkHasEditorType('windsurf')).toBe(false);
}); });
it('should return true for cursor if "cursor" command exists', () => { it('should return true for cursor if "cursor" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/cursor')); (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', { expect(execSync).toHaveBeenCalledWith('command -v cursor', {
stdio: 'ignore', stdio: 'ignore',
}); });
@ -62,12 +76,12 @@ describe('checkHasEditor', () => {
(execSync as Mock).mockImplementation(() => { (execSync as Mock).mockImplementation(() => {
throw new Error(); throw new Error();
}); });
expect(checkHasEditor('cursor')).toBe(false); expect(checkHasEditorType('cursor')).toBe(false);
}); });
it('should return true for vim if "vim" command exists', () => { it('should return true for vim if "vim" command exists', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim')); (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
expect(checkHasEditor('vim')).toBe(true); expect(checkHasEditorType('vim')).toBe(true);
const expectedCommand = const expectedCommand =
process.platform === 'win32' ? 'where.exe vim' : 'command -v vim'; process.platform === 'win32' ? 'where.exe vim' : 'command -v vim';
expect(execSync).toHaveBeenCalledWith(expectedCommand, { expect(execSync).toHaveBeenCalledWith(expectedCommand, {
@ -79,7 +93,7 @@ describe('checkHasEditor', () => {
(execSync as Mock).mockImplementation(() => { (execSync as Mock).mockImplementation(() => {
throw new Error(); 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);
});
});

View File

@ -8,6 +8,10 @@ import { execSync, spawn } from 'child_process';
export type EditorType = 'vscode' | 'windsurf' | 'cursor' | 'vim'; export type EditorType = 'vscode' | 'windsurf' | 'cursor' | 'vim';
function isValidEditorType(editor: string): editor is EditorType {
return ['vscode', 'windsurf', 'cursor', 'vim'].includes(editor);
}
interface DiffCommand { interface DiffCommand {
command: string; command: string;
args: string[]; args: string[];
@ -32,13 +36,35 @@ const editorCommands: Record<EditorType, { win32: string; default: string }> = {
vim: { win32: 'vim', default: 'vim' }, vim: { win32: 'vim', default: 'vim' },
}; };
export function checkHasEditor(editor: EditorType): boolean { export function checkHasEditorType(editor: EditorType): boolean {
const commandConfig = editorCommands[editor]; const commandConfig = editorCommands[editor];
const command = const command =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
return commandExists(command); 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. * Get the diff command for a specific editor.
*/ */