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", () => {...@@ -88,6 +88,33 @@ describe("ElectronShell", () => {
88 }).pipe(Effect.provide(ElectronShell.layer)),88 }).pipe(Effect.provide(ElectronShell.layer)),
89 );89 );
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
91 it.effect("does not open remote editor URLs with userinfo", () =>118 it.effect("does not open remote editor URLs with userinfo", () =>
92 Effect.gen(function* () {119 Effect.gen(function* () {
93 openExternalMock.mockResolvedValue(undefined);120 openExternalMock.mockResolvedValue(undefined);
...@@ -100,9 +127,10 @@ describe("ElectronShell", () => {...@@ -100,9 +127,10 @@ describe("ElectronShell", () => {
100 electronShell.openExternal(127 electronShell.openExternal(
101 "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",128 "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project",
102 ),129 ),
130 electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
103 ]);131 ]);
104132
105 assert.deepEqual(results, [false, false]);133 assert.deepEqual(results, [false, false, false]);
106 assert.equal(openExternalMock.mock.calls.length, 0);134 assert.equal(openExternalMock.mock.calls.length, 0);
107 }).pipe(Effect.provide(ElectronShell.layer)),135 }).pipe(Effect.provide(ElectronShell.layer)),
108 );136 );
apps/desktop/src/electron/ElectronShell.ts+11-5
...@@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {...@@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
24 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",24 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
25};25};
2626
27// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)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.28// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
29// scheme stays blocked.
29const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);30const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
30const REMOTE_EDITOR_PROTOCOLS = new Set(31const REMOTE_EDITOR_PROTOCOLS = new Set(
31 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {32 REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
...@@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(...@@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
34 }),35 }),
35);36);
3637
38// Zed's host sits in the first path segment, so it needs its own userinfo ban.
39const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;
40
37const isRemoteEditorUrl = (url: URL) =>41const isRemoteEditorUrl = (url: URL) =>
38 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&42 REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
39 url.username.length === 0 &&43 url.username.length === 0 &&
40 url.password.length === 0 &&44 url.password.length === 0 &&
41 url.host === "vscode-remote" &&45 (url.protocol === "zed:"
42 url.pathname.startsWith("/ssh-remote+") &&46 ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname)
43 url.pathname.length > "/ssh-remote+".length;47 : url.host === "vscode-remote" &&
48 url.pathname.startsWith("/ssh-remote+") &&
49 url.pathname.length > "/ssh-remote+".length);
4450
45export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {51export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
46 if (typeof rawUrl !== "string") {52 if (typeof rawUrl !== "string") {
apps/web/src/remoteOpen.test.ts+11-1
...@@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => {...@@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => {
141 ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo");141 ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo");
142 });142 });
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
144 it("returns undefined for editors without remote support", () => {154 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(
146 undefined,156 undefined,
147 );157 );
148 });158 });
packages/contracts/src/editor.ts+17-6
...@@ -13,7 +13,8 @@ type EditorDefinition = {...@@ -13,7 +13,8 @@ type EditorDefinition = {
13 /**13 /**
14 * URL scheme for editors that support VS Code's remote deep links14 * URL scheme for editors that support VS Code's remote deep links
15 * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code15 * (`<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.
17 */18 */
18 readonly remoteScheme?: string;19 readonly remoteScheme?: string;
19};20};
...@@ -49,7 +50,13 @@ export const EDITORS = [...@@ -49,7 +50,13 @@ export const EDITORS = [
49 launchStyle: "goto",50 launchStyle: "goto",
50 remoteScheme: "vscodium",51 remoteScheme: "vscodium",
51 },52 },
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 },
53 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },60 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
54 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },61 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
55 { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" },62 { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" },
...@@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {...@@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
95};102};
96103
97/**104/**
98 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that105 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
99 * opens `absolutePath` on `host` in the local editor over SSH. Returns106 * takes `zed://ssh/<host><path>`) that opens `absolutePath` on `host` in the
100 * undefined for editors without remote deep-link support.107 * local editor over SSH. Returns undefined for editors without remote
108 * deep-link support.
101 */109 */
102export const buildRemoteOpenUrl = (input: {110export const buildRemoteOpenUrl = (input: {
103 readonly editor: EditorId;111 readonly editor: EditorId;
...@@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: {...@@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: {
112 const posixPath = input.absolutePath.replaceAll("\\", "/");120 const posixPath = input.absolutePath.replaceAll("\\", "/");
113 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;121 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
114 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");122 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}`;
116};127};
117128
118/**129/**