/** * Utilities for launching an external text editor ($VISUAL / $EDITOR). */ import { spawn } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { $env, Snowflake } from "@oh-my-pi/pi-utils"; /** Returns the user's preferred editor command, or undefined if not configured. */ export function getEditorCommand(): string | undefined { return $env.VISUAL || $env.EDITOR || undefined; } export interface OpenInEditorOptions { /** File extension for the temp file (default: ".md"). */ extension?: string; /** Custom stdio configuration (default: all "inherit"). */ stdio?: [number | "inherit", number | "inherit", number | "inherit"]; /** Keep the file's trailing newline instead of trimming it from the returned text. */ trimTrailingNewline?: boolean; } /** * Opens `content` in the user's external editor and returns the edited text. * Returns `null` if the editor exits with a non-zero code. * * The caller is responsible for stopping/starting the TUI around this call. */ export async function openInEditor( editorCmd: string, content: string, options?: OpenInEditorOptions, ): Promise { const ext = options?.extension ?? ".md"; const tmpFile = path.join(os.tmpdir(), `omp-editor-${Snowflake.next()}${ext}`); try { await Bun.write(tmpFile, content); const [editor, ...editorArgs] = editorCmd.split(" "); const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"]; const child = spawn(editor, [...editorArgs, tmpFile], { stdio, shell: process.platform === "win32" }); const { promise, reject, resolve } = Promise.withResolvers(); child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0))); child.once("error", error => reject(error)); const exitCode = await promise; if (exitCode === 0) { const text = await Bun.file(tmpFile).text(); if (options?.trimTrailingNewline === false) { return text; } return text.replace(/\n$/, ""); } return null; } finally { try { await fs.rm(tmpFile, { force: true }); } catch { // Ignore cleanup errors } } }