fb75ba481d
The external editor flow (Ctrl+G, plan editor, /todo edit) warned 'No editor configured' on Windows because getEditorCommand() returned undefined whenever neither $VISUAL nor $EDITOR was set — the default state for most Windows shells. Fall back to 'notepad' on win32 after consulting $VISUAL/$EDITOR (always present in %SystemRoot%\\System32) and trim env values so accidentally padded strings still resolve. POSIX still returns undefined so the warning continues to nudge users to configure an editor. Fixes #2604
79 lines
2.5 KiB
TypeScript
79 lines
2.5 KiB
TypeScript
/**
|
|
* 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 a platform default.
|
|
*
|
|
* Resolution order:
|
|
* 1. `$VISUAL`
|
|
* 2. `$EDITOR`
|
|
* 3. `notepad` on Windows (always present in `%SystemRoot%\System32`)
|
|
*
|
|
* POSIX returns `undefined` when neither variable is set so the caller can
|
|
* surface a warning that nudges the user to configure one.
|
|
*/
|
|
export function getEditorCommand(): string | undefined {
|
|
const configured = $env.VISUAL?.trim() || $env.EDITOR?.trim();
|
|
if (configured) return configured;
|
|
if (process.platform === "win32") return "notepad";
|
|
return 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<string | null> {
|
|
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<number>();
|
|
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
|
|
}
|
|
}
|
|
}
|