diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 8ba8082f0e57..580a8b95684d 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -106,6 +106,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the JetBrains Toolbox ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const url = + "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject"; + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -114,9 +128,12 @@ describe("ElectronShell", () => { const results = yield* Effect.all([ electronShell.openExternal("zed://extension/attacker"), electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"), + electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"), + electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); @@ -134,9 +151,10 @@ describe("ElectronShell", () => { "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), + electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"), ]); - assert.deepEqual(results, [false, false, false]); + assert.deepEqual(results, [false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9be6d4ecf55b..5504125ec202 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -12,8 +12,8 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`) must reach the OS handler; every other non-web -// scheme stays blocked. +// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must +// reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -31,9 +31,13 @@ const isRemoteEditorUrl = (url: URL) => url.password.length === 0 && (url.protocol === "zed:" ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) - : url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length); + : url.protocol === "jetbrains:" + ? url.host === "gateway" && + url.pathname === "/ssh/environment" && + (url.searchParams.get("h") ?? "").length > 0 + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 0f636a526cef..d64161c66917 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -160,8 +160,25 @@ describe("buildRemoteOpenUrl", () => { ); }); + it("builds a Toolbox ssh deep link for JetBrains IDEs", () => { + expect( + buildRemoteOpenUrl({ + editor: "pycharm", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol.tail1234.ts.net&launchIde=true&ideHint=PY&projectHint=%2Fhome%2Ftheo%2Fcode%2Fmy+repo", + ); + expect( + buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=C%3A%2FUsers%2Ftheo", + ); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 55f16e1e0124..75dca95786bf 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -17,6 +17,12 @@ type EditorDefinition = { * `zed://ssh/` shape. */ readonly remoteScheme?: string; + /** + * JetBrains product code (`IU`, `PY`, ...). JetBrains IDEs open remote + * projects through the Toolbox App's `jetbrains://gateway/ssh/...` link, + * which uses the code to choose the IDE backend. + */ + readonly jetbrainsProductCode?: string; }; export const EDITORS = [ @@ -67,18 +73,90 @@ export const EDITORS = [ commands: ["antigravity-ide", "agy-ide"], launchStyle: "goto", }, - { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, - { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, - { id: "clion", label: "CLion", commands: ["clion"], launchStyle: "line-column" }, - { id: "datagrip", label: "DataGrip", commands: ["datagrip"], launchStyle: "line-column" }, - { id: "dataspell", label: "DataSpell", commands: ["dataspell"], launchStyle: "line-column" }, - { id: "goland", label: "GoLand", commands: ["goland"], launchStyle: "line-column" }, - { id: "phpstorm", label: "PhpStorm", commands: ["phpstorm"], launchStyle: "line-column" }, - { id: "pycharm", label: "PyCharm", commands: ["pycharm"], launchStyle: "line-column" }, - { id: "rider", label: "Rider", commands: ["rider"], launchStyle: "line-column" }, - { id: "rubymine", label: "RubyMine", commands: ["rubymine"], launchStyle: "line-column" }, - { id: "rustrover", label: "RustRover", commands: ["rustrover"], launchStyle: "line-column" }, - { id: "webstorm", label: "WebStorm", commands: ["webstorm"], launchStyle: "line-column" }, + { + id: "idea", + label: "IntelliJ IDEA", + commands: ["idea"], + launchStyle: "line-column", + jetbrainsProductCode: "IU", + }, + { + id: "aqua", + label: "Aqua", + commands: ["aqua"], + launchStyle: "line-column", + jetbrainsProductCode: "QA", + }, + { + id: "clion", + label: "CLion", + commands: ["clion"], + launchStyle: "line-column", + jetbrainsProductCode: "CL", + }, + { + id: "datagrip", + label: "DataGrip", + commands: ["datagrip"], + launchStyle: "line-column", + jetbrainsProductCode: "DB", + }, + { + id: "dataspell", + label: "DataSpell", + commands: ["dataspell"], + launchStyle: "line-column", + jetbrainsProductCode: "DS", + }, + { + id: "goland", + label: "GoLand", + commands: ["goland"], + launchStyle: "line-column", + jetbrainsProductCode: "GO", + }, + { + id: "phpstorm", + label: "PhpStorm", + commands: ["phpstorm"], + launchStyle: "line-column", + jetbrainsProductCode: "PS", + }, + { + id: "pycharm", + label: "PyCharm", + commands: ["pycharm"], + launchStyle: "line-column", + jetbrainsProductCode: "PY", + }, + { + id: "rider", + label: "Rider", + commands: ["rider"], + launchStyle: "line-column", + jetbrainsProductCode: "RD", + }, + { + id: "rubymine", + label: "RubyMine", + commands: ["rubymine"], + launchStyle: "line-column", + jetbrainsProductCode: "RM", + }, + { + id: "rustrover", + label: "RustRover", + commands: ["rustrover"], + launchStyle: "line-column", + jetbrainsProductCode: "RR", + }, + { + id: "webstorm", + label: "WebStorm", + commands: ["webstorm"], + launchStyle: "line-column", + jetbrainsProductCode: "WS", + }, { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; @@ -98,7 +176,8 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; +const remoteSchemeOf = (editor: EditorDefinition): string | undefined => + editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains"); /** Editors that can open a remote workspace via an SSH deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => @@ -112,9 +191,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { /** * Builds a `://vscode-remote/ssh-remote+` deep link (Zed - * takes `zed://ssh/`) that opens `absolutePath` on `host` in the - * local editor over SSH. Returns undefined for editors without remote - * deep-link support. + * takes `zed://ssh/`, JetBrains IDEs a Toolbox App + * `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on + * `host` in the local editor over SSH. Returns undefined for editors without + * remote deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -125,6 +205,18 @@ export const buildRemoteOpenUrl = (input: { if (scheme === undefined) { return undefined; } + const editor = EDITORS.find((candidate) => candidate.id === input.editor); + if (editor !== undefined && "jetbrainsProductCode" in editor) { + // Like the VS Code link, no user or port: the SSH config entry for `host` + // supplies them. A bare product code lets Toolbox pick the backend build. + const params = new URLSearchParams({ + h: input.host, + launchIde: "true", + ideHint: editor.jetbrainsProductCode, + projectHint: input.absolutePath.replaceAll("\\", "/"), + }); + return `${scheme}://gateway/ssh/environment?${params.toString()}`; + } // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;