authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 12:30:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 12:30:47-07:00
logdf616cc5491dcc7dd80d3ade8decd88f7a652220
treee99088ee401887c54cb2ec5b39465cc5f82064fe
parentfd25c42ae6e56a82be7219950a746572b1c4d0e9
signature Signed by PGP key B5690EEEBB952194

feat(editors): open remote projects in JetBrains IDEs over SSH (#17271)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 155 insertions(+), 24 deletions(-)

apps/desktop/src/electron/ElectronShell.test.ts+20-2
......@@ -106,6 +106,20 @@ describe("ElectronShell", () => {
106106 }).pipe(Effect.provide(ElectronShell.layer)),
107107 );
108108
109 it.effect("opens the JetBrains Toolbox ssh deep link", () =>
110 Effect.gen(function* () {
111 openExternalMock.mockResolvedValue(undefined);
112
113 const url =
114 "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject";
115 const electronShell = yield* ElectronShell.ElectronShell;
116 const result = yield* electronShell.openExternal(url);
117
118 assert.equal(result, true);
119 assert.deepEqual(openExternalMock.mock.calls, [[url]]);
120 }).pipe(Effect.provide(ElectronShell.layer)),
121 );
122
109123 it.effect("does not open editor URLs that mix up link shapes", () =>
110124 Effect.gen(function* () {
111125 openExternalMock.mockResolvedValue(undefined);
......@@ -114,9 +128,12 @@ describe("ElectronShell", () => {
114128 const results = yield* Effect.all([
115129 electronShell.openExternal("zed://extension/attacker"),
116130 electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
131 electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"),
132 electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"),
133 electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"),
117134 ]);
118135
119 assert.deepEqual(results, [false, false]);
136 assert.deepEqual(results, [false, false, false, false, false]);
120137 assert.equal(openExternalMock.mock.calls.length, 0);
121138 }).pipe(Effect.provide(ElectronShell.layer)),
122139 );
......@@ -134,9 +151,10 @@ describe("ElectronShell", () => {
134151 "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
135152 ),
136153 electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
154 electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"),
137155 ]);
138156
139 assert.deepEqual(results, [false, false, false]);
157 assert.deepEqual(results, [false, false, false, false]);
140158 assert.equal(openExternalMock.mock.calls.length, 0);
141159 }).pipe(Effect.provide(ElectronShell.layer)),
142160 );
apps/desktop/src/electron/ElectronShell.ts+9-5
......@@ -12,8 +12,8 @@ import * as Option from "effect/Option";
1212import * as Electron from "electron";
1313
1414// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
15// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
16// scheme stays blocked.
15// `zed://ssh/<host>/<path>`, `jetbrains://gateway/ssh/environment?h=…`) must
16// reach the OS handler; every other non-web scheme stays blocked.
1717const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
1818const REMOTE_EDITOR_PROTOCOLS = new Set(
1919 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
......@@ -31,9 +31,13 @@ const isRemoteEditorUrl = (url: URL) =>
3131 url.password.length === 0 &&
3232 (url.protocol === "zed:"
3333 ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
34 : url.host === "vscode-remote" &&
35 url.pathname.startsWith("/ssh-remote+") &&
36 url.pathname.length > "/ssh-remote+".length);
34 : url.protocol === "jetbrains:"
35 ? url.host === "gateway" &&
36 url.pathname === "/ssh/environment" &&
37 (url.searchParams.get("h") ?? "").length > 0
38 : url.host === "vscode-remote" &&
39 url.pathname.startsWith("/ssh-remote+") &&
40 url.pathname.length > "/ssh-remote+".length);
3741
3842export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
3943 if (typeof rawUrl !== "string") {
apps/web/src/remoteOpen.test.ts+18-1
......@@ -160,8 +160,25 @@ describe("buildRemoteOpenUrl", () => {
160160 );
161161 });
162162
163 it("builds a Toolbox ssh deep link for JetBrains IDEs", () => {
164 expect(
165 buildRemoteOpenUrl({
166 editor: "pycharm",
167 host: "sol.tail1234.ts.net",
168 absolutePath: "/home/theo/code/my repo",
169 }),
170 ).toBe(
171 "jetbrains://gateway/ssh/environment?h=sol.tail1234.ts.net&launchIde=true&ideHint=PY&projectHint=%2Fhome%2Ftheo%2Fcode%2Fmy+repo",
172 );
173 expect(
174 buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "C:\\Users\\theo" }),
175 ).toBe(
176 "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=C%3A%2FUsers%2Ftheo",
177 );
178 });
179
163180 it("returns undefined for editors without remote support", () => {
164 expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe(
181 expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe(
165182 undefined,
166183 );
167184 });
packages/contracts/src/editor.ts+108-16
......@@ -17,6 +17,12 @@ type EditorDefinition = {
1717 * `zed://ssh/<host><path>` shape.
1818 */
1919 readonly remoteScheme?: string;
20 /**
21 * JetBrains product code (`IU`, `PY`, ...). JetBrains IDEs open remote
22 * projects through the Toolbox App's `jetbrains://gateway/ssh/...` link,
23 * which uses the code to choose the IDE backend.
24 */
25 readonly jetbrainsProductCode?: string;
2026};
2127
2228export const EDITORS = [
......@@ -67,18 +73,90 @@ export const EDITORS = [
6773 commands: ["antigravity-ide", "agy-ide"],
6874 launchStyle: "goto",
6975 },
70 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
71 { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" },
72 { id: "clion", label: "CLion", commands: ["clion"], launchStyle: "line-column" },
73 { id: "datagrip", label: "DataGrip", commands: ["datagrip"], launchStyle: "line-column" },
74 { id: "dataspell", label: "DataSpell", commands: ["dataspell"], launchStyle: "line-column" },
75 { id: "goland", label: "GoLand", commands: ["goland"], launchStyle: "line-column" },
76 { id: "phpstorm", label: "PhpStorm", commands: ["phpstorm"], launchStyle: "line-column" },
77 { id: "pycharm", label: "PyCharm", commands: ["pycharm"], launchStyle: "line-column" },
78 { id: "rider", label: "Rider", commands: ["rider"], launchStyle: "line-column" },
79 { id: "rubymine", label: "RubyMine", commands: ["rubymine"], launchStyle: "line-column" },
80 { id: "rustrover", label: "RustRover", commands: ["rustrover"], launchStyle: "line-column" },
81 { id: "webstorm", label: "WebStorm", commands: ["webstorm"], launchStyle: "line-column" },
76 {
77 id: "idea",
78 label: "IntelliJ IDEA",
79 commands: ["idea"],
80 launchStyle: "line-column",
81 jetbrainsProductCode: "IU",
82 },
83 {
84 id: "aqua",
85 label: "Aqua",
86 commands: ["aqua"],
87 launchStyle: "line-column",
88 jetbrainsProductCode: "QA",
89 },
90 {
91 id: "clion",
92 label: "CLion",
93 commands: ["clion"],
94 launchStyle: "line-column",
95 jetbrainsProductCode: "CL",
96 },
97 {
98 id: "datagrip",
99 label: "DataGrip",
100 commands: ["datagrip"],
101 launchStyle: "line-column",
102 jetbrainsProductCode: "DB",
103 },
104 {
105 id: "dataspell",
106 label: "DataSpell",
107 commands: ["dataspell"],
108 launchStyle: "line-column",
109 jetbrainsProductCode: "DS",
110 },
111 {
112 id: "goland",
113 label: "GoLand",
114 commands: ["goland"],
115 launchStyle: "line-column",
116 jetbrainsProductCode: "GO",
117 },
118 {
119 id: "phpstorm",
120 label: "PhpStorm",
121 commands: ["phpstorm"],
122 launchStyle: "line-column",
123 jetbrainsProductCode: "PS",
124 },
125 {
126 id: "pycharm",
127 label: "PyCharm",
128 commands: ["pycharm"],
129 launchStyle: "line-column",
130 jetbrainsProductCode: "PY",
131 },
132 {
133 id: "rider",
134 label: "Rider",
135 commands: ["rider"],
136 launchStyle: "line-column",
137 jetbrainsProductCode: "RD",
138 },
139 {
140 id: "rubymine",
141 label: "RubyMine",
142 commands: ["rubymine"],
143 launchStyle: "line-column",
144 jetbrainsProductCode: "RM",
145 },
146 {
147 id: "rustrover",
148 label: "RustRover",
149 commands: ["rustrover"],
150 launchStyle: "line-column",
151 jetbrainsProductCode: "RR",
152 },
153 {
154 id: "webstorm",
155 label: "WebStorm",
156 commands: ["webstorm"],
157 launchStyle: "line-column",
158 jetbrainsProductCode: "WS",
159 },
82160 { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" },
83161] as const satisfies ReadonlyArray<EditorDefinition>;
84162
......@@ -98,7 +176,8 @@ export const LaunchEditorInput = Schema.Struct({
98176});
99177export type LaunchEditorInput = typeof LaunchEditorInput.Type;
100178
101const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme;
179const remoteSchemeOf = (editor: EditorDefinition): string | undefined =>
180 editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains");
102181
103182/** Editors that can open a remote workspace via an SSH deep link. */
104183export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>
......@@ -112,9 +191,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
112191
113192/**
114193 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
115 * takes `zed://ssh/<host><path>`) that opens `absolutePath` on `host` in the
116 * local editor over SSH. Returns undefined for editors without remote
117 * deep-link support.
194 * takes `zed://ssh/<host><path>`, JetBrains IDEs a Toolbox App
195 * `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on
196 * `host` in the local editor over SSH. Returns undefined for editors without
197 * remote deep-link support.
118198 */
119199export const buildRemoteOpenUrl = (input: {
120200 readonly editor: EditorId;
......@@ -125,6 +205,18 @@ export const buildRemoteOpenUrl = (input: {
125205 if (scheme === undefined) {
126206 return undefined;
127207 }
208 const editor = EDITORS.find((candidate) => candidate.id === input.editor);
209 if (editor !== undefined && "jetbrainsProductCode" in editor) {
210 // Like the VS Code link, no user or port: the SSH config entry for `host`
211 // supplies them. A bare product code lets Toolbox pick the backend build.
212 const params = new URLSearchParams({
213 h: input.host,
214 launchIde: "true",
215 ideHint: editor.jetbrainsProductCode,
216 projectHint: input.absolutePath.replaceAll("\\", "/"),
217 });
218 return `${scheme}://gateway/ssh/environment?${params.toString()}`;
219 }
128220 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
129221 const posixPath = input.absolutePath.replaceAll("\\", "/");
130222 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;