diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..75eea216df21 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -88,6 +88,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -100,9 +127,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index cda9c2567b37..2089be58c0dc 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, +// `zed://ssh//`) 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) => { @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (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); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..6f7c17d8177a 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds Zed's ssh deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 72efd84a14d6..6331a544d341 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -13,7 +13,8 @@ type EditorDefinition = { /** * URL scheme for editors that support VS Code's remote deep links * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. + * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own + * `zed://ssh/` shape. */ readonly remoteScheme?: string; }; @@ -49,7 +50,13 @@ export const EDITORS = [ launchStyle: "goto", remoteScheme: "vscodium", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { }; /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds a `://vscode-remote/ssh-remote+` deep link (Zed + * takes `zed://ssh/`) 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; @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + return input.editor === "zed" + ? `${scheme}://ssh/${encodedHost}${encodedPath}` + : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /**