Skip to content
This repository was archived by the owner on Nov 2, 2026. It is now read-only.

Commit a326e06

Browse files
maria-rcksclaude
andauthored
fix(editors): open remote projects in Zed (pingdotgg#11022)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5590568 commit a326e06

4 files changed

Lines changed: 68 additions & 13 deletions

File tree

‎apps/desktop/src/electron/ElectronShell.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2930
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
3031
const 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.
39+
const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/;
40+
3741
const 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

4551
export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
4652
if (typeof rawUrl !== "string") {

‎apps/web/src/remoteOpen.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
102110
export 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
/**

0 commit comments

Comments
 (0)