Files
gemini-cli/packages/cli/src/ui/utils/editorUtils.ts
T

211 lines
6.1 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, spawnSync } from 'node:child_process';
import type { ReadStream } from 'node:tty';
import {
ALL_EDITORS,
CoreEvent,
coreEvents,
type EditorType,
getEditorCommand,
getEditorExtraArgs,
getEditorWaitFlag,
isGuiEditor,
isTerminalEditor,
isValidEditorType,
resolveEditorTypeFromCommand,
} from '@google/gemini-cli-core';
/**
* Command name substrings used to guess whether an unknown $VISUAL/$EDITOR
* value is a GUI editor. This is a fallback for editors not in the registry;
* registered editors are detected via resolveEditorTypeFromCommand instead.
*/
const HEURISTIC_GUI_COMMANDS = [
'code',
'cursor',
'subl',
'zed',
'atom',
'agy',
] as const;
/**
* 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.
*
* @param filePath Path to the file to open
* @param stdin The stdin stream from Ink/Node
* @param setRawMode Function to toggle raw mode
* @param preferredEditorType The user's preferred editor from config
* @param openInNewWindow Whether to open VS Code-family editors in a new window
*/
export async function openFileInEditor(
filePath: string,
stdin: ReadStream | null | undefined,
setRawMode: ((mode: boolean) => void) | undefined,
preferredEditorType?: EditorType,
openInNewWindow?: boolean,
): Promise<void> {
let command: string | undefined = undefined;
const args = [filePath];
// Extra args that come before the file path (e.g. -nw for emacsclient)
const extraArgs: string[] = [];
if (preferredEditorType) {
if (!isValidEditorType(preferredEditorType)) {
coreEvents.emitFeedback(
'error',
`Editor '${preferredEditorType}' is not a recognized editor identifier. ` +
`Supported editors: ${ALL_EDITORS.join(', ')}. ` +
`Use /editor to select one, or set the $VISUAL or $EDITOR environment variable.`,
);
return;
}
command = getEditorCommand(preferredEditorType);
if (isGuiEditor(preferredEditorType)) {
args.unshift(getEditorWaitFlag(preferredEditorType));
}
extraArgs.push(
...getEditorExtraArgs(preferredEditorType, {
newWindow: openInNewWindow,
}),
);
}
if (!command) {
const envCommand = process.env['VISUAL'] ?? process.env['EDITOR'];
if (envCommand) {
command = envCommand;
const [envExecutable = ''] = envCommand.split(' ');
const resolvedType = resolveEditorTypeFromCommand(envExecutable);
if (resolvedType) {
if (
isGuiEditor(resolvedType) &&
!envCommand.includes('--wait') &&
!envCommand.includes('-w')
) {
args.unshift(getEditorWaitFlag(resolvedType));
}
extraArgs.push(
...getEditorExtraArgs(resolvedType, { newWindow: openInNewWindow }),
);
} else {
// Heuristic fallback for commands not in the registry
const lower = envCommand.toLowerCase();
const isGui = HEURISTIC_GUI_COMMANDS.some((g) => lower.includes(g));
if (isGui && !lower.includes('--wait') && !lower.includes('-w')) {
args.unshift(lower.includes('subl') ? '-w' : '--wait');
}
}
}
}
if (!command) {
command = process.platform === 'win32' ? 'notepad' : 'vi';
}
const [executable = '', ...initialArgs] = command.split(' ');
// Determine if we should use sync or async based on the command/editor type.
// If we have a preferredEditorType, we can check if it's a terminal editor.
// Otherwise, we guess based on the command name.
const terminalEditors = [
'vi',
'vim',
'nvim',
'emacs',
'emacsclient',
'hx',
'nano',
'micro',
];
const isTerminal = preferredEditorType
? isTerminalEditor(preferredEditorType)
: terminalEditors.some((te) => executable.toLowerCase().includes(te));
if (
isTerminal &&
(executable.includes('vi') ||
executable.includes('vim') ||
executable.includes('nvim'))
) {
// Pass -i NONE to prevent E138 'Can't write viminfo file' errors in restricted environments.
args.unshift('-i', 'NONE');
}
const wasRaw = stdin?.isRaw ?? false;
setRawMode?.(false);
try {
if (isTerminal) {
const result = spawnSync(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
if (result.error) {
const spawnErr = result.error as NodeJS.ErrnoException;
coreEvents.emitFeedback(
'error',
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
return;
}
if (typeof result.status === 'number' && result.status !== 0) {
coreEvents.emitFeedback(
'error',
`External editor exited with status ${result.status}`,
);
return;
}
} else {
await new Promise<void>((resolve) => {
const child = spawn(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
child.on('error', (err) => {
const spawnErr = err as NodeJS.ErrnoException;
resolve();
coreEvents.emitFeedback(
'error',
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
});
child.on('close', (status) => {
resolve();
if (typeof status === 'number' && status !== 0) {
coreEvents.emitFeedback(
'error',
`External editor exited with status ${status}`,
);
}
});
});
}
} finally {
if (wasRaw) {
setRawMode?.(true);
}
coreEvents.emit(CoreEvent.ExternalEditorClosed);
}
}