Open files in more installed editors (#2119)

* feat(desktop): open files in more installed editors

Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches.

* fix(desktop): preserve editor target compatibility

Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
This commit is contained in:
Mohamed Boudra
2026-07-16 14:39:55 +02:00
committed by GitHub
parent 3e8dce7d7c
commit f4509fe044
50 changed files with 1665 additions and 1051 deletions

View File

@@ -80,13 +80,15 @@ interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
}
interface DesktopEditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
export interface ConfirmDialogCall {

View File

@@ -6,9 +6,10 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings";
interface EditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
function requireE2EEnv(name: string): string {
@@ -55,7 +56,9 @@ async function expectEditorOpened(input: {
const records = await readEditorOpenRecords(input.recordPath);
return records
.slice(input.afterCount)
.some((record) => record.editorId === input.editorId && record.path === input.path);
.some(
(record) => record.editorId === input.editorId && record.workspacePath === input.path,
);
},
{ timeout: 30_000 },
)
@@ -75,8 +78,18 @@ test.describe("Workspace open in editor", () => {
await injectDesktopBridge(page, {
serverId,
editorTargets: [
{ id: "cursor", label: "Cursor", kind: "editor" },
{ id: "vscode", label: "VS Code", kind: "editor" },
{
id: "cursor",
label: "Cursor",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
{
id: "vscode",
label: "VS Code",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
],
editorRecordPath: recordPath,
});

View File

@@ -1,37 +0,0 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
interface EditorAppIconProps {
editorId: EditorTargetId;
size?: number;
color?: string;
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
antigravity: require("../../../assets/images/editor-apps/antigravity.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
const source = EDITOR_APP_IMAGES[editorId];
if (!source) {
return <SquareTerminal size={size} color={color} />;
}
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}

View File

@@ -0,0 +1,27 @@
import { Folder, SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image } from "react-native";
import type { DesktopOpenTargetIcon } from "@/workspace/desktop-open-targets";
interface EditorTargetIconProps {
icon: DesktopOpenTargetIcon;
size?: number;
color?: string;
}
export function EditorTargetIcon({ icon, size = 16, color }: EditorTargetIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
const imageSource = useMemo(
() => (icon.kind === "image" ? { uri: icon.dataUrl } : undefined),
[icon],
);
if (imageSource) {
return <Image source={imageSource} style={imageStyle} resizeMode="contain" />;
}
if (icon.kind === "symbol" && icon.name === "folder") {
return <Folder size={size} color={color} />;
}
return <SquareTerminal size={size} color={color} />;
}

View File

@@ -66,13 +66,15 @@ export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
export interface DesktopEditorBridge {

View File

@@ -1,7 +1,8 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { EditorTargetId } from "@/workspace/editor-targets";
type EditorTargetId = string;
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];

View File

@@ -10,7 +10,7 @@ import {
import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import { EditorTargetIcon } from "@/components/icons/editor-target-icon";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
DropdownMenu,
@@ -46,7 +46,7 @@ interface OpenTarget {
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheckIcon = withUnistyles(Check);
@@ -134,7 +134,9 @@ export function WorkspaceOpenInEditorButton({
return {
id: target.id,
label: target.label,
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
icon: (
<ThemedEditorTargetIcon icon={target.icon} size={16} uniProps={mutedColorMapping} />
),
onOpen: () => openDesktopTarget(target.openInput),
};
}),

View File

@@ -2,19 +2,23 @@ import { useQuery } from "@tanstack/react-query";
import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host";
export type DesktopOpenTargetKind = "editor" | "file-manager";
export type DesktopOpenMode = "open" | "reveal";
export type DesktopOpenTargetIcon =
| { kind: "image"; dataUrl: string }
| { kind: "symbol"; name: "folder" | "terminal" };
export interface DesktopOpenTarget {
id: string;
label: string;
kind: DesktopOpenTargetKind;
icon: DesktopOpenTargetIcon;
}
export interface OpenDesktopTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: DesktopOpenMode;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
interface AvailableDesktopEditorBridge {

View File

@@ -1,11 +0,0 @@
import { describe, expect, it } from "vitest";
import { isKnownEditorTargetId } from "./editor-targets";
describe("editor target ids", () => {
it("recognizes built-ins without typing custom ids out of the system", () => {
const customEditorId: string = "script:open-in-nvim";
expect(isKnownEditorTargetId("vscode")).toBe(true);
expect(isKnownEditorTargetId(customEditorId)).toBe(false);
});
});

View File

@@ -1,16 +0,0 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"antigravity",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}

View File

@@ -2,8 +2,18 @@ import { describe, expect, it } from "vitest";
import { planWorkspaceOpenTargets } from "./open-target-planner";
const desktopTargets = [
{ id: "vscode", label: "VS Code", kind: "editor" as const },
{ id: "finder", label: "Finder", kind: "file-manager" as const },
{
id: "vscode",
label: "VS Code",
kind: "editor" as const,
icon: { kind: "symbol" as const, name: "terminal" as const },
},
{
id: "finder",
label: "Finder",
kind: "file-manager" as const,
icon: { kind: "symbol" as const, name: "folder" as const },
},
];
const checkoutStatus = {
@@ -25,7 +35,12 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
openInput: {
editorId: "vscode",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
line: 3,
},
});
});
@@ -41,7 +56,11 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
openInput: {
editorId: "finder",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
},
});
});
@@ -56,12 +75,12 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo" },
openInput: { editorId: "vscode", workspacePath: "/repo" },
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo" },
openInput: { editorId: "finder", workspacePath: "/repo" },
});
});
@@ -69,7 +88,14 @@ describe("planWorkspaceOpenTargets", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
desktopTargets: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
],
canUseDesktopBridge: true,
isLocalExecution: true,
});
@@ -80,10 +106,11 @@ describe("planWorkspaceOpenTargets", () => {
id: "script:open-in-nvim",
label: "Open in Neovim",
editorId: "script:open-in-nvim",
icon: { kind: "symbol", name: "terminal" },
openInput: {
editorId: "script:open-in-nvim",
path: "/repo/src/app.ts",
cwd: "/repo",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
},
},
]);

View File

@@ -17,6 +17,7 @@ export interface PlannedDesktopOpenTarget {
id: string;
label: string;
editorId: string;
icon: DesktopOpenTarget["icon"];
openInput: OpenDesktopTargetInput;
}
@@ -58,6 +59,7 @@ function resolveActiveFileForOpenTargets(
function planDesktopOpenTargets(input: {
workspaceDirectory: string;
activeFile?: WorkspaceFileLocation | null;
resolvedFile: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
@@ -74,20 +76,8 @@ function planDesktopOpenTargets(input: {
id: target.id,
label: target.label,
editorId: target.id,
openInput: { editorId: target.id, path: input.workspaceDirectory },
};
}
if (target.kind === "editor") {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
cwd: input.workspaceDirectory,
},
icon: target.icon,
openInput: { editorId: target.id, workspacePath: input.workspaceDirectory },
};
}
return {
@@ -95,10 +85,12 @@ function planDesktopOpenTargets(input: {
id: target.id,
label: target.label,
editorId: target.id,
icon: target.icon,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
mode: "reveal",
workspacePath: input.workspaceDirectory,
filePath: input.resolvedFile.absolutePath,
...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}),
},
};
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -21,6 +21,8 @@ extraResources:
to: app-dist
- from: ../../skills
to: skills
- from: assets/editor-targets
to: editor-target-icons
publish:
provider: github
owner: getpaseo

View File

@@ -1,568 +0,0 @@
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("opens the workspace directory in Explorer using Windows path separators", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "C:/Users/me/project" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["C:/Users/me/project", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
// explorer.exe reads each "/segment" of a forward-slash arg as a switch and
// falls back to the Documents folder. The path must use backslashes.
expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe");
expect(recorder.calls[0]?.args).toEqual(["C:\\Users\\me\\project"]);
expect(recorder.calls[0]?.options.shell).toBe(false);
});
it("opens UNC workspace directories in Explorer using Windows path separators", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "explorer", path: "//server/share/project" },
{
platform: "win32",
env: { PATH: "C:/Windows" },
existsSync: createExistsSync(["//server/share/project", "C:/Windows/explorer.exe"]),
spawn: recorder.spawn,
},
);
// UNC paths must become \\server\share\... — the leading // is preserved by
// the app's path normalization and explorer accepts the backslash form.
expect(recorder.calls[0]?.args).toEqual(["\\\\server\\share\\project"]);
});
it("reveals files in Explorer on Windows using Windows path separators", 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,
},
);
// Separators flip to backslashes; "&" stays literal and the path stays a
// separate arg token (Node only quotes the path, not the "/select," switch).
expect(recorder.calls[0]).toMatchObject({
command: "C:/Windows/explorer.exe",
args: ["/select,", "C:\\repo\\src\\file & calculator.ts"],
options: { shell: false },
});
});
it("does not convert separators for editor targets on Windows", async () => {
const recorder = createSpawnRecorder();
await openEditorTarget(
{ editorId: "vscode", path: "C:/repo/src/index.ts", cwd: "C:/repo" },
{
platform: "win32",
env: { PATH: "C:/Program Files/Microsoft VS Code/bin" },
existsSync: createExistsSync([
"C:/repo/src/index.ts",
"C:/repo",
"C:/Program Files/Microsoft VS Code/bin/code.exe",
]),
spawn: recorder.spawn,
},
);
// Editors accept forward slashes; the backslash conversion is Explorer-scoped.
expect(recorder.calls[0]?.args).toEqual(["C:/repo", "C:/repo/src/index.ts"]);
});
it("prefers Windows command shims over extensionless shell launchers", async () => {
const recorder = createSpawnRecorder();
const vscodeBin = "C:/Users/me/AppData/Local/Programs/Microsoft VS Code/bin";
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo",
},
{
platform: "win32",
env: { PATH: vscodeBin },
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`, `${vscodeBin}/code.cmd`]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: `"${vscodeBin}/code.cmd"`,
args: ["C:/repo"],
options: { shell: true },
});
});
it("keeps the Windows extensionless fallback when no command shim exists", async () => {
const recorder = createSpawnRecorder();
const vscodeBin = "C:/Portable/VS Code/bin";
await openEditorTarget(
{
editorId: "vscode",
path: "C:/repo",
},
{
platform: "win32",
env: { PATH: vscodeBin },
existsSync: createExistsSync(["C:/repo", `${vscodeBin}/code`]),
spawn: recorder.spawn,
},
);
expect(recorder.calls[0]).toMatchObject({
command: `${vscodeBin}/code`,
args: ["C:/repo"],
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"]);
});
});

View File

@@ -1,370 +0,0 @@
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: "antigravity", label: "Antigravity", kind: "editor", command: "antigravity" },
{
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.platform === "win32") {
const hasExtension = Boolean(win32.extname(command));
const extensions = hasExtension ? [""] : [".exe", ".cmd", ".bat", ".com", ""];
for (const extension of extensions) {
const windowsCandidate = `${candidate}${extension}`;
if (input.existsSync(windowsCandidate)) {
return windowsCandidate;
}
}
continue;
}
if (input.existsSync(candidate)) {
return candidate;
}
}
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);
}
// App paths are normalized to forward slashes everywhere (see file-open), but
// explorer.exe parses each "/segment" of an argument as a command-line switch.
// Given a POSIX-style path it finds no path token and silently falls back to
// the default shell folder (the user's Documents). Hand it native backslashes.
// Only the argument needs converting — CreateProcess resolves the command path
// fine with forward slashes.
function toWindowsPathSeparators(value: string): string {
return value.replace(/\//g, "\\");
}
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,", toWindowsPathSeparators(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] };
}
if (input.target.id === "explorer" && input.platform === "win32") {
return { command: input.executable, args: [toWindowsPathSeparators(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);
});
}

View File

@@ -0,0 +1,37 @@
import { ipcMain } from "electron";
import { z } from "zod";
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
import { createEditorTargetRuntime } from "./runtime.js";
import type { EditorTarget, EditorTargetRuntime } from "./target.js";
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
const EditorTargetLaunchInputSchema = z.object({
editorId: z.string().trim().min(1),
workspacePath: z.string().trim().min(1),
filePath: z.string().trim().min(1).optional(),
line: z.number().int().positive().optional(),
column: z.number().int().positive().optional(),
});
export function registerEditorTargetHandlers(
options: {
ipc?: IpcHandlerRegistry;
runtime?: EditorTargetRuntime;
targets?: readonly EditorTarget[];
} = {},
): void {
const ipc = options.ipc ?? ipcMain;
const runtime = options.runtime ?? createEditorTargetRuntime();
ipc.handle("paseo:editor:listTargets", () =>
listAvailableEditorTargets(runtime, options.targets),
);
ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => {
const input = EditorTargetLaunchInputSchema.parse(payload);
await openEditorTarget(input, runtime, options.targets);
});
}

View File

@@ -0,0 +1,292 @@
import { describe, expect, it } from "vitest";
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js";
import { cursorTarget } from "./targets/cursor.js";
import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js";
import { intellijIdeaTarget } from "./targets/intellij-idea.js";
import { pycharmTarget } from "./targets/pycharm.js";
import { vscodeTarget } from "./targets/vscode.js";
import { webstormTarget } from "./targets/webstorm.js";
import { zedTarget } from "./targets/zed.js";
interface RecordedLaunch {
command: string;
args: string[];
}
class FakeEditorTargets implements EditorTargetRuntime {
readonly launches: RecordedLaunch[] = [];
readonly openedPaths: string[] = [];
readonly revealedPaths: string[] = [];
readonly openedMacApplications: Array<{
applicationName: string;
paths: string[];
}> = [];
readonly env: NodeJS.ProcessEnv;
readonly platform: NodeJS.Platform;
private readonly paths = new Set<string>();
private readonly commands = new Map<string, string>();
private readonly macApplications = new Set<string>();
constructor(platform: NodeJS.Platform = "linux", env: NodeJS.ProcessEnv = {}) {
this.platform = platform;
this.env = env;
}
addPath(targetPath: string): void {
this.paths.add(targetPath);
}
installCommand(command: string, executable = `/bin/${command}`): void {
this.commands.set(command, executable);
}
installMacApplication(applicationName: string): void {
this.macApplications.add(applicationName);
}
pathExists(targetPath: string): boolean {
return this.paths.has(targetPath);
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
resolveCommand(commands: readonly string[]): string | null {
for (const command of commands) {
const executable = this.commands.get(command);
if (executable) return executable;
}
return null;
}
async spawnDetached(input: { command: string; args: readonly string[] }): Promise<void> {
this.launches.push({ command: input.command, args: [...input.args] });
}
async openPath(targetPath: string): Promise<void> {
this.openedPaths.push(targetPath);
}
revealPath(targetPath: string): void {
this.revealedPaths.push(targetPath);
}
async loadIcon(fileName: string): Promise<EditorTargetIcon> {
return { kind: "image", dataUrl: `data:image/png;base64,${fileName}` };
}
hasMacApplication(applicationName: string): boolean {
return this.macApplications.has(applicationName);
}
async openMacApplication(input: {
applicationName: string;
paths: readonly string[];
}): Promise<void> {
this.openedMacApplications.push({
applicationName: input.applicationName,
paths: [...input.paths],
});
}
}
describe("editor target registry", () => {
it("lists installed target implementations in registration order", async () => {
const runtime = new FakeEditorTargets();
runtime.installCommand("code");
runtime.installCommand("webstorm");
const targets = await listAvailableEditorTargets(runtime, [
cursorTarget,
vscodeTarget,
webstormTarget,
fileManagerTarget,
]);
expect(targets).toEqual([
{
id: "vscode",
label: "VS Code",
kind: "editor",
icon: { kind: "image", dataUrl: "data:image/png;base64,vscode.png" },
},
{
id: "webstorm",
label: "WebStorm",
kind: "editor",
icon: { kind: "image", dataUrl: "data:image/png;base64,webstorm.png" },
},
{
id: "file-manager",
label: "Files",
kind: "file-manager",
icon: { kind: "symbol", name: "folder" },
},
]);
});
it("opens a selected file at its position through the target implementation", async () => {
const runtime = new FakeEditorTargets();
runtime.installCommand("code");
runtime.addPath("/repo");
runtime.addPath("/repo/src/app.ts");
await openEditorTarget(
{
editorId: "vscode",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
line: 12,
column: 4,
},
runtime,
[vscodeTarget],
);
expect(runtime.launches).toEqual([
{
command: "/bin/code",
args: ["/repo", "--goto", "/repo/src/app.ts:12:4"],
},
]);
});
it("lets each target choose its own command and arguments", async () => {
const runtime = new FakeEditorTargets();
runtime.installCommand("zeditor");
runtime.installCommand("webstorm");
runtime.installCommand("idea");
await zedTarget.launch(
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 7, column: 2 },
runtime,
);
await webstormTarget.launch(
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 7, column: 2 },
runtime,
);
await intellijIdeaTarget.launch({ workspacePath: "/repo" }, runtime);
expect(runtime.launches).toEqual([
{ command: "/bin/zeditor", args: ["/repo", "/repo/src/app.ts:7:2"] },
{
command: "/bin/webstorm",
args: ["--line", "7", "--column", "2", "/repo", "/repo/src/app.ts"],
},
{ command: "/bin/idea", args: ["/repo"] },
]);
});
it("recognizes Windows 64-bit project IDE launchers", async () => {
const runtime = new FakeEditorTargets("win32");
runtime.installCommand("pycharm64", "C:/Tools/PyCharm/bin/pycharm64.exe");
expect(await pycharmTarget.isInstalled(runtime)).toBe(true);
await pycharmTarget.launch(
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.py", line: 6 },
runtime,
);
expect(runtime.launches).toEqual([
{
command: "C:/Tools/PyCharm/bin/pycharm64.exe",
args: ["--line", "6", "C:/repo", "C:/repo/src/app.py"],
},
]);
});
it("detects and launches the macOS application when the command is absent", async () => {
const runtime = new FakeEditorTargets("darwin");
runtime.installMacApplication("Cursor");
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
await cursorTarget.launch({ workspacePath: "/repo", filePath: "/repo/src/app.ts" }, runtime);
expect(runtime.openedMacApplications).toEqual([
{
applicationName: "Cursor",
paths: ["/repo", "/repo/src/app.ts"],
},
]);
});
it("uses Cursor's bundled macOS command so file positions survive application detection", async () => {
const runtime = new FakeEditorTargets("darwin");
const bundledCommand = "/Applications/Cursor.app/Contents/Resources/app/bin/cursor";
runtime.installCommand(bundledCommand, bundledCommand);
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
await cursorTarget.launch(
{ workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 18, column: 3 },
runtime,
);
expect(runtime.launches).toEqual([
{
command: bundledCommand,
args: ["/repo", "--goto", "/repo/src/app.ts:18:3"],
},
]);
});
it("detects Cursor's installed Windows command when it is absent from PATH", async () => {
const runtime = new FakeEditorTargets("win32", {
LOCALAPPDATA: "C:/Users/me/AppData/Local",
});
const installedCommand =
"C:/Users/me/AppData/Local/Programs/cursor/resources/app/bin/cursor.cmd";
runtime.installCommand(installedCommand, installedCommand);
expect(await cursorTarget.isInstalled(runtime)).toBe(true);
await cursorTarget.launch(
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.ts", line: 9 },
runtime,
);
expect(runtime.launches).toEqual([
{
command: installedCommand,
args: ["C:/repo", "--goto", "C:/repo/src/app.ts:9"],
},
]);
});
it("delegates folder opening and file reveal to the system file manager", async () => {
const runtime = new FakeEditorTargets("win32");
expect(await explorerTarget.describe(runtime)).toEqual({
id: "explorer",
label: "Explorer",
kind: "file-manager",
icon: { kind: "symbol", name: "folder" },
});
await explorerTarget.launch({ workspacePath: "C:/repo" }, runtime);
await explorerTarget.launch(
{ workspacePath: "C:/repo", filePath: "C:/repo/src/app.ts" },
runtime,
);
expect(runtime.openedPaths).toEqual(["C:/repo"]);
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
explorerTarget,
fileManagerTarget,
]);
const windowsTargets = await listAvailableEditorTargets(new FakeEditorTargets("win32"), [
finderTarget,
explorerTarget,
fileManagerTarget,
]);
expect(macTargets.map((target) => target.id)).toEqual(["finder"]);
expect(windowsTargets.map((target) => target.id)).toEqual(["explorer"]);
});
});

View File

@@ -0,0 +1,103 @@
import type {
EditorTarget,
EditorTargetDescriptor,
EditorTargetLaunchInput,
EditorTargetRuntime,
} from "./target.js";
import { antigravityTarget } from "./targets/antigravity.js";
import { aquaTarget } from "./targets/aqua.js";
import { clionTarget } from "./targets/clion.js";
import { cursorTarget } from "./targets/cursor.js";
import { datagripTarget } from "./targets/datagrip.js";
import { dataspellTarget } from "./targets/dataspell.js";
import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js";
import { golandTarget } from "./targets/goland.js";
import { intellijIdeaTarget } from "./targets/intellij-idea.js";
import { kiroTarget } from "./targets/kiro.js";
import { phpstormTarget } from "./targets/phpstorm.js";
import { pycharmTarget } from "./targets/pycharm.js";
import { riderTarget } from "./targets/rider.js";
import { rubymineTarget } from "./targets/rubymine.js";
import { rustroverTarget } from "./targets/rustrover.js";
import { traeTarget } from "./targets/trae.js";
import { vscodiumTarget } from "./targets/vscodium.js";
import { vscodeInsidersTarget } from "./targets/vscode-insiders.js";
import { vscodeTarget } from "./targets/vscode.js";
import { webstormTarget } from "./targets/webstorm.js";
import { zedTarget } from "./targets/zed.js";
export const EDITOR_TARGETS: readonly EditorTarget[] = [
cursorTarget,
traeTarget,
kiroTarget,
vscodeTarget,
vscodeInsidersTarget,
vscodiumTarget,
zedTarget,
antigravityTarget,
intellijIdeaTarget,
aquaTarget,
clionTarget,
datagripTarget,
dataspellTarget,
golandTarget,
phpstormTarget,
pycharmTarget,
riderTarget,
rubymineTarget,
rustroverTarget,
webstormTarget,
finderTarget,
explorerTarget,
fileManagerTarget,
];
export async function listAvailableEditorTargets(
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<EditorTargetDescriptor[]> {
const descriptors: EditorTargetDescriptor[] = [];
for (const target of targets) {
if (await target.isInstalled(runtime)) {
descriptors.push(await target.describe(runtime));
}
}
return descriptors;
}
export function getEditorTarget(
id: string,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): EditorTarget {
const target = targets.find((candidate) => candidate.id === id);
if (!target) throw new Error(`Unknown editor target: ${id}`);
return target;
}
export async function openEditorTarget(
input: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
if (!runtime.pathExists(input.workspacePath)) {
throw new Error(`Path does not exist: ${input.workspacePath}`);
}
if (input.filePath) {
if (!runtime.isAbsolutePath(input.filePath)) {
throw new Error("Editor target file path must be an absolute local path");
}
if (!runtime.pathExists(input.filePath)) {
throw new Error(`Path does not exist: ${input.filePath}`);
}
}
const target = getEditorTarget(input.editorId, targets);
if (!(await target.isInstalled(runtime))) {
const descriptor = await target.describe(runtime);
throw new Error(`Editor target unavailable: ${descriptor.label}`);
}
await target.launch(input, runtime);
}

View File

@@ -0,0 +1,60 @@
import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest";
import { createEditorTargetRuntime } from "./runtime.js";
interface SpawnRecord {
command: string;
args: string[];
options: SpawnOptions;
unrefed: boolean;
}
describe("editor target runtime", () => {
it("resolves command aliases and safely launches Windows command scripts", async () => {
const records: SpawnRecord[] = [];
const runtime = createEditorTargetRuntime({
platform: "win32",
env: {
PATH: "C:/Program Files/Editors & Tools/bin",
ELECTRON_RUN_AS_NODE: "1",
},
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: (command, args, options) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
},
});
const command = runtime.resolveCommand(["missing", "code"]);
if (!command) throw new Error("Expected the editor command to resolve");
await runtime.spawnDetached({
command,
args: ["C:/repo & workspace", "C:/repo/src/file & calculator.ts"],
});
expect(records).toEqual([
{
command: '"C:/Program Files/Editors & Tools/bin/code.cmd"',
args: ['"C:/repo & workspace"', '"C:/repo/src/file & calculator.ts"'],
options: {
detached: true,
env: { PATH: "C:/Program Files/Editors & Tools/bin" },
shell: true,
stdio: "ignore",
},
unrefed: true,
},
]);
});
});

View File

@@ -0,0 +1,189 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import { existsSync as nodeExistsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path, { posix, win32 } from "node:path";
import { app, shell } from "electron";
import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js";
interface SpawnedProcess {
once(event: "error", handler: (error: Error) => void): SpawnedProcess;
once(event: "spawn", handler: () => void): SpawnedProcess;
unref(): void;
}
export interface RecordedEditorLaunch {
command: string;
args: string[];
options: SpawnOptions;
}
export interface EditorTargetRuntimeOptions {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pathExists?: (path: string) => boolean;
spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess;
openPath?: (path: string) => Promise<string>;
revealPath?: (path: string) => void;
loadIcon?: (fileName: string) => Promise<EditorTargetIcon>;
homeDirectory?: string;
}
const RUNTIME_CONTROL_ENV_KEYS = [
"PASEO_NODE_ENV",
"PASEO_DESKTOP_MANAGED",
"PASEO_SUPERVISED",
"ELECTRON_RUN_AS_NODE",
"ELECTRON_NO_ATTACH_CONSOLE",
] as const;
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 isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function resolveExecutable(
commands: readonly string[],
input: {
env: NodeJS.ProcessEnv;
pathExists: (path: string) => boolean;
platform: NodeJS.Platform;
},
): string | null {
for (const command of commands) {
if (isAbsolutePath(command, input.platform) && input.pathExists(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.platform !== "win32") {
if (input.pathExists(candidate)) return candidate;
continue;
}
const hasExtension = Boolean(win32.extname(command));
const extensions = hasExtension ? [""] : [".exe", ".cmd", ".bat", ".com", ""];
for (const extension of extensions) {
const windowsCandidate = `${candidate}${extension}`;
if (input.pathExists(windowsCandidate)) return windowsCandidate;
}
}
}
return null;
}
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)) return unquoted;
const quoted = unquoted
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
return `"${quoted}"`;
}
function spawnProcess(command: string, args: string[], options: SpawnOptions): SpawnedProcess {
return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess;
}
function iconPath(fileName: string): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "editor-target-icons", fileName);
}
return path.resolve(__dirname, "../../../assets/editor-targets", fileName);
}
async function loadBundledIcon(fileName: string): Promise<EditorTargetIcon> {
const bytes = await readFile(iconPath(fileName));
return { kind: "image", dataUrl: `data:image/png;base64,${bytes.toString("base64")}` };
}
export function createEditorTargetRuntime(
options: EditorTargetRuntimeOptions = {},
): EditorTargetRuntime {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const pathExists = options.pathExists ?? nodeExistsSync;
const spawn = options.spawn ?? spawnProcess;
const openPath = options.openPath ?? ((targetPath) => shell.openPath(targetPath));
const revealPath = options.revealPath ?? ((targetPath) => shell.showItemInFolder(targetPath));
const loadIcon = options.loadIcon ?? loadBundledIcon;
const homeDirectory = options.homeDirectory ?? os.homedir();
return {
platform,
env,
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
const launchCommand = commandScript ? escapeWindowsCmdValue(command) : command;
const launchArgs = commandScript ? args.map(escapeWindowsCmdValue) : [...args];
await new Promise<void>((resolve, reject) => {
let child: SpawnedProcess;
try {
child = spawn(launchCommand, launchArgs, {
detached: true,
env: createExternalProcessEnv(env),
shell: commandScript,
stdio: "ignore",
});
} catch (error) {
reject(error);
return;
}
child.once("error", reject);
child.once("spawn", () => {
child.unref();
resolve();
});
});
},
async openPath(targetPath) {
const errorMessage = await openPath(targetPath);
if (errorMessage) throw new Error(errorMessage);
},
revealPath,
loadIcon,
hasMacApplication(applicationName) {
if (platform !== "darwin") return false;
return [
`/Applications/${applicationName}.app`,
`${homeDirectory}/Applications/${applicationName}.app`,
`/System/Applications/${applicationName}.app`,
].some(pathExists);
},
async openMacApplication({ applicationName, paths }) {
await this.spawnDetached({
command: "/usr/bin/open",
args: ["-a", applicationName, ...paths],
});
},
};
}

View File

@@ -0,0 +1,42 @@
export type EditorTargetKind = "editor" | "file-manager";
export type EditorTargetIcon =
| { kind: "image"; dataUrl: string }
| { kind: "symbol"; name: "folder" | "terminal" };
export interface EditorTargetDescriptor {
id: string;
label: string;
kind: EditorTargetKind;
icon: EditorTargetIcon;
}
export interface EditorTargetLaunchInput {
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
export interface EditorTargetRuntime {
readonly platform: NodeJS.Platform;
readonly env: NodeJS.ProcessEnv;
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
revealPath(path: string): void;
loadIcon(fileName: string): Promise<EditorTargetIcon>;
hasMacApplication(applicationName: string): boolean;
openMacApplication(input: { applicationName: string; paths: readonly string[] }): Promise<void>;
}
export interface EditorTarget {
readonly id: string;
describe(runtime: EditorTargetRuntime): Promise<EditorTargetDescriptor>;
isInstalled(runtime: EditorTargetRuntime): Promise<boolean>;
launch(input: EditorTargetLaunchInput, runtime: EditorTargetRuntime): Promise<void>;
}

View File

@@ -0,0 +1,36 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["agy", "antigravity"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const antigravityTarget: EditorTarget = {
id: "antigravity",
async describe(runtime) {
return {
id: this.id,
label: "Antigravity",
kind: "editor",
icon: await runtime.loadIcon("antigravity.png"),
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("Antigravity is not installed");
await runtime.spawnDetached({ command, args: launchArgs(input) });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["aqua", "aqua64"] as const;
export const aquaTarget: EditorTarget = {
id: "aqua",
async describe() {
return {
id: this.id,
label: "Aqua",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("Aqua is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["clion", "clion64"] as const;
export const clionTarget: EditorTarget = {
id: "clion",
async describe() {
return {
id: this.id,
label: "CLion",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("CLion is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,69 @@
import type { EditorTarget, EditorTargetLaunchInput, EditorTargetRuntime } from "../target.js";
function commands(runtime: EditorTargetRuntime): string[] {
const candidates = ["cursor"];
if (runtime.platform === "darwin") {
candidates.push("/Applications/Cursor.app/Contents/Resources/app/bin/cursor");
if (runtime.env.HOME) {
candidates.push(
`${runtime.env.HOME}/Applications/Cursor.app/Contents/Resources/app/bin/cursor`,
);
}
}
if (runtime.platform === "win32") {
if (runtime.env.LOCALAPPDATA) {
candidates.push(`${runtime.env.LOCALAPPDATA}/Programs/cursor/resources/app/bin/cursor.cmd`);
candidates.push(`${runtime.env.LOCALAPPDATA}/Programs/cursor/Cursor.exe`);
}
if (runtime.env.ProgramFiles) {
candidates.push(`${runtime.env.ProgramFiles}/Cursor/resources/app/bin/cursor.cmd`);
candidates.push(`${runtime.env.ProgramFiles}/Cursor/Cursor.exe`);
}
}
return candidates;
}
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const cursorTarget: EditorTarget = {
id: "cursor",
async describe(runtime) {
return {
id: this.id,
label: "Cursor",
kind: "editor",
icon: await runtime.loadIcon("cursor.png"),
};
},
async isInstalled(runtime) {
return (
runtime.resolveCommand(commands(runtime)) !== null || runtime.hasMacApplication("Cursor")
);
},
async launch(input, runtime) {
const command = runtime.resolveCommand(commands(runtime));
if (command) {
await runtime.spawnDetached({ command, args: launchArgs(input) });
return;
}
if (runtime.hasMacApplication("Cursor")) {
await runtime.openMacApplication({
applicationName: "Cursor",
paths: input.filePath ? [input.workspacePath, input.filePath] : [input.workspacePath],
});
return;
}
throw new Error("Cursor is not installed");
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["datagrip", "datagrip64"] as const;
export const datagripTarget: EditorTarget = {
id: "datagrip",
async describe() {
return {
id: this.id,
label: "DataGrip",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("DataGrip is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["dataspell", "dataspell64"] as const;
export const dataspellTarget: EditorTarget = {
id: "dataspell",
async describe() {
return {
id: this.id,
label: "DataSpell",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("DataSpell is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,57 @@
import type { EditorTarget } from "../target.js";
const launchFileManager: EditorTarget["launch"] = async (input, runtime) => {
if (input.filePath) {
runtime.revealPath(input.filePath);
return;
}
await runtime.openPath(input.workspacePath);
};
export const finderTarget: EditorTarget = {
id: "finder",
async describe(runtime) {
return {
id: this.id,
label: "Finder",
kind: "file-manager",
icon: await runtime.loadIcon("finder.png"),
};
},
async isInstalled(runtime) {
return runtime.platform === "darwin";
},
launch: launchFileManager,
};
export const explorerTarget: EditorTarget = {
id: "explorer",
async describe() {
return {
id: this.id,
label: "Explorer",
kind: "file-manager",
icon: { kind: "symbol", name: "folder" },
};
},
async isInstalled(runtime) {
return runtime.platform === "win32";
},
launch: launchFileManager,
};
export const fileManagerTarget: EditorTarget = {
id: "file-manager",
async describe() {
return {
id: this.id,
label: "Files",
kind: "file-manager",
icon: { kind: "symbol", name: "folder" },
};
},
async isInstalled(runtime) {
return runtime.platform !== "darwin" && runtime.platform !== "win32";
},
launch: launchFileManager,
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["goland", "goland64"] as const;
export const golandTarget: EditorTarget = {
id: "goland",
async describe() {
return {
id: this.id,
label: "GoLand",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("GoLand is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["idea", "idea64"] as const;
export const intellijIdeaTarget: EditorTarget = {
id: "intellij-idea",
async describe() {
return {
id: this.id,
label: "IntelliJ IDEA",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("IntelliJ IDEA is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,36 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["kiro"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return ["ide", input.workspacePath];
if (!input.line) return ["ide", input.workspacePath, input.filePath];
return ["ide", input.workspacePath, "--goto", location(input)];
}
export const kiroTarget: EditorTarget = {
id: "kiro",
async describe() {
return {
id: this.id,
label: "Kiro",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("Kiro is not installed");
await runtime.spawnDetached({ command, args: launchArgs(input) });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["phpstorm", "phpstorm64"] as const;
export const phpstormTarget: EditorTarget = {
id: "phpstorm",
async describe() {
return {
id: this.id,
label: "PhpStorm",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("PhpStorm is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["pycharm", "pycharm64"] as const;
export const pycharmTarget: EditorTarget = {
id: "pycharm",
async describe() {
return {
id: this.id,
label: "PyCharm",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("PyCharm is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["rider", "rider64"] as const;
export const riderTarget: EditorTarget = {
id: "rider",
async describe() {
return {
id: this.id,
label: "Rider",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("Rider is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["rubymine", "rubymine64"] as const;
export const rubymineTarget: EditorTarget = {
id: "rubymine",
async describe() {
return {
id: this.id,
label: "RubyMine",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("RubyMine is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,28 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["rustrover", "rustrover64"] as const;
export const rustroverTarget: EditorTarget = {
id: "rustrover",
async describe() {
return {
id: this.id,
label: "RustRover",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("RustRover is not installed");
if (!input.filePath) return runtime.spawnDetached({ command, args: [input.workspacePath] });
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,36 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["trae"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const traeTarget: EditorTarget = {
id: "trae",
async describe() {
return {
id: this.id,
label: "Trae",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("Trae is not installed");
await runtime.spawnDetached({ command, args: launchArgs(input) });
},
};

View File

@@ -0,0 +1,74 @@
import type { EditorTarget, EditorTargetLaunchInput, EditorTargetRuntime } from "../target.js";
function commands(runtime: EditorTargetRuntime): string[] {
const candidates = ["code-insiders"];
if (runtime.platform === "darwin") {
candidates.push(
"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code",
);
if (runtime.env.HOME) {
candidates.push(
`${runtime.env.HOME}/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code`,
);
}
}
if (runtime.platform === "win32") {
if (runtime.env.LOCALAPPDATA) {
candidates.push(
`${runtime.env.LOCALAPPDATA}/Programs/Microsoft VS Code Insiders/bin/code-insiders.cmd`,
);
}
if (runtime.env.ProgramFiles) {
candidates.push(
`${runtime.env.ProgramFiles}/Microsoft VS Code Insiders/bin/code-insiders.cmd`,
);
}
}
return candidates;
}
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const vscodeInsidersTarget: EditorTarget = {
id: "vscode-insiders",
async describe() {
return {
id: this.id,
label: "VS Code Insiders",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return (
runtime.resolveCommand(commands(runtime)) !== null ||
runtime.hasMacApplication("Visual Studio Code - Insiders")
);
},
async launch(input, runtime) {
const command = runtime.resolveCommand(commands(runtime));
if (command) {
await runtime.spawnDetached({ command, args: launchArgs(input) });
return;
}
if (runtime.hasMacApplication("Visual Studio Code - Insiders")) {
await runtime.openMacApplication({
applicationName: "Visual Studio Code - Insiders",
paths: input.filePath ? [input.workspacePath, input.filePath] : [input.workspacePath],
});
return;
}
throw new Error("VS Code Insiders is not installed");
},
};

View File

@@ -0,0 +1,68 @@
import type { EditorTarget, EditorTargetLaunchInput, EditorTargetRuntime } from "../target.js";
function commands(runtime: EditorTargetRuntime): string[] {
const candidates = ["code"];
if (runtime.platform === "darwin") {
candidates.push("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code");
if (runtime.env.HOME) {
candidates.push(
`${runtime.env.HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code`,
);
}
}
if (runtime.platform === "win32") {
if (runtime.env.LOCALAPPDATA) {
candidates.push(`${runtime.env.LOCALAPPDATA}/Programs/Microsoft VS Code/bin/code.cmd`);
}
if (runtime.env.ProgramFiles) {
candidates.push(`${runtime.env.ProgramFiles}/Microsoft VS Code/bin/code.cmd`);
}
}
return candidates;
}
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const vscodeTarget: EditorTarget = {
id: "vscode",
async describe(runtime) {
return {
id: this.id,
label: "VS Code",
kind: "editor",
icon: await runtime.loadIcon("vscode.png"),
};
},
async isInstalled(runtime) {
return (
runtime.resolveCommand(commands(runtime)) !== null ||
runtime.hasMacApplication("Visual Studio Code")
);
},
async launch(input, runtime) {
const command = runtime.resolveCommand(commands(runtime));
if (command) {
await runtime.spawnDetached({ command, args: launchArgs(input) });
return;
}
if (runtime.hasMacApplication("Visual Studio Code")) {
await runtime.openMacApplication({
applicationName: "Visual Studio Code",
paths: input.filePath ? [input.workspacePath, input.filePath] : [input.workspacePath],
});
return;
}
throw new Error("VS Code is not installed");
},
};

View File

@@ -0,0 +1,67 @@
import type { EditorTarget, EditorTargetLaunchInput, EditorTargetRuntime } from "../target.js";
function commands(runtime: EditorTargetRuntime): string[] {
const candidates = ["codium"];
if (runtime.platform === "darwin") {
candidates.push("/Applications/VSCodium.app/Contents/Resources/app/bin/codium");
if (runtime.env.HOME) {
candidates.push(
`${runtime.env.HOME}/Applications/VSCodium.app/Contents/Resources/app/bin/codium`,
);
}
}
if (runtime.platform === "win32") {
if (runtime.env.LOCALAPPDATA) {
candidates.push(`${runtime.env.LOCALAPPDATA}/Programs/VSCodium/bin/codium.cmd`);
}
if (runtime.env.ProgramFiles) {
candidates.push(`${runtime.env.ProgramFiles}/VSCodium/bin/codium.cmd`);
}
}
return candidates;
}
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
function launchArgs(input: EditorTargetLaunchInput): string[] {
if (!input.filePath) return [input.workspacePath];
if (!input.line) return [input.workspacePath, input.filePath];
return [input.workspacePath, "--goto", location(input)];
}
export const vscodiumTarget: EditorTarget = {
id: "vscodium",
async describe() {
return {
id: this.id,
label: "VSCodium",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
};
},
async isInstalled(runtime) {
return (
runtime.resolveCommand(commands(runtime)) !== null || runtime.hasMacApplication("VSCodium")
);
},
async launch(input, runtime) {
const command = runtime.resolveCommand(commands(runtime));
if (command) {
await runtime.spawnDetached({ command, args: launchArgs(input) });
return;
}
if (runtime.hasMacApplication("VSCodium")) {
await runtime.openMacApplication({
applicationName: "VSCodium",
paths: input.filePath ? [input.workspacePath, input.filePath] : [input.workspacePath],
});
return;
}
throw new Error("VSCodium is not installed");
},
};

View File

@@ -0,0 +1,31 @@
import type { EditorTarget } from "../target.js";
const COMMANDS = ["webstorm", "webstorm64"] as const;
export const webstormTarget: EditorTarget = {
id: "webstorm",
async describe(runtime) {
return {
id: this.id,
label: "WebStorm",
kind: "editor",
icon: await runtime.loadIcon("webstorm.png"),
};
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null;
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (!command) throw new Error("WebStorm is not installed");
if (!input.filePath) {
await runtime.spawnDetached({ command, args: [input.workspacePath] });
return;
}
const args: string[] = [];
if (input.line) args.push("--line", String(input.line));
if (input.column) args.push("--column", String(input.column));
args.push(input.workspacePath, input.filePath);
await runtime.spawnDetached({ command, args });
},
};

View File

@@ -0,0 +1,38 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["zed", "zeditor"] as const;
function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!;
return input.column
? `${input.filePath}:${input.line}:${input.column}`
: `${input.filePath}:${input.line}`;
}
export const zedTarget: EditorTarget = {
id: "zed",
async describe(runtime) {
return { id: this.id, label: "Zed", kind: "editor", icon: await runtime.loadIcon("zed.png") };
},
async isInstalled(runtime) {
return runtime.resolveCommand(COMMANDS) !== null || runtime.hasMacApplication("Zed");
},
async launch(input, runtime) {
const command = runtime.resolveCommand(COMMANDS);
if (command) {
await runtime.spawnDetached({
command,
args: input.filePath ? [input.workspacePath, location(input)] : [input.workspacePath],
});
return;
}
if (runtime.hasMacApplication("Zed")) {
await runtime.openMacApplication({
applicationName: "Zed",
paths: input.filePath ? [input.workspacePath, input.filePath] : [input.workspacePath],
});
return;
}
throw new Error("Zed is not installed");
},
};

View File

@@ -45,7 +45,7 @@ import {
ensureNotificationCenterRegistration,
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,

View File

@@ -77,9 +77,10 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"),
openTarget: (input: {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}) => ipcRenderer.invoke("paseo:editor:openTarget", input),
},
webUtils: {