Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)),
);
Expand All @@ -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)),
);
Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>/<path>`) must reach the OS handler; every other non-web
// scheme stays blocked.
// `zed://ssh/<host>/<path>`, `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) => {
Expand All @@ -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<string> {
if (typeof rawUrl !== "string") {
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Expand Down
124 changes: 108 additions & 16 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ type EditorDefinition = {
* `zed://ssh/<host><path>` 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 = [
Expand Down Expand Up @@ -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<EditorDefinition>;

Expand All @@ -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<EditorId> = EDITORS.flatMap((editor) =>
Expand All @@ -112,9 +191,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {

/**
* Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
* takes `zed://ssh/<host><path>`) that opens `absolutePath` on `host` in the
* local editor over SSH. Returns undefined for editors without remote
* deep-link support.
* takes `zed://ssh/<host><path>`, 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;
Expand All @@ -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}`;
Expand Down
Loading