From f4509fe04450be76eac7ab574b3944590f46f571 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 14:39:55 +0200 Subject: [PATCH] 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. --- packages/app/e2e/helpers/desktop-updates.ts | 8 +- .../app/e2e/workspace-open-in-editor.spec.ts | 25 +- .../src/components/icons/editor-app-icons.tsx | 37 -- .../components/icons/editor-target-icon.tsx | 27 + packages/app/src/desktop/host.ts | 8 +- .../app/src/hooks/use-preferred-editor.ts | 3 +- .../workspace-open-in-editor-button.tsx | 8 +- .../app/src/workspace/desktop-open-targets.ts | 12 +- .../app/src/workspace/editor-targets.test.ts | 11 - packages/app/src/workspace/editor-targets.ts | 16 - .../src/workspace/open-target-planner.test.ts | 45 +- .../app/src/workspace/open-target-planner.ts | 24 +- .../assets/editor-targets/antigravity.png | Bin 0 -> 3064 bytes .../desktop/assets/editor-targets/cursor.png | Bin 0 -> 2478 bytes .../desktop/assets/editor-targets/finder.png | Bin 0 -> 3893 bytes .../desktop/assets/editor-targets/vscode.png | Bin 0 -> 2804 bytes .../assets/editor-targets/webstorm.png | Bin 0 -> 4677 bytes .../desktop/assets/editor-targets/zed.png | Bin 0 -> 4789 bytes packages/desktop/electron-builder.yml | 2 + .../src/features/editor-targets.test.ts | 568 ------------------ .../desktop/src/features/editor-targets.ts | 370 ------------ .../src/features/editor-targets/ipc.ts | 37 ++ .../features/editor-targets/registry.test.ts | 292 +++++++++ .../src/features/editor-targets/registry.ts | 103 ++++ .../features/editor-targets/runtime.test.ts | 60 ++ .../src/features/editor-targets/runtime.ts | 189 ++++++ .../src/features/editor-targets/target.ts | 42 ++ .../editor-targets/targets/antigravity.ts | 36 ++ .../features/editor-targets/targets/aqua.ts | 28 + .../features/editor-targets/targets/clion.ts | 28 + .../features/editor-targets/targets/cursor.ts | 69 +++ .../editor-targets/targets/datagrip.ts | 28 + .../editor-targets/targets/dataspell.ts | 28 + .../editor-targets/targets/file-manager.ts | 57 ++ .../features/editor-targets/targets/goland.ts | 28 + .../editor-targets/targets/intellij-idea.ts | 28 + .../features/editor-targets/targets/kiro.ts | 36 ++ .../editor-targets/targets/phpstorm.ts | 28 + .../editor-targets/targets/pycharm.ts | 28 + .../features/editor-targets/targets/rider.ts | 28 + .../editor-targets/targets/rubymine.ts | 28 + .../editor-targets/targets/rustrover.ts | 28 + .../features/editor-targets/targets/trae.ts | 36 ++ .../editor-targets/targets/vscode-insiders.ts | 74 +++ .../features/editor-targets/targets/vscode.ts | 68 +++ .../editor-targets/targets/vscodium.ts | 67 +++ .../editor-targets/targets/webstorm.ts | 31 + .../features/editor-targets/targets/zed.ts | 38 ++ packages/desktop/src/main.ts | 2 +- packages/desktop/src/preload.ts | 7 +- 50 files changed, 1665 insertions(+), 1051 deletions(-) delete mode 100644 packages/app/src/components/icons/editor-app-icons.tsx create mode 100644 packages/app/src/components/icons/editor-target-icon.tsx delete mode 100644 packages/app/src/workspace/editor-targets.test.ts delete mode 100644 packages/app/src/workspace/editor-targets.ts create mode 100644 packages/desktop/assets/editor-targets/antigravity.png create mode 100644 packages/desktop/assets/editor-targets/cursor.png create mode 100644 packages/desktop/assets/editor-targets/finder.png create mode 100644 packages/desktop/assets/editor-targets/vscode.png create mode 100644 packages/desktop/assets/editor-targets/webstorm.png create mode 100644 packages/desktop/assets/editor-targets/zed.png delete mode 100644 packages/desktop/src/features/editor-targets.test.ts delete mode 100644 packages/desktop/src/features/editor-targets.ts create mode 100644 packages/desktop/src/features/editor-targets/ipc.ts create mode 100644 packages/desktop/src/features/editor-targets/registry.test.ts create mode 100644 packages/desktop/src/features/editor-targets/registry.ts create mode 100644 packages/desktop/src/features/editor-targets/runtime.test.ts create mode 100644 packages/desktop/src/features/editor-targets/runtime.ts create mode 100644 packages/desktop/src/features/editor-targets/target.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/antigravity.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/aqua.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/clion.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/cursor.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/datagrip.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/dataspell.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/file-manager.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/goland.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/intellij-idea.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/kiro.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/phpstorm.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/pycharm.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/rider.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/rubymine.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/rustrover.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/trae.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/vscode-insiders.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/vscode.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/vscodium.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/webstorm.ts create mode 100644 packages/desktop/src/features/editor-targets/targets/zed.ts diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index f8d910955..1014270d4 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -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 { diff --git a/packages/app/e2e/workspace-open-in-editor.spec.ts b/packages/app/e2e/workspace-open-in-editor.spec.ts index 35d32a1d9..62b35d270 100644 --- a/packages/app/e2e/workspace-open-in-editor.spec.ts +++ b/packages/app/e2e/workspace-open-in-editor.spec.ts @@ -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, }); diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx deleted file mode 100644 index 8767319e2..000000000 --- a/packages/app/src/components/icons/editor-app-icons.tsx +++ /dev/null @@ -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 = { - 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 ; - } - - return ; -} diff --git a/packages/app/src/components/icons/editor-target-icon.tsx b/packages/app/src/components/icons/editor-target-icon.tsx new file mode 100644 index 000000000..f281e2724 --- /dev/null +++ b/packages/app/src/components/icons/editor-target-icon.tsx @@ -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 ; + } + if (icon.kind === "symbol" && icon.name === "folder") { + return ; + } + return ; +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 41f872c12..423591f75 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -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 { diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index 15b8c1c4c..480fd3d56 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -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"]; diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index b52d95583..0a23fca41 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -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: , + icon: ( + + ), onOpen: () => openDesktopTarget(target.openInput), }; }), diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts index 63b483c40..83eea4a94 100644 --- a/packages/app/src/workspace/desktop-open-targets.ts +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -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 { diff --git a/packages/app/src/workspace/editor-targets.test.ts b/packages/app/src/workspace/editor-targets.test.ts deleted file mode 100644 index 4e5ea78ff..000000000 --- a/packages/app/src/workspace/editor-targets.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/workspace/editor-targets.ts b/packages/app/src/workspace/editor-targets.ts deleted file mode 100644 index 9c587998c..000000000 --- a/packages/app/src/workspace/editor-targets.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type EditorTargetId = string; - -const KNOWN_EDITOR_TARGET_IDS: ReadonlySet = new Set([ - "cursor", - "vscode", - "webstorm", - "zed", - "antigravity", - "finder", - "explorer", - "file-manager", -]); - -export function isKnownEditorTargetId(editorId: EditorTargetId): boolean { - return KNOWN_EDITOR_TARGET_IDS.has(editorId); -} diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-target-planner.test.ts index 19acff6cf..aa6aee762 100644 --- a/packages/app/src/workspace/open-target-planner.test.ts +++ b/packages/app/src/workspace/open-target-planner.test.ts @@ -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", }, }, ]); diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-target-planner.ts index 9539df4f0..64ffa6c3b 100644 --- a/packages/app/src/workspace/open-target-planner.ts +++ b/packages/app/src/workspace/open-target-planner.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 } : {}), }, }; }); diff --git a/packages/desktop/assets/editor-targets/antigravity.png b/packages/desktop/assets/editor-targets/antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..f00d4649118468f37e508c309c0d3068314a9548 GIT binary patch literal 3064 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 literal 0 HcmV?d00001 diff --git a/packages/desktop/assets/editor-targets/cursor.png b/packages/desktop/assets/editor-targets/cursor.png new file mode 100644 index 0000000000000000000000000000000000000000..7e30a3b6c70d2be55f04382c783165c2f065e525 GIT binary patch literal 2478 zcmV;f2~qZmP)m!&+QLv}N?NId zco~6K>a^6cSi_}O7?9CY2q7wz6bm7cKrSTN&EC&BzV8<{(we>HoK1qwGqaQIIeX52 z@9lXn-&tS=Gnl~)W{@@{Og~I$O<70}3cXa1$CDin2kx^d(h41VA$_xkDS!^hl7#MP zSUZsG3H`aN3xU`gQ@uy3TI0`2xw*C)yT$SsdRL*5$hxj$IzS>VD6&LW(A&e_=;sE5 z!Ok0}k{U)vaHfZ?m~A?-%4dU%;sFN#&DL zIIow^cDIG2xQ)Ik-H@3YNKh3?I&!to*Mw0e#PtLv*{zBS-Fu=c?!IM&9|a3t;c{9E z{DH_m2)Y;_i9A5bb6GxSb=X@qZAv)KFceva>Q~G9{Qe%q@q$D#qS_s{*R@+!dV;EH zs_c~CLV^&8rvS<2!fXvv1yUo*w7{$#4o4u#if~R~`s<4Ofp{luHru0F zvh*343})<+kr0-C;w~&+yad_VIk4MpxN@Z%7K`~bqf?YCS$I1fR^J`(d|U;%owo0j zRqA09*uq|~A9ZzgxNqhCRKduIM57oQ9E8K+6vzF3KL!Q{V6|FdCL~Gj4F)4Ea6)Yi^HTU#q#Gs0eIO1(k% zD2aD2t^)J~sUsDz;E_m_|HG=)E8%jv$-*NEV_j9F^a2_i?}X3iBSo}C@wC+ zx%Sdh;NqR+r)LF`NZ7O(&+#N05kWdDA^; z-m+ON%IqGB36BLo-F5S`Q2b`*;jHQ?xx!(+#dVt``( z)XC$bIxH)zz!OhAj>^g^m;uuZAtt2inp!;bQtxqa+-Jalz+l7&ywFhhlf*j~R; z#xt+%-TQjd>(fO>6%Ye2xTdxiEiHeGl9gkt~`gOEYoDUBV!DhFA;Cl9` z^0IO~^`j>R^P7oD9dt5lS5;Nxhd=lp#k3vUxBmutc{wb-_R&1?)4~G+G#$!>5=>J-Y&gP` z8Wx;~Bk2krgOkeM-Me2BM9E{ZN=lvg-1|u}0O5I|nTjwT0#dA?k;DL#7a5!0OawZ0 zml4`EYCnA%vpgPgj6;B54UKoePYqGqsaDgJV8{c+-Z=@+X>IP@x-l;>0uKrO3o4oO z3kv?{T$rrS#nj!)?nMnXT&yUgFr)xZQ|zKlbiCnV6de2K-a97-s)<;iyOL_;X`6y(FhE2 z058a9<{nW8-z&pp^77U*H^<-I^AM8JHK=cJ425*l1_2?07*suvc?+|%` zeR>W=cu}5NF0{6uz_Gvm9S^TrBQ`U5j-QCN-Q@B+$+884`2H&xXMunIqa8op`ZHv^ ztm58gcY_(J1i9qRc4cADt9#(4DscV!$3%U=Bd!lWmBVD`rR}qhjt**#^G2shq!8Xx zYku}w5$}094skpaPo~9HfFy?A69+HW;jlncLwN4F=P`fY9ISbGtzh_@OJa{FADcJ- zM5I41e|fCF`?oLQ{R53?9R`Z-wmvAT;jQnZ5b8!PRC&%$^YnJ>xOhoM2 zAXSsFZta&*R$eJ~e7kAt`|``L&_pPUNKi4=)kQ0=h94Rn48Jz+`FI!7=0l;&E?rYI z(1wbqM*JZZ5w1?drI(;q&==X`TJZz=O~%sMBGk zFz``%8z9SDGa@`em7t$P9N`&>>HygvQdbiXO#0l=EGUYk_yXZCAOY7U5>6W)_8(^g z0khIA1|3oqDt_nlh0h|vZQd<{tQuyiu_ z<_oiWdc75-szb6Y#g0u^Xk1IsGXHQxcy|4S>rHj4Ahbvg$Wo_W`8_4pB2Aw@zZu}H z!p0%OyGAkJL~efuqp~G6R=Q&qp$Lb=QBKX-gBDp@K^*Dfu?VIHG8v&a8dVQ!lJym@ s*V~UIE^ik8Z+`&UF-Mh_nvj@ykyw>jDJmYN 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 D0a|me literal 0 HcmV?d00001 diff --git a/packages/desktop/assets/editor-targets/vscode.png b/packages/desktop/assets/editor-targets/vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..ced6ec21407ed4ca8e3a10663146d90f1dbb3129 GIT binary patch literal 2804 zcmVZd{{Mge*ZI#eFn|FJU;qR7{{@H0(AHmBwQ5y%etv#oZfvU=v0cndnZ?>&NT0 z9Xxn&H8GF7jd=!&6pUtZ|Ni|iLuaCOH9!eDI~JxQ4VOJekOCPWek+SP9s$H!HEwO=s8G^e1T;9;#cO#x7rHwkfs z95OXLvi1Nr?yUqIX3_*s)d+h62b-10qP<=uyp-nuk|rzlZ+f(b^$LO_QLd5JvMyd3q9Y3AgoraTdPe|04XUc zcEkYzIS2!JyTOaL;@?dzki_;Rb>@s|gjwW};o$N5Ngs@_)v4X|WI*c_hgO@W0LXdh z#?M$lBIZSbN8;SgxM}1eMc{9lAWQ-YE>*y*C%j!niA1diy*!{-V?R@SiQGCj>wQQr ztA-kPEf6M%?|pvVJ}eSww&^{GUV=nR;5v^ViI4sf;t+=_j60)yUXF6ruDWPSGHL-Z z(kM&92nJkfMn0x1(#*;UjKfg$gY&wPUYbcjP+U z&z54?^JREp&lUAIJ$~4jpH|jl?BZ<@hB;LqpanLTK+OSPm$Ta?^mvI%&6&WLZT|+v z+b@F4Gz000SbwMnBVPKPJUWZ%9s8>#8j{-k$Eq>&H@m?PNr9{r#MKFHmQk2v!ke?R z(JFP%6rzD%6wrL=!!z~RR8j*aNkq7^1=%Q|nrX(9M^IVYf;OSIX-o0WFY&W?O2DVv zs7;$8CP1auk9Qv*gdHm;!6PK0mnvK;qS64b*8@cqJCVh*IU&=6`-)HFRniCsHk&{G z!%?i=@dewXj<3|pB@^n6o znWJto9}h&8?64WLW{t$#GezWBYw*%hGk9CDvwBmCs{|=Y8F@c&^#ag%jq+G)NbIZ> z&Mpmpoc--^xH8?ar)MJG=uxthoRNd4rw+lEnH~fixLXB`j-Vg?v}j4C^+@(;O4}O2 zQTXFLGDeYqtABGWGIK{Gtq+)Ih$;%Sha)WuvnJ(Y|2!{gw+WgaTc)ChNQIfa2ca~1 zvAWQPW6v>_&x1YLhQ2~niO}t^yV8)BGZM<#qkxgEJ9HdZfV5%cB)HroU?VdU41HT} z?6C&OB&g4-Zeq-moe%~k1LenPrW2s)^2sE4K7eO-H(Lb67R&iGX<1{}WXg}CA~v;>0a$&#QaMav$o1zz9t z6@I#AANVYLD7$0ffoYDFZ2GHGa(0YUZSo{GiYo!OWyi3)q9OF&D3qZm`V;k9V9DlF z_}vG@WSX@j(bULMk%Kqckayz@9vDO~k#&SXLgKS5D0;6Fk8S!oY!ZkYqFW1;Uc#UD zoP}af>}2YU0i{94f{7NKe0drU%uT=)mk(S(TL`KElSS+~-vrAqPSE#uCeh38Au1DS zX=#CC5IZqX6OUkREB>)G6Km&=Lt^@Hn6gG;&l66}A5K}LkJGhRiAr4lWFt&Z9m9dD zCiS=8c8^h+z~X{bJd$Sy*AfyEV~VGIHzgpU_Kne)m_Hh+Swqw^H%}jP);-wxXa-h} zr(B^~D`5(OMFc+Ef*-zh7K=Wr!U(4cy%{8;^8pDCY&@BpR z1Et%{x543bVZ^;-aA9c+vd*}%ufL?^%o($)ReB4+O0N)g(t+CsC zbW;Q9IisN8H+)#)j?fE(E$C&~tQI8Xj7R-53X)o7st>f6g1!b&rGYk6cwB21@vBjd z`h7W0lNU_^)YjJOj~XLb5={`?`S@;yh@pa?O5t5I?kC2t`b?OYas}1X%3yJ3>poLg zSEtE~rT{!1&lSW82}UD0_gI`=Y{tZ-R`3!NLPxdcDAM?X0fFkMQ_l=2Xbu{1(`Uf5 z=~cM+&?zh(XF&=v?#>ymo+8x)^M9vLpZ=FtyH*%fjvqh%DdG%)!f9gmD120O9izqA`C0_Pvw(C7BNs?a(73Sl9NUG-YC2);b zXa+e19oa}HK}5Po5I*3gd&o<3#EBCpikX?2j@;bbAN0j3v83c=n4K9&O3gwt-FLS_ z)gJ78buzPg|Kbgw)d3q{{{MkAlhhP$K!#|Y(xhZKG{B4TMnvj6QCFWuM-B> z?y6Se*cY<(Kut|eE8RwXo+g$y67O80@3xZ$Fn|I4&)`2K06wVj18Ttl000099I}8K_R0IiWkdzMT z5D*EGQh#uc_nhBzpL_T7tZ%LTuJ>JQuf6~KqP4YDNFj6(002nUR8hJY9(id*gcskZ z7`OQg2ey;flm~$7_^am@1Q&alm8z~L0QhkLKu{O}oL)dd8vx+N4**}x0YEYX0BBsZ z8+4>E5a_U)qDN6cMAPeV-& zNh_=~!onJBX@l@_cD+;q(ms+Gp|g#r1ol8RU-th=78g_Vu0fb?I)|AzkU@;6%> z<74Awh{8DAxOiNgNLGMfQ23APf2aOWsnP#0{jby?sWji^!2fjtf8Xw3=*9KQLZtcr zc`8{*>yA_f0Faxiq2%xSfOd?1UG>Lq{GK}--@eMsOhJ?db7Zg~jwN!4fsGmT8FpE0 zGo?XlwRO-uqoTFbiJMTGFa@p=w7R4ItoHN5g{tdEN)OnGlNc#$-tWu@e)ip+pZzi4 z#7Dd!I2zzOKh=DIYd*-?&Gr9EJfxOGCNi>a)p} zuR*-wK2rr5t|?BRvQ;6f3$@!th6c=Bt$Q|Bne@G?j~umM<9=vyNxestwh-r0CNR`! z>O=Jl9|nFwmzDRH?9+MgMP2K#ncYHn&kt)69}L)uaN7Jtre%L-l|N8@Ml#73ZT(pD z`BMQ`f2-Wlh#Ef;;$4$KXmc-)%(R*H?v3_DDQ1iV5mI`<1)f9^5(+-J+FdS5v( z(1t+7MQpEXr%-`%*Zb-g)4iO%<}j151ZnI@`R-z3+g9~^Dbq@~=)lYk+5Px?k8PbA z*3W3iyqKgx_n;mjjDo>yt`xtZVz0~DHRBAE=AH$xi-^AJu)nFp&Ou;O_M5ZXwxyQp zDcmJO8;w!4V2yXwHpN%c-=Q6I<`boo1|qjZ3u~eclS4^5T++3TpZ!{n^TvC%-e2KR z<-+*9x^8Tte#TaEAU@+AI*xM{_X)jIzF#oqyxylH<+ zuLs=W+V@pfc@hhn*x>g2oR+PvpNoc>$c-HQF|QI&e{$=NemiXN+LWknk0TD^^gdK{ zoo*OiitS)+)S{6sW{b~vSOx3DAvaU>B@%KJc}<80we&=^ zAHcdZ4+>h2+9vH!%o#F$5q2Y6FM8vgGxB0MI-*b5t@dW*XH=KE3Y?t_zz1|g!~77Dy>Q=rsmdyVF@yC$gTaKvIC;TUrK(qocep(c7j8{=~Ki- zosm2eYs`IIk!)hWhl=l}MgqlTH*anznrMKkgkfVj<2f2xYh0PtqVL&CjrwpxZami} zZ%F1OLGzG-{UI8l{JJ)00>Vl+|@wkf=dRN|>uLkUUS8oah#&yU@*CA}aj@i#j% zMI47-W6he(92FF-V3j8Rq2scPTw6v4iZ7oo?u0KjToL=kwc>Uc?a8YAh+;2^{Whv! z=xiaBJjUH6H$6R)*SJ0^_w~FVyz?<*Y+4#Ml!m6rtU2)bcsYK!NQGR-OiwRV7IzxO z@3w@}$P^}KXJ;=&9J4NlFB#qX_L~v0>5#PxiFoW+V?|Aoc~YerI{-O89KEAd`SACz z?*4x0W~IqH%Y~L;zh`dCotqfK*v*C3&|7dgzA*&;aIuYGHt=lc&Bu=6~4*}i2EP$4CKl2`Nc6v=(byRKDxGd z`h81BU!OqqEF}fHI4c}JMb3{|ld%oZc1zP`jptw~5mQR(tZm?(_wM7) zPGtshidjvBalJ-{zp^r+!3%Wwhe-0}p#nw2CcipEvXI1{c+`W&T?g4;o^JIgx>3Q= zTtM+F$b%4pR8BP(o*u5D?Q1F(`fGA8;uou zWlKMQd{(isVZV<^|Hc$AbCmIo@1hUQPFtYx4QJEF<5h<3^1;a4M`0q-sR9H+VhW2` z*Y*%&hfp`C>FblP?8q6_!3oL9_@b-Z+fED;?qE_<(#agW7^sJevW198%veM|%)h5(7Qb^w*#$)gsU ze&>zt&lHIjy`5AkZVX7N8RUX2MtC?72r}=_I59@d6akSFXj^EKdEeH)HH2X4TJKI~ zRu(Hj)XO^Dzsq{n5G_g{E-^UVrx#F_(_!sa;ziX|QP1t9L7!r0&Js0o7$_NOIda}I zkRzp(%gA;m_hXhzgPQbjk-SAu$?69?Xs5|4P%Jes50I}5FQ&84+ zuv!bH+wWnjZGvimV7bNYI&2<0Lm~bcuR@FC__eXODIg1#&UP(b51(u~kF`fWYaW@Y z%ZyB;vu{22TCXsfZ;q5KbS-=!m*eR*^IfHq+}w+;{q@0}c4hH;u`N63PAWnfCzT5+ z1)?O@=VtSt5o)p;0pJ zuLfmfso)0on#NYnEF?%(Yl1WS%?fRaEUj-=*aXXCfKJg)D*?q_xoWI5tk*d1JJY-M zWI|9%Db7bO-`Sp)fmK9Agt)mn;KaQ;z(=v;=ajE<{n!~s5aLVHmkfQF2)vQo60+{j z+xWsavf{8y&;Yi_*p>96Do# z^xuH?vY2+)Pg;st+jmaxFQ8TPh)9FT1gpWYr1~sx-t56xRbyn~5T$>P)8ZK<;Sgukk=_6v;7~V*o{Wi1w zT{>ONLke;p-l=GQ-~LN@j{eDUVRbsKW&n0kXX;3NJfu4dfLi&(!)#VhIKpm7%po;9 zzNT==lzzfHl*{Dd4MI<(rxD8bZ$zALxZG6;c_v&rcs+lr6iF6pC3mHR?}%J>TG4u1 zT|nX$#E$NCaUs>%b?UZR$gKzS1EM&%@g)mx@hy#MgzMjMd=x; zsZI5kF?i~c_e7&ed)xUFa;8AzkzoAy4Iv@mdoSk)qIqSdK5`SB=5phe;G;9GtQc4E zJEcpe?%Wc-&)sBW`{r52OH$Hc6nBx4VCLKR!Ys(@l#%Bl+Fs(!Ig@8y+3W}NxGa*f z=Tzx?t_CPdjUOK~bl#sHZnkuWfhBM93xsV9RMV+}AH^M!n;zbgjbyIiBuYR_zR16N zum4<`i|Gy%D&~%}=U~?ROxa;H_h3y|EwJE``k*eiaPFg0Q77kjtwB%Hg(pYcq5{in z)aF;w%3%amV;egakMp2{Ig6Z<^qU*gX{P>Z zTnqVTgD3BGoovHsxq2;+<&C7$#45T>#Vd2f<{k9(+{GVe%-m%%e`^e8#>AFQXW(o{ zBM9tS1!5F4=YRd^!LhG587GZqk0pKBWTBvhPA#TovjydV%kn2Bc0wFR_ge+SMWOf0 zCg&DBgGN{;H*_3guVd)v7*cP6%SjLL2Fs1Ablz$dj+&{pY*i?ld>7b64NK1Ko5>}= z2^f}Y3hpaz>{vrisIg6lzM2n3aCJjf1&8pdO*pjrHmAH%6Q(^TdV(ckZ`hi9X=a>j zQO6&L)hLHbqiiSPqG}8txxGE-O@-1aWq$QUF1saSTJz$2rQC!k!-k(>`sw-x0>kLz7b=Z@(A( z+b<|TWF*`vShi~){W?Hg#e+C)mLiz6A1f?}zg1-&NkzIf_hb{5e?3?WtUz>5o}!sG UyK5}bzl3O`-mo`~i*H%6~zs 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 P00000NkvXXu0mjf1FbTA literal 0 HcmV?d00001 diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index c0534e146..53583c8e9 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -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 diff --git a/packages/desktop/src/features/editor-targets.test.ts b/packages/desktop/src/features/editor-targets.test.ts deleted file mode 100644 index 66467d8d8..000000000 --- a/packages/desktop/src/features/editor-targets.test.ts +++ /dev/null @@ -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 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"]); - }); -}); diff --git a/packages/desktop/src/features/editor-targets.ts b/packages/desktop/src/features/editor-targets.ts deleted file mode 100644 index c44177a50..000000000 --- a/packages/desktop/src/features/editor-targets.ts +++ /dev/null @@ -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 { - 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((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); - }); -} diff --git a/packages/desktop/src/features/editor-targets/ipc.ts b/packages/desktop/src/features/editor-targets/ipc.ts new file mode 100644 index 000000000..4737f4f32 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/ipc.ts @@ -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); + }); +} diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts new file mode 100644 index 000000000..d0d93e0ba --- /dev/null +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -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(); + private readonly commands = new Map(); + private readonly macApplications = new Set(); + + 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 { + this.launches.push({ command: input.command, args: [...input.args] }); + } + + async openPath(targetPath: string): Promise { + this.openedPaths.push(targetPath); + } + + revealPath(targetPath: string): void { + this.revealedPaths.push(targetPath); + } + + async loadIcon(fileName: string): Promise { + 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 { + 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"]); + }); +}); diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts new file mode 100644 index 000000000..8de2a55a8 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -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 { + 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 { + 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); +} diff --git a/packages/desktop/src/features/editor-targets/runtime.test.ts b/packages/desktop/src/features/editor-targets/runtime.test.ts new file mode 100644 index 000000000..6a2342e90 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/runtime.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts new file mode 100644 index 000000000..dcd5b514c --- /dev/null +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -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; + revealPath?: (path: string) => void; + loadIcon?: (fileName: string) => Promise; + 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 { + 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((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], + }); + }, + }; +} diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts new file mode 100644 index 000000000..6e92d71b5 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -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; + openPath(path: string): Promise; + revealPath(path: string): void; + loadIcon(fileName: string): Promise; + hasMacApplication(applicationName: string): boolean; + openMacApplication(input: { applicationName: string; paths: readonly string[] }): Promise; +} + +export interface EditorTarget { + readonly id: string; + + describe(runtime: EditorTargetRuntime): Promise; + isInstalled(runtime: EditorTargetRuntime): Promise; + launch(input: EditorTargetLaunchInput, runtime: EditorTargetRuntime): Promise; +} diff --git a/packages/desktop/src/features/editor-targets/targets/antigravity.ts b/packages/desktop/src/features/editor-targets/targets/antigravity.ts new file mode 100644 index 000000000..932cd99f0 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/antigravity.ts @@ -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) }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/aqua.ts b/packages/desktop/src/features/editor-targets/targets/aqua.ts new file mode 100644 index 000000000..d02a79cf1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/aqua.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/clion.ts b/packages/desktop/src/features/editor-targets/targets/clion.ts new file mode 100644 index 000000000..23957e97f --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/clion.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/cursor.ts b/packages/desktop/src/features/editor-targets/targets/cursor.ts new file mode 100644 index 000000000..f4557cbfe --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/cursor.ts @@ -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"); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/datagrip.ts b/packages/desktop/src/features/editor-targets/targets/datagrip.ts new file mode 100644 index 000000000..11f1da4b9 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/datagrip.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/dataspell.ts b/packages/desktop/src/features/editor-targets/targets/dataspell.ts new file mode 100644 index 000000000..c0c6f6550 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/dataspell.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/file-manager.ts b/packages/desktop/src/features/editor-targets/targets/file-manager.ts new file mode 100644 index 000000000..28b45008b --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/file-manager.ts @@ -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, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/goland.ts b/packages/desktop/src/features/editor-targets/targets/goland.ts new file mode 100644 index 000000000..937f24f66 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/goland.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/intellij-idea.ts b/packages/desktop/src/features/editor-targets/targets/intellij-idea.ts new file mode 100644 index 000000000..a50dd38f3 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/intellij-idea.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/kiro.ts b/packages/desktop/src/features/editor-targets/targets/kiro.ts new file mode 100644 index 000000000..aae9f71db --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/kiro.ts @@ -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) }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/phpstorm.ts b/packages/desktop/src/features/editor-targets/targets/phpstorm.ts new file mode 100644 index 000000000..f8bd2aceb --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/phpstorm.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/pycharm.ts b/packages/desktop/src/features/editor-targets/targets/pycharm.ts new file mode 100644 index 000000000..6a07928bc --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/pycharm.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/rider.ts b/packages/desktop/src/features/editor-targets/targets/rider.ts new file mode 100644 index 000000000..484bf6acc --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/rider.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/rubymine.ts b/packages/desktop/src/features/editor-targets/targets/rubymine.ts new file mode 100644 index 000000000..a408cdeb1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/rubymine.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/rustrover.ts b/packages/desktop/src/features/editor-targets/targets/rustrover.ts new file mode 100644 index 000000000..9e8d016c4 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/rustrover.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/trae.ts b/packages/desktop/src/features/editor-targets/targets/trae.ts new file mode 100644 index 000000000..aa082ea86 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/trae.ts @@ -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) }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/vscode-insiders.ts b/packages/desktop/src/features/editor-targets/targets/vscode-insiders.ts new file mode 100644 index 000000000..d66625041 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/vscode-insiders.ts @@ -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"); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/vscode.ts b/packages/desktop/src/features/editor-targets/targets/vscode.ts new file mode 100644 index 000000000..1fcbb33f1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/vscode.ts @@ -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"); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/vscodium.ts b/packages/desktop/src/features/editor-targets/targets/vscodium.ts new file mode 100644 index 000000000..8594b054f --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/vscodium.ts @@ -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"); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/webstorm.ts b/packages/desktop/src/features/editor-targets/targets/webstorm.ts new file mode 100644 index 000000000..dfa2d2a4f --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/webstorm.ts @@ -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 }); + }, +}; diff --git a/packages/desktop/src/features/editor-targets/targets/zed.ts b/packages/desktop/src/features/editor-targets/targets/zed.ts new file mode 100644 index 000000000..40b13f258 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/zed.ts @@ -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"); + }, +}; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index ade45e013..6ba65190b 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index c402abb27..fcd6e2b46 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -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: {