import { spawn } from "node:child_process"; export type EditorOpenResult = | { ok: true; file: string; command: string } | { ok: false; file: string; error: string }; export interface EditorCandidate { command: string; args: string[]; wait: boolean; } export async function openEditableFile( file: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): Promise { for (const candidate of editorCandidates(file, env, platform)) { const ok = await trySpawn(candidate); if (ok) return { ok: true, file, command: candidate.command }; } return { ok: false, file, error: "No editor opener found" }; } export function editorCandidates( file: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): EditorCandidate[] { const configured = [env.VISUAL, env.EDITOR] .map((item) => parseEditorCommand(item ?? "")) .filter((item): item is { command: string; args: string[] } => Boolean(item), ) .map((item) => ({ ...item, args: [...item.args, file], wait: true })); return [...configured, ...platformCandidates(file, env, platform)]; } export function parseEditorCommand( input: string, ): { command: string; args: string[] } | undefined { const parts = splitCommandLine(input.trim()); const command = parts[0]; if (!command) return undefined; return { command, args: parts.slice(1) }; } function platformCandidates( file: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform, ): EditorCandidate[] { if (platform === "android" && env.TERMUX_VERSION) return [{ command: "termux-open", args: [file], wait: false }]; if (platform === "darwin") return [{ command: "open", args: ["-t", file], wait: false }]; if (platform === "win32") return [{ command: "notepad.exe", args: [file], wait: false }]; return [ { command: "xdg-open", args: [file], wait: false }, { command: "gio", args: ["open", file], wait: false }, { command: "wslview", args: [file], wait: false }, { command: "vi", args: [file], wait: true }, ]; } function trySpawn(candidate: EditorCandidate): Promise { return new Promise((resolve) => { const child = spawn(candidate.command, candidate.args, { detached: !candidate.wait, stdio: candidate.wait ? "inherit" : "ignore", windowsHide: !candidate.wait, }); let settled = false; const done = (ok: boolean) => { if (settled) return; settled = true; resolve(ok); }; child.once("error", () => done(false)); if (candidate.wait) child.once("close", (code) => done(code === 0)); else child.once("spawn", () => { child.unref(); done(true); }); }); } function splitCommandLine(input: string): string[] { const parts: string[] = []; let current = ""; let quote: "'" | '"' | undefined; for (let index = 0; index < input.length; index++) { const char = input[index]; if (char === "\\" && quote !== "'") { const next = input[index + 1]; if (next && (/\s/.test(next) || next === '"' || next === "'")) { current += next; index++; continue; } } if ((char === "'" || char === '"') && !quote) { quote = char; continue; } if (char === quote) { quote = undefined; continue; } if (/\s/.test(char) && !quote) { if (current) parts.push(current); current = ""; continue; } current += char; } if (current) parts.push(current); return parts; }