authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-09 23:26:51-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 23:26:51-03:00
log0f602b3372b300ae94084bd3fe7dbaadaa58ba3a
tree9c9617f259e1199b56e7e89e80ec8ff8e74d6e8a
parentd1eeb16247a0bd2eca8bbbfa5ab777e096af949c
signature Signed by PGP key B5690EEEBB952194

fix(editors): open remote projects in Zed (#11022)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

4 files changed, 68 insertions(+), 13 deletions(-)

apps/desktop/src/electron/ElectronShell.test.ts+29-1
......@@ -88,6 +88,33 @@ describe("ElectronShell", () => {
8888 }).pipe(Effect.provide(ElectronShell.layer)),
8989 );
9090
91 it.effect("opens Zed's ssh deep link", () =>
92 Effect.gen(function* () {
93 openExternalMock.mockResolvedValue(undefined);
94
95 const electronShell = yield* ElectronShell.ElectronShell;
96 const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project");
97
98 assert.equal(result, true);
99 assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]);
100 }).pipe(Effect.provide(ElectronShell.layer)),
101 );
102
103 it.effect("does not open editor URLs that mix up link shapes", () =>
104 Effect.gen(function* () {
105 openExternalMock.mockResolvedValue(undefined);
106
107 const electronShell = yield* ElectronShell.ElectronShell;
108 const results = yield* Effect.all([
109 electronShell.openExternal("zed://extension/attacker"),
110 electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
111 ]);
112
113 assert.deepEqual(results, [false, false]);
114 assert.equal(openExternalMock.mock.calls.length, 0);
115 }).pipe(Effect.provide(ElectronShell.layer)),
116 );
117
91118 it.effect("does not open remote editor URLs with userinfo", () =>
92119 Effect.gen(function* () {
93120 openExternalMock.mockResolvedValue(undefined);
......@@ -100,9 +127,10 @@ describe("ElectronShell", () => {
100127 electronShell.openExternal(
101128 "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
102129 ),
130 electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
103131 ]);
104132
105 assert.deepEqual(results, [false, false]);
133 assert.deepEqual(results, [false, false, false]);
106134 assert.equal(openExternalMock.mock.calls.length, 0);
107135 }).pipe(Effect.provide(ElectronShell.layer)),
108136 );
apps/desktop/src/electron/ElectronShell.ts+11-5
......@@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
2424 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
2525};
2626
27// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
28// must reach the OS handler; every other non-web scheme stays blocked.
27// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
28// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
29// scheme stays blocked.
2930const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
3031const REMOTE_EDITOR_PROTOCOLS = new Set(
3132 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
......@@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
3435 }),
3536);
3637
38// Zed's host sits in the first path segment, so it needs its own userinfo ban.
39const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;
40
3741const isRemoteEditorUrl = (url: URL) =>
3842 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
3943 url.username.length === 0 &&
4044 url.password.length === 0 &&
41 url.host === "vscode-remote" &&
42 url.pathname.startsWith("/ssh-remote+") &&
43 url.pathname.length > "/ssh-remote+".length;
45 (url.protocol === "zed:"
46 ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
47 : url.host === "vscode-remote" &&
48 url.pathname.startsWith("/ssh-remote+") &&
49 url.pathname.length > "/ssh-remote+".length);
4450
4551export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
4652 if (typeof rawUrl !== "string") {
apps/web/src/remoteOpen.test.ts+11-1
......@@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => {
141141 ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo");
142142 });
143143
144 it("builds Zed's ssh deep link", () => {
145 expect(
146 buildRemoteOpenUrl({
147 editor: "zed",
148 host: "sol.tail1234.ts.net",
149 absolutePath: "/home/theo/code/my repo",
150 }),
151 ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo");
152 });
153
144154 it("returns undefined for editors without remote support", () => {
145 expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe(
155 expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe(
146156 undefined,
147157 );
148158 });
packages/contracts/src/editor.ts+17-6
......@@ -13,7 +13,8 @@ type EditorDefinition = {
1313 /**
1414 * URL scheme for editors that support VS Code's remote deep links
1515 * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code
16 * and forks that ship the Remote-SSH machinery.
16 * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own
17 * `zed://ssh/<host><path>` shape.
1718 */
1819 readonly remoteScheme?: string;
1920};
......@@ -49,7 +50,13 @@ export const EDITORS = [
4950 launchStyle: "goto",
5051 remoteScheme: "vscodium",
5152 },
52 { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" },
53 {
54 id: "zed",
55 label: "Zed",
56 commands: ["zed", "zeditor"],
57 launchStyle: "direct-path",
58 remoteScheme: "zed",
59 },
5360 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
5461 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
5562 { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" },
......@@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
95102};
96103
97104/**
98 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that
99 * opens `absolutePath` on `host` in the local editor over SSH. Returns
100 * undefined for editors without remote deep-link support.
105 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
106 * takes `zed://ssh/<host><path>`) that opens `absolutePath` on `host` in the
107 * local editor over SSH. Returns undefined for editors without remote
108 * deep-link support.
101109 */
102110export const buildRemoteOpenUrl = (input: {
103111 readonly editor: EditorId;
......@@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: {
112120 const posixPath = input.absolutePath.replaceAll("\\", "/");
113121 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
114122 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
115 return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`;
123 const encodedHost = encodeURIComponent(input.host);
124 return input.editor === "zed"
125 ? `${scheme}://ssh/${encodedHost}${encodedPath}`
126 : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`;
116127};
117128
118129/**