Compare commits

...

5 Commits

Author SHA1 Message Date
A.K.M. Adib 7fbcd0e765 feat(cli): remove editor environment fallback and use feedback instead of dialog 2026-05-05 08:49:24 -04:00
A.K.M. Adib d98910b77c test(cli): use platform-aware editor command in editorUtils tests 2026-05-04 16:29:46 -04:00
Adib234 9287366fa5 Update packages/cli/src/ui/utils/editorUtils.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-04 16:08:19 -04:00
A.K.M. Adib 5ea9c0e3c0 test(cli): add unit tests for editor fallback and improve env var handling 2026-05-04 15:51:56 -04:00
A.K.M. Adib a7d49971d2 fix(cli): prompt for editor selection when CTRL-X is pressed and none is configured 2026-05-04 15:24:25 -04:00
4 changed files with 251 additions and 19 deletions
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../../test-utils/render.js';
import { useTextBuffer } from './text-buffer.js';
import {
openFileInEditor,
EditorNotConfiguredError,
} from '../../utils/editorUtils.js';
import { coreEvents, CoreEvent } from '@google/gemini-cli-core';
import fs from 'node:fs';
vi.mock('node:fs', () => ({
default: {
mkdtempSync: vi.fn().mockReturnValue('/tmp/gemini-edit-123'),
writeFileSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('updated text'),
unlinkSync: vi.fn(),
rmdirSync: vi.fn(),
},
}));
vi.mock('node:os', () => ({
default: {
tmpdir: vi.fn().mockReturnValue('/tmp'),
},
}));
vi.mock('../../utils/editorUtils.js', () => ({
openFileInEditor: vi.fn(),
EditorNotConfiguredError: class extends Error {
constructor() {
super('No external editor configured');
this.name = 'EditorNotConfiguredError';
}
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
coreEvents: {
emit: vi.fn(),
emitFeedback: vi.fn(),
},
};
});
describe('useTextBuffer external editor', () => {
const viewport = { width: 80, height: 24 };
beforeEach(() => {
vi.clearAllMocks();
});
it('should emit feedback when openFileInEditor throws EditorNotConfiguredError', async () => {
vi.mocked(openFileInEditor).mockRejectedValue(
new EditorNotConfiguredError(),
);
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'some text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No external editor configured. Please set your preferred editor in settings.',
);
expect(coreEvents.emit).not.toHaveBeenCalledWith(
CoreEvent.RequestEditorSelection,
);
});
it('should update text when openFileInEditor succeeds', async () => {
vi.mocked(openFileInEditor).mockResolvedValue(undefined);
vi.mocked(fs.readFileSync).mockReturnValue('updated text from editor');
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'initial text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(result.current.text).toBe('updated text from editor');
});
it('should log feedback error for other errors', async () => {
const unexpectedError = new Error('Some unexpected error');
vi.mocked(openFileInEditor).mockRejectedValue(unexpectedError);
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'some text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'[useTextBuffer] external editor error',
unexpectedError,
);
expect(coreEvents.emit).not.toHaveBeenCalledWith(
CoreEvent.RequestEditorSelection,
);
});
});
@@ -29,7 +29,10 @@ import { Command } from '../../key/keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
import {
openFileInEditor,
EditorNotConfiguredError,
} from '../../utils/editorUtils.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
@@ -3342,6 +3345,13 @@ export function useTextBuffer({
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
if (err instanceof EditorNotConfiguredError) {
coreEvents.emitFeedback(
'warning',
'No external editor configured. Please set your preferred editor in settings.',
);
return;
}
coreEvents.emitFeedback(
'error',
'[useTextBuffer] external editor error',
@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { openFileInEditor, EditorNotConfiguredError } from './editorUtils.js';
import {
spawnSync,
spawn,
type SpawnSyncReturns,
type ChildProcess,
} from 'node:child_process';
import {
CoreEvent,
coreEvents,
getEditorCommand,
} from '@google/gemini-cli-core';
vi.mock('node:child_process', () => ({
spawnSync: vi.fn(),
spawn: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
coreEvents: {
emit: vi.fn(),
emitFeedback: vi.fn(),
},
};
});
describe('editorUtils', () => {
beforeEach(() => {
vi.stubEnv('VISUAL', '');
vi.stubEnv('EDITOR', '');
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should throw EditorNotConfiguredError if no editor is configured', async () => {
await expect(openFileInEditor('test.txt', null, undefined)).rejects.toThrow(
EditorNotConfiguredError,
);
});
it('should use preferredEditorType if provided (terminal editor)', async () => {
vi.mocked(spawnSync).mockReturnValue({
status: 0,
} as SpawnSyncReturns<Buffer>);
await openFileInEditor('test.txt', null, undefined, 'vim');
expect(spawnSync).toHaveBeenCalledWith(
getEditorCommand('vim'),
expect.arrayContaining(['test.txt']),
expect.anything(),
);
expect(coreEvents.emit).toHaveBeenCalledWith(
CoreEvent.ExternalEditorClosed,
);
});
it('should use preferredEditorType if provided (GUI editor)', async () => {
const mockChild = {
on: vi.fn((event: string, cb: (code: number) => void) => {
if (event === 'close') cb(0);
return mockChild;
}),
};
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
await openFileInEditor('test.txt', null, undefined, 'vscode');
expect(spawn).toHaveBeenCalledWith(
getEditorCommand('vscode'),
expect.arrayContaining(['--wait', 'test.txt']),
expect.anything(),
);
expect(coreEvents.emit).toHaveBeenCalledWith(
CoreEvent.ExternalEditorClosed,
);
});
it('should handle editor exit with non-zero status', async () => {
vi.mocked(spawnSync).mockReturnValue({
status: 1,
} as SpawnSyncReturns<Buffer>);
await expect(
openFileInEditor('test.txt', null, undefined, 'vim'),
).rejects.toThrow('External editor exited with status 1');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
expect.any(String),
expect.any(Error),
);
});
});
+8 -18
View File
@@ -15,6 +15,13 @@ import {
isTerminalEditor,
} from '@google/gemini-cli-core';
export class EditorNotConfiguredError extends Error {
constructor() {
super('No external editor configured');
this.name = 'EditorNotConfiguredError';
}
}
/**
* Opens a file in an external editor and waits for it to close.
* Handles raw mode switching to ensure the editor can interact with the terminal.
@@ -41,24 +48,7 @@ export async function openFileInEditor(
}
if (!command) {
command = process.env['VISUAL'] ?? process.env['EDITOR'];
if (command) {
const lowerCommand = command.toLowerCase();
const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
lowerCommand.includes(gui),
);
if (
isGui &&
!lowerCommand.includes('--wait') &&
!lowerCommand.includes('-w')
) {
args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
}
}
}
if (!command) {
command = process.platform === 'win32' ? 'notepad' : 'vi';
throw new EditorNotConfiguredError();
}
const [executable = '', ...initialArgs] = command.split(' ');