mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
27
packages/app/src/components/icons/editor-target-icon.tsx
Normal file
27
packages/app/src/components/icons/editor-target-icon.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
BIN
packages/desktop/assets/editor-targets/antigravity.png
Normal file
BIN
packages/desktop/assets/editor-targets/antigravity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
packages/desktop/assets/editor-targets/cursor.png
Normal file
BIN
packages/desktop/assets/editor-targets/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/desktop/assets/editor-targets/finder.png
Normal file
BIN
packages/desktop/assets/editor-targets/finder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/desktop/assets/editor-targets/vscode.png
Normal file
BIN
packages/desktop/assets/editor-targets/vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/desktop/assets/editor-targets/webstorm.png
Normal file
BIN
packages/desktop/assets/editor-targets/webstorm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/desktop/assets/editor-targets/zed.png
Normal file
BIN
packages/desktop/assets/editor-targets/zed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
37
packages/desktop/src/features/editor-targets/ipc.ts
Normal file
37
packages/desktop/src/features/editor-targets/ipc.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
292
packages/desktop/src/features/editor-targets/registry.test.ts
Normal file
292
packages/desktop/src/features/editor-targets/registry.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
103
packages/desktop/src/features/editor-targets/registry.ts
Normal file
103
packages/desktop/src/features/editor-targets/registry.ts
Normal 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);
|
||||
}
|
||||
60
packages/desktop/src/features/editor-targets/runtime.test.ts
Normal file
60
packages/desktop/src/features/editor-targets/runtime.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
189
packages/desktop/src/features/editor-targets/runtime.ts
Normal file
189
packages/desktop/src/features/editor-targets/runtime.ts
Normal 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],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
42
packages/desktop/src/features/editor-targets/target.ts
Normal file
42
packages/desktop/src/features/editor-targets/target.ts
Normal 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>;
|
||||
}
|
||||
@@ -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) });
|
||||
},
|
||||
};
|
||||
28
packages/desktop/src/features/editor-targets/targets/aqua.ts
Normal file
28
packages/desktop/src/features/editor-targets/targets/aqua.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
36
packages/desktop/src/features/editor-targets/targets/kiro.ts
Normal file
36
packages/desktop/src/features/editor-targets/targets/kiro.ts
Normal 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) });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
36
packages/desktop/src/features/editor-targets/targets/trae.ts
Normal file
36
packages/desktop/src/features/editor-targets/targets/trae.ts
Normal 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) });
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
38
packages/desktop/src/features/editor-targets/targets/zed.ts
Normal file
38
packages/desktop/src/features/editor-targets/targets/zed.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user