mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
fix: resolve infinite loop when using 'Modify with external editor' (#17453)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com> Co-authored-by: ehedlund <ehedlund@google.com>
This commit is contained in:
@@ -14,17 +14,22 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import {
|
||||
checkHasEditorType,
|
||||
hasValidEditorCommand,
|
||||
hasValidEditorCommandAsync,
|
||||
getDiffCommand,
|
||||
openDiff,
|
||||
allowEditorTypeInSandbox,
|
||||
isEditorAvailable,
|
||||
isEditorAvailableAsync,
|
||||
resolveEditorAsync,
|
||||
type EditorType,
|
||||
} from './editor.js';
|
||||
import { execSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { coreEvents, CoreEvent } from './events.js';
|
||||
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
|
||||
@@ -51,7 +56,7 @@ describe('editor utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkHasEditorType', () => {
|
||||
describe('hasValidEditorCommand', () => {
|
||||
const testCases: Array<{
|
||||
editor: EditorType;
|
||||
commands: string[];
|
||||
@@ -89,7 +94,7 @@ describe('editor utils', () => {
|
||||
(execSync as Mock).mockReturnValue(
|
||||
Buffer.from(`/usr/bin/${commands[0]}`),
|
||||
);
|
||||
expect(checkHasEditorType(editor)).toBe(true);
|
||||
expect(hasValidEditorCommand(editor)).toBe(true);
|
||||
expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
@@ -103,7 +108,7 @@ describe('editor utils', () => {
|
||||
throw new Error(); // first command not found
|
||||
})
|
||||
.mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found
|
||||
expect(checkHasEditorType(editor)).toBe(true);
|
||||
expect(hasValidEditorCommand(editor)).toBe(true);
|
||||
expect(execSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}
|
||||
@@ -113,7 +118,7 @@ describe('editor utils', () => {
|
||||
(execSync as Mock).mockImplementation(() => {
|
||||
throw new Error(); // all commands not found
|
||||
});
|
||||
expect(checkHasEditorType(editor)).toBe(false);
|
||||
expect(hasValidEditorCommand(editor)).toBe(false);
|
||||
expect(execSync).toHaveBeenCalledTimes(commands.length);
|
||||
});
|
||||
|
||||
@@ -123,7 +128,7 @@ describe('editor utils', () => {
|
||||
(execSync as Mock).mockReturnValue(
|
||||
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
|
||||
);
|
||||
expect(checkHasEditorType(editor)).toBe(true);
|
||||
expect(hasValidEditorCommand(editor)).toBe(true);
|
||||
expect(execSync).toHaveBeenCalledWith(
|
||||
`where.exe ${win32Commands[0]}`,
|
||||
{
|
||||
@@ -142,7 +147,7 @@ describe('editor utils', () => {
|
||||
.mockReturnValueOnce(
|
||||
Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`),
|
||||
); // second command found
|
||||
expect(checkHasEditorType(editor)).toBe(true);
|
||||
expect(hasValidEditorCommand(editor)).toBe(true);
|
||||
expect(execSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}
|
||||
@@ -152,7 +157,7 @@ describe('editor utils', () => {
|
||||
(execSync as Mock).mockImplementation(() => {
|
||||
throw new Error(); // all commands not found
|
||||
});
|
||||
expect(checkHasEditorType(editor)).toBe(false);
|
||||
expect(hasValidEditorCommand(editor)).toBe(false);
|
||||
expect(execSync).toHaveBeenCalledTimes(win32Commands.length);
|
||||
});
|
||||
});
|
||||
@@ -542,4 +547,167 @@ describe('editor utils', () => {
|
||||
expect(isEditorAvailable('neovim')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock exec that simulates async behavior
|
||||
const mockExecAsync = (implementation: (cmd: string) => boolean): void => {
|
||||
(exec as unknown as Mock).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
if (implementation(cmd)) {
|
||||
callback(null, '/usr/bin/cmd', '');
|
||||
} else {
|
||||
callback(new Error('Command not found'), '', '');
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('hasValidEditorCommandAsync', () => {
|
||||
it('should return true if vim command exists', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
mockExecAsync((cmd) => cmd.includes('vim'));
|
||||
expect(await hasValidEditorCommandAsync('vim')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if vim command does not exist', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
mockExecAsync(() => false);
|
||||
expect(await hasValidEditorCommandAsync('vim')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check zed and zeditor commands in order', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
mockExecAsync((cmd) => cmd.includes('zeditor'));
|
||||
expect(await hasValidEditorCommandAsync('zed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEditorAvailableAsync', () => {
|
||||
it('should return false for undefined editor', async () => {
|
||||
expect(await isEditorAvailableAsync(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string editor', async () => {
|
||||
expect(await isEditorAvailableAsync('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid editor type', async () => {
|
||||
expect(await isEditorAvailableAsync('invalid-editor')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for vscode when installed and not in sandbox mode', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('code'));
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
expect(await isEditorAvailableAsync('vscode')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for vscode when not installed', async () => {
|
||||
mockExecAsync(() => false);
|
||||
expect(await isEditorAvailableAsync('vscode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for vscode in sandbox mode', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('code'));
|
||||
vi.stubEnv('SANDBOX', 'sandbox');
|
||||
expect(await isEditorAvailableAsync('vscode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for vim in sandbox mode', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('vim'));
|
||||
vi.stubEnv('SANDBOX', 'sandbox');
|
||||
expect(await isEditorAvailableAsync('vim')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEditorAsync', () => {
|
||||
it('should return the preferred editor when available', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('vim'));
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const result = await resolveEditorAsync('vim');
|
||||
expect(result).toBe('vim');
|
||||
});
|
||||
|
||||
it('should request editor selection when preferred editor is not installed', async () => {
|
||||
mockExecAsync(() => false);
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const resolvePromise = resolveEditorAsync('vim');
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }),
|
||||
0,
|
||||
);
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBe('neovim');
|
||||
});
|
||||
|
||||
it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('code'));
|
||||
vi.stubEnv('SANDBOX', 'sandbox');
|
||||
const resolvePromise = resolveEditorAsync('vscode');
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
|
||||
0,
|
||||
);
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBe('vim');
|
||||
});
|
||||
|
||||
it('should request editor selection when no preference is set', async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
|
||||
const resolvePromise = resolveEditorAsync(undefined);
|
||||
|
||||
// Simulate UI selection
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
|
||||
0,
|
||||
);
|
||||
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBe('vim');
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
|
||||
});
|
||||
|
||||
it('should return undefined when editor selection is cancelled', async () => {
|
||||
const resolvePromise = resolveEditorAsync(undefined);
|
||||
|
||||
// Simulate UI cancellation (exit dialog)
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }),
|
||||
0,
|
||||
);
|
||||
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when abort signal is triggered', async () => {
|
||||
const controller = new AbortController();
|
||||
const resolvePromise = resolveEditorAsync(undefined, controller.signal);
|
||||
|
||||
setTimeout(() => controller.abort(), 0);
|
||||
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should request editor selection in sandbox mode when no preference is set', async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
vi.stubEnv('SANDBOX', 'sandbox');
|
||||
|
||||
const resolvePromise = resolveEditorAsync(undefined);
|
||||
|
||||
// Simulate UI selection
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
|
||||
0,
|
||||
);
|
||||
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBe('vim');
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { once } from 'node:events';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { coreEvents, CoreEvent } from './events.js';
|
||||
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
|
||||
|
||||
const GUI_EDITORS = [
|
||||
'vscode',
|
||||
@@ -23,6 +25,9 @@ const GUI_EDITORS_SET = new Set<string>(GUI_EDITORS);
|
||||
const TERMINAL_EDITORS_SET = new Set<string>(TERMINAL_EDITORS);
|
||||
const EDITORS_SET = new Set<string>(EDITORS);
|
||||
|
||||
export const NO_EDITOR_AVAILABLE_ERROR =
|
||||
'No external editor is available. Please run /editor to configure one.';
|
||||
|
||||
export const DEFAULT_GUI_EDITOR: GuiEditorType = 'vscode';
|
||||
|
||||
export type GuiEditorType = (typeof GUI_EDITORS)[number];
|
||||
@@ -73,12 +78,26 @@ interface DiffCommand {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function getCommandExistsCmd(cmd: string): string {
|
||||
return process.platform === 'win32'
|
||||
? `where.exe ${cmd}`
|
||||
: `command -v ${cmd}`;
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function commandExistsAsync(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(getCommandExistsCmd(cmd));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -108,17 +127,29 @@ const editorCommands: Record<
|
||||
hx: { win32: ['hx'], default: ['hx'] },
|
||||
};
|
||||
|
||||
export function checkHasEditorType(editor: EditorType): boolean {
|
||||
function getEditorCommands(editor: EditorType): string[] {
|
||||
const commandConfig = editorCommands[editor];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
return commands.some((cmd) => commandExists(cmd));
|
||||
return process.platform === 'win32'
|
||||
? commandConfig.win32
|
||||
: commandConfig.default;
|
||||
}
|
||||
|
||||
export function hasValidEditorCommand(editor: EditorType): boolean {
|
||||
return getEditorCommands(editor).some((cmd) => commandExists(cmd));
|
||||
}
|
||||
|
||||
export async function hasValidEditorCommandAsync(
|
||||
editor: EditorType,
|
||||
): Promise<boolean> {
|
||||
return Promise.any(
|
||||
getEditorCommands(editor).map((cmd) =>
|
||||
commandExistsAsync(cmd).then((exists) => exists || Promise.reject()),
|
||||
),
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
export function getEditorCommand(editor: EditorType): string {
|
||||
const commandConfig = editorCommands[editor];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
const commands = getEditorCommands(editor);
|
||||
return (
|
||||
commands.slice(0, -1).find((cmd) => commandExists(cmd)) ||
|
||||
commands[commands.length - 1]
|
||||
@@ -134,15 +165,52 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEditorTypeAvailable(
|
||||
editor: string | undefined,
|
||||
): editor is EditorType {
|
||||
return (
|
||||
!!editor && isValidEditorType(editor) && allowEditorTypeInSandbox(editor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) && allowEditorTypeInSandbox(editor);
|
||||
return isEditorTypeAvailable(editor) && hasValidEditorCommand(editor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async function isEditorAvailableAsync(
|
||||
editor: string | undefined,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
isEditorTypeAvailable(editor) && (await hasValidEditorCommandAsync(editor))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an editor to use for external editing without blocking the event loop.
|
||||
* 1. If a preferred editor is set and available, uses it.
|
||||
* 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it.
|
||||
*/
|
||||
export async function resolveEditorAsync(
|
||||
preferredEditor: EditorType | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditorType | undefined> {
|
||||
if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) {
|
||||
return preferredEditor;
|
||||
}
|
||||
return false;
|
||||
|
||||
coreEvents.emit(CoreEvent.RequestEditorSelection);
|
||||
|
||||
return once(coreEvents, CoreEvent.EditorSelected, { signal })
|
||||
.then(([payload]) => (payload as EditorSelectedPayload).editor)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import type { McpClient } from '../tools/mcp-client.js';
|
||||
import type { ExtensionEvents } from './extensionLoader.js';
|
||||
import type { EditorType } from './editor.js';
|
||||
|
||||
/**
|
||||
* Defines the severity level for user-facing feedback.
|
||||
@@ -143,6 +144,15 @@ export enum CoreEvent {
|
||||
RetryAttempt = 'retry-attempt',
|
||||
ConsentRequest = 'consent-request',
|
||||
AgentsDiscovered = 'agents-discovered',
|
||||
RequestEditorSelection = 'request-editor-selection',
|
||||
EditorSelected = 'editor-selected',
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'editor-selected' event.
|
||||
*/
|
||||
export interface EditorSelectedPayload {
|
||||
editor?: EditorType;
|
||||
}
|
||||
|
||||
export interface CoreEvents extends ExtensionEvents {
|
||||
@@ -162,6 +172,8 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
||||
[CoreEvent.ConsentRequest]: [ConsentRequestPayload];
|
||||
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
|
||||
[CoreEvent.RequestEditorSelection]: never[];
|
||||
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
|
||||
}
|
||||
|
||||
type EventBacklogItem = {
|
||||
|
||||
Reference in New Issue
Block a user