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 000000000..f00d46491 Binary files /dev/null and b/packages/desktop/assets/editor-targets/antigravity.png differ diff --git a/packages/desktop/assets/editor-targets/cursor.png b/packages/desktop/assets/editor-targets/cursor.png new file mode 100644 index 000000000..7e30a3b6c Binary files /dev/null and b/packages/desktop/assets/editor-targets/cursor.png differ diff --git a/packages/desktop/assets/editor-targets/finder.png b/packages/desktop/assets/editor-targets/finder.png new file mode 100644 index 000000000..474dfe4b6 Binary files /dev/null and b/packages/desktop/assets/editor-targets/finder.png differ diff --git a/packages/desktop/assets/editor-targets/vscode.png b/packages/desktop/assets/editor-targets/vscode.png new file mode 100644 index 000000000..ced6ec214 Binary files /dev/null and b/packages/desktop/assets/editor-targets/vscode.png differ diff --git a/packages/desktop/assets/editor-targets/webstorm.png b/packages/desktop/assets/editor-targets/webstorm.png new file mode 100644 index 000000000..7837d566e Binary files /dev/null and b/packages/desktop/assets/editor-targets/webstorm.png differ diff --git a/packages/desktop/assets/editor-targets/zed.png b/packages/desktop/assets/editor-targets/zed.png new file mode 100644 index 000000000..6111ab2ab Binary files /dev/null and b/packages/desktop/assets/editor-targets/zed.png differ 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: {