Open workspace files in desktop targets

This commit is contained in:
Mohamed Boudra
2026-06-03 18:34:24 +08:00
committed by GitHub
parent 45f412456c
commit 3681fd132b
30 changed files with 1830 additions and 822 deletions
@@ -0,0 +1,459 @@
import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
listAvailableEditorTargets,
openEditorTarget,
registerEditorTargetHandlers,
} from "./editor-targets";
interface SpawnCall {
command: string;
args: string[];
options: SpawnOptions;
listenedForError: boolean;
listenedForSpawn: boolean;
unrefed: boolean;
}
function createExistsSync(paths: readonly string[]): (path: string) => boolean {
const availablePaths = new Set(paths);
return (path) => availablePaths.has(path);
}
function createSpawnRecorder() {
const calls: SpawnCall[] = [];
const spawn = (command: string, args: string[], options: SpawnOptions) => {
const call: SpawnCall = {
command,
args,
options,
listenedForError: false,
listenedForSpawn: false,
unrefed: false,
};
calls.push(call);
const child = {
once: (event: "error" | "spawn", handler: (error?: Error) => void) => {
if (event === "error") {
call.listenedForError = true;
}
if (event === "spawn") {
call.listenedForSpawn = true;
queueMicrotask(() => handler());
}
return child;
},
unref: () => {
call.unrefed = true;
},
};
return child;
};
return { calls, spawn };
}
describe("desktop editor targets", () => {
it("lists all known editor targets for the platform in deterministic order", () => {
const targets = listAvailableEditorTargets({
platform: "win32",
env: { PATH: "C:/bin" },
existsSync: createExistsSync([
"C:/bin/cursor.exe",
"C:/bin/code.exe",
"C:/bin/webstorm.exe",
"C:/bin/zed.exe",
"C:/bin/explorer.exe",
]),
});
expect(targets).toEqual([
{ id: "cursor", label: "Cursor", kind: "editor" },
{ id: "vscode", label: "VS Code", kind: "editor" },
{ id: "webstorm", label: "WebStorm", kind: "editor" },
{ id: "zed", label: "Zed", kind: "editor" },
{ id: "explorer", label: "Explorer", kind: "file-manager" },
]);
});
it("lists Finder on macOS", () => {
const targets = listAvailableEditorTargets({
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/usr/bin/open"]),
});
expect(targets).toEqual([{ id: "finder", label: "Finder", kind: "file-manager" }]);
});
it("lists the generic file manager on Linux", () => {
const targets = listAvailableEditorTargets({
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/usr/bin/xdg-open"]),
});
expect(targets).toEqual([{ id: "file-manager", label: "File Manager", kind: "file-manager" }]);
});
it("can list future custom script targets without changing bridge types", () => {
const targets = listAvailableEditorTargets({
platform: "linux",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync(["/usr/local/bin/open-in-nvim"]),
targetDefinitions: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
command: "open-in-nvim",
},
],
});
expect(targets).toEqual([
{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" },
]);
});
it("launches editors as detached external processes", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/local/bin", ELECTRON_RUN_AS_NODE: "1" },
existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls).toEqual([
{
command: "/usr/local/bin/code",
args: ["/tmp/repo"],
options: {
detached: true,
env: { PATH: "/usr/local/bin" },
shell: false,
stdio: "ignore",
},
listenedForError: true,
listenedForSpawn: true,
unrefed: true,
},
]);
});
it("reveals files in Finder on macOS", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "finder", path: "/tmp/repo/src/index.ts", mode: "reveal" },
{
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/tmp/repo/src/index.ts", "/usr/bin/open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.command).toBe("/usr/bin/open");
expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]);
});
it("reveals files in Explorer on Windows", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/repo/src/index.ts", mode: "reveal" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["C:/repo/src/index.ts", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]);
expect(recorder.calls[0]?.options.shell).toBe(false);
});
it("keeps Windows shell metacharacters literal for direct executables", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/repo/src/file & calculator.ts", mode: "reveal" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync([
"C:/repo/src/file & calculator.ts",
"C:/Windows/explorer.exe",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: "C:/Windows/explorer.exe",
args: ["/select,", "C:/repo/src/file & calculator.ts"],
options: { shell: false },
});
});
it("quotes Windows command-script paths without corrupting metacharacters", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo/src/file & calculator.ts",
cwd: "C:/repo & workspace",
},
{
platform: "win32",
env: { PATH: "C:/Program Files/Editors & Tools/bin" },
existsSync: createExistsSync([
"C:/repo/src/file & calculator.ts",
"C:/repo & workspace",
"C:/Program Files/Editors & Tools/bin/code.cmd",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: '"C:/Program Files/Editors & Tools/bin/code.cmd"',
args: ['"C:/repo & workspace"', '"C:/repo/src/file & calculator.ts"'],
options: { shell: true },
});
});
it("quotes Windows command-script values that contain shell metacharacters", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo/src/file&calculator.ts",
},
{
platform: "win32",
env: { PATH: "C:/Editors&Tools/bin" },
existsSync: createExistsSync([
"C:/repo/src/file&calculator.ts",
"C:/Editors&Tools/bin/code.cmd",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: '"C:/Editors&Tools/bin/code.cmd"',
args: ['"C:/repo/src/file&calculator.ts"'],
options: { shell: true },
});
});
it("reveals Linux files by opening the containing folder", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "file-manager", path: "/home/user/repo/src/index.ts", mode: "reveal" },
{
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/xdg-open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src"]);
});
it("ignores reveal mode for editor targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/home/user/repo/src/index.ts", mode: "reveal" },
{
platform: "linux",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/code"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src/index.ts"]);
});
it("opens the workspace folder alongside the file for editor targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo/src/index.ts",
"/tmp/repo",
"/usr/local/bin/code",
]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo", "/tmp/repo/src/index.ts"]);
});
it("can launch future custom script targets by string id", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "script:open-in-nvim", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" },
{
platform: "linux",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo/src/index.ts",
"/tmp/repo",
"/usr/local/bin/open-in-nvim",
]),
spawn: recorder.spawn,
targetDefinitions: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
command: "open-in-nvim",
},
],
},
);
expect(recorder.calls[0]).toMatchObject({
command: "/usr/local/bin/open-in-nvim",
args: ["/tmp/repo", "/tmp/repo/src/index.ts"],
options: { shell: false },
});
});
it("does not prepend invalid, equal, relative, or missing cwd values", async () => {
const inputs = [
{ editorId: "vscode", path: "/tmp/repo", cwd: "/tmp/repo" },
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "repo" },
{ editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/missing" },
];
for (const input of inputs) {
const recorder = createSpawnRecorder();
await openEditorTarget(input, {
platform: "darwin",
env: { PATH: "/usr/local/bin" },
existsSync: createExistsSync([
"/tmp/repo",
"/tmp/repo/src/index.ts",
"/usr/local/bin/code",
]),
spawn: recorder.spawn,
});
expect(recorder.calls[0]?.args).toEqual([input.path]);
}
});
it("does not prepend cwd for file-manager targets", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "finder", path: "/tmp/repo/src", cwd: "/tmp/repo" },
{
platform: "darwin",
env: { PATH: "/usr/bin" },
existsSync: createExistsSync(["/tmp/repo/src", "/tmp/repo", "/usr/bin/open"]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo/src"]);
});
it("rejects relative and missing paths", async () => {
await expect(
openEditorTarget(
{ editorId: "cursor", path: "repo" },
{ existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target path must be an absolute local path");
await expect(
openEditorTarget(
{ editorId: "cursor", path: "/tmp/repo" },
{ existsSync: () => false, env: { PATH: "/bin" } },
),
).rejects.toThrow("Path does not exist: /tmp/repo");
});
it("rejects unsupported, unknown, and missing executable targets", async () => {
await expect(
openEditorTarget(
{ editorId: "finder", path: "/tmp/repo" },
{ platform: "linux", existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target unavailable: Finder");
await expect(
openEditorTarget(
{ editorId: "unknown-editor", path: "/tmp/repo" },
{ existsSync: () => true, env: { PATH: "/bin" } },
),
).rejects.toThrow("Unknown editor target: unknown-editor");
await expect(
openEditorTarget(
{ editorId: "vscode", path: "/tmp/repo" },
{ existsSync: (path) => path === "/tmp/repo", env: { PATH: "/bin" } },
),
).rejects.toThrow("Editor target unavailable: VS Code");
});
it("runs list and open behavior through registered IPC handlers", async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
const ipc = {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, listener);
},
};
const recorder = createSpawnRecorder();
registerEditorTargetHandlers({
ipc,
dependencies: {
platform: "darwin",
env: { PATH: "/usr/local/bin:/usr/bin" },
existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code", "/usr/bin/open"]),
spawn: recorder.spawn,
},
});
const listHandler = handlers.get("paseo:editor:listTargets");
const openHandler = handlers.get("paseo:editor:openTarget");
if (!listHandler || !openHandler) {
throw new Error("editor IPC handlers were not registered");
}
expect(listHandler({})).toEqual([
{ id: "vscode", label: "VS Code", kind: "editor" },
{ id: "finder", label: "Finder", kind: "file-manager" },
]);
await openHandler({}, { editorId: "vscode", path: "/tmp/repo" });
await expect(openHandler({}, { editorId: "vscode", path: "repo" })).rejects.toThrow(
"Editor target path must be an absolute local path",
);
expect(recorder.calls[0]?.args).toEqual(["/tmp/repo"]);
});
});
@@ -0,0 +1,353 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { existsSync as nodeExistsSync } from "node:fs";
import { posix, win32 } from "node:path";
import { ipcMain } from "electron";
import { z } from "zod";
type EditorTargetKind = "editor" | "file-manager";
type OpenEditorMode = "open" | "reveal";
interface EditorTargetDefinition {
id: string;
label: string;
kind: EditorTargetKind;
command: string;
platforms?: readonly NodeJS.Platform[];
excludedPlatforms?: readonly NodeJS.Platform[];
}
export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: EditorTargetKind;
}
export interface OpenEditorTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: OpenEditorMode;
}
interface ListEditorTargetsDependencies {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
existsSync?: (path: string) => boolean;
targetDefinitions?: readonly EditorTargetDefinition[];
}
interface SpawnedProcess {
once(event: "error", handler: (error: Error) => void): SpawnedProcess;
once(event: "spawn", handler: () => void): SpawnedProcess;
unref(): void;
}
interface OpenEditorTargetDependencies extends ListEditorTargetsDependencies {
spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess;
}
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
interface Launch {
command: string;
args: string[];
}
interface SpawnLaunch extends Launch {
shell: boolean;
}
const RUNTIME_CONTROL_ENV_KEYS = [
"PASEO_NODE_ENV",
"PASEO_DESKTOP_MANAGED",
"PASEO_SUPERVISED",
"ELECTRON_RUN_AS_NODE",
"ELECTRON_NO_ATTACH_CONSOLE",
] as const;
const BUILT_IN_EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", kind: "editor", command: "cursor" },
{ id: "vscode", label: "VS Code", kind: "editor", command: "code" },
{ id: "webstorm", label: "WebStorm", kind: "editor", command: "webstorm" },
{ id: "zed", label: "Zed", kind: "editor", command: "zed" },
{
id: "finder",
label: "Finder",
kind: "file-manager",
command: "open",
platforms: ["darwin"],
},
{
id: "explorer",
label: "Explorer",
kind: "file-manager",
command: "explorer",
platforms: ["win32"],
},
{
id: "file-manager",
label: "File Manager",
kind: "file-manager",
command: "xdg-open",
excludedPlatforms: ["darwin", "win32"],
},
];
const OpenEditorTargetInputSchema = z.object({
editorId: z.string().trim().min(1),
path: z.string().trim().min(1),
cwd: z.string().trim().min(1).optional(),
mode: z.enum(["open", "reveal"]).optional(),
});
function isTargetSupportedOnPlatform(
target: EditorTargetDefinition,
platform: NodeJS.Platform,
): boolean {
if (target.platforms && !target.platforms.includes(platform)) {
return false;
}
if (target.excludedPlatforms?.includes(platform)) {
return false;
}
return true;
}
function createExternalProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
for (const key of RUNTIME_CONTROL_ENV_KEYS) {
delete env[key];
}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete env[key];
}
}
return env;
}
function resolveExecutable(
command: string,
input: {
env: NodeJS.ProcessEnv;
existsSync: (path: string) => boolean;
platform: NodeJS.Platform;
},
): string | null {
if (isAbsolutePath(command, input.platform) && input.existsSync(command)) {
return command;
}
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
for (const directory of pathValue.split(pathDelimiter)) {
if (!directory) {
continue;
}
const candidate = `${directory}/${command}`;
if (input.existsSync(candidate)) {
return candidate;
}
if (input.platform === "win32") {
for (const extension of [".exe", ".cmd"]) {
const windowsCandidate = `${candidate}${extension}`;
if (input.existsSync(windowsCandidate)) {
return windowsCandidate;
}
}
}
}
return null;
}
function resolveTargetDefinitions(
dependencies: ListEditorTargetsDependencies,
): readonly EditorTargetDefinition[] {
return dependencies.targetDefinitions ?? BUILT_IN_EDITOR_TARGETS;
}
function findTarget(
targetId: string,
targetDefinitions: readonly EditorTargetDefinition[],
): EditorTargetDefinition {
const target = targetDefinitions.find((entry) => entry.id === targetId);
if (!target) {
throw new Error(`Unknown editor target: ${targetId}`);
}
return target;
}
function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function dirnameForPlatform(value: string, platform: NodeJS.Platform): string {
return platform === "win32" ? win32.dirname(value) : posix.dirname(value);
}
function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean {
if (platform !== "win32") {
return false;
}
const extension = win32.extname(executable).toLowerCase();
return extension === ".cmd" || extension === ".bat";
}
function escapeWindowsCmdValue(value: string): string {
const isQuoted = value.startsWith('"') && value.endsWith('"');
const unquoted = isQuoted ? value.slice(1, -1) : value;
if (isQuoted || /[\s"&|^<>()!]/u.test(unquoted)) {
const quoted = unquoted
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
return `"${quoted}"`;
}
return unquoted;
}
function createSpawnLaunch(launch: Launch, platform: NodeJS.Platform): SpawnLaunch {
if (!isWindowsCommandScript(launch.command, platform)) {
return { ...launch, shell: false };
}
return {
command: escapeWindowsCmdValue(launch.command),
args: launch.args.map(escapeWindowsCmdValue),
shell: true,
};
}
function buildLaunch(input: {
target: EditorTargetDefinition;
path: string;
cwd?: string;
mode: OpenEditorMode;
platform: NodeJS.Platform;
executable: string;
}): Launch {
if (input.mode === "reveal") {
if (input.target.id === "finder" && input.platform === "darwin") {
return { command: input.executable, args: ["-R", input.path] };
}
if (input.target.id === "explorer" && input.platform === "win32") {
return { command: input.executable, args: ["/select,", input.path] };
}
if (input.target.id === "file-manager") {
return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] };
}
}
if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) {
return { command: input.executable, args: [input.cwd, input.path] };
}
return { command: input.executable, args: [input.path] };
}
function spawnDetachedProcess(
command: string,
args: string[],
options: SpawnOptions,
): SpawnedProcess {
return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess;
}
export function listAvailableEditorTargets(
dependencies: ListEditorTargetsDependencies = {},
): DesktopEditorTargetDescriptor[] {
const platform = dependencies.platform ?? process.platform;
const existsSync = dependencies.existsSync ?? nodeExistsSync;
const env = dependencies.env ?? process.env;
const targetDefinitions = resolveTargetDefinitions(dependencies);
return targetDefinitions
.filter((target) => isTargetSupportedOnPlatform(target, platform))
.filter((target) => resolveExecutable(target.command, { platform, env, existsSync }))
.map((target) => ({ id: target.id, label: target.label, kind: target.kind }));
}
export async function openEditorTarget(
input: OpenEditorTargetInput,
dependencies: OpenEditorTargetDependencies = {},
): Promise<void> {
const parsedInput = OpenEditorTargetInputSchema.parse(input);
const platform = dependencies.platform ?? process.platform;
const existsSync = dependencies.existsSync ?? nodeExistsSync;
const env = dependencies.env ?? process.env;
const spawn = dependencies.spawn ?? spawnDetachedProcess;
const pathToOpen = parsedInput.path;
if (!isAbsolutePath(pathToOpen, platform)) {
throw new Error("Editor target path must be an absolute local path");
}
if (!existsSync(pathToOpen)) {
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const target = findTarget(parsedInput.editorId, resolveTargetDefinitions(dependencies));
if (!isTargetSupportedOnPlatform(target, platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = resolveExecutable(target.command, { platform, env, existsSync });
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const workspaceCwd =
parsedInput.cwd &&
isAbsolutePath(parsedInput.cwd, platform) &&
parsedInput.cwd !== pathToOpen &&
existsSync(parsedInput.cwd)
? parsedInput.cwd
: undefined;
const launch = buildLaunch({
target,
path: pathToOpen,
cwd: workspaceCwd,
mode: parsedInput.mode ?? "open",
platform,
executable,
});
const spawnLaunch = createSpawnLaunch(launch, platform);
await new Promise<void>((resolve, reject) => {
let child: SpawnedProcess;
try {
child = spawn(spawnLaunch.command, spawnLaunch.args, {
detached: true,
env: createExternalProcessEnv(env),
shell: spawnLaunch.shell,
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
}
export function registerEditorTargetHandlers(
options: {
ipc?: IpcHandlerRegistry;
dependencies?: OpenEditorTargetDependencies;
} = {},
): void {
const ipc = options.ipc ?? ipcMain;
const dependencies = options.dependencies ?? {};
ipc.handle("paseo:editor:listTargets", () => listAvailableEditorTargets(dependencies));
ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => {
const parsedInput = OpenEditorTargetInputSchema.parse(payload);
await openEditorTarget(parsedInput, dependencies);
});
}