Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/the-system/open-editor.ts

Raw
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<EditorOpenResult> {
	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<boolean> {
	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;
}