From 7d8d617b35b38bebd7681653dfafec038cc71925 Mon Sep 17 00:00:00 2001 From: Hussein Abdelraouf Date: Sat, 22 Aug 2026 12:42:36 +0000 Subject: [PATCH] feat(web): open remote worktrees in Zed --- .../src/electron/ElectronShell.test.ts | 32 ++++++++++++ apps/desktop/src/electron/ElectronShell.ts | 43 +++++++++++----- apps/web/src/remoteOpen.test.ts | 12 ++++- docs/user/remote-access.md | 12 +++++ packages/contracts/src/editor.ts | 49 ++++++++++++++----- 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..d6b9c61cc726 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -52,6 +52,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed SSH hotlinks", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "zed://ssh/example.com/home/user/my%20project", + ); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["zed://ssh/example.com/home/user/my%20project"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -95,6 +111,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open non-remote Zed URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://settings"), + electronShell.openExternal("zed://ssh/example.com"), + electronShell.openExternal("zed://ssh//home/user/project"), + ]); + + assert.deepEqual(results, [false, false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("returns false when Electron rejects openExternal", () => Effect.gen(function* () { openExternalMock.mockRejectedValue(new Error("open failed")); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..3e4c474036dc 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,9 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + type RemoteEditorUrlStyle, + remoteSchemeForEditor, + remoteUrlStyleForEditor, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,23 +11,37 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// 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 must reach the OS handler; every other +// non-web scheme and every unrelated route within an editor scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); -const REMOTE_EDITOR_PROTOCOLS = new Set( +const REMOTE_EDITOR_STYLES_BY_PROTOCOL = new Map( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { const scheme = remoteSchemeForEditor(id); - return scheme === undefined ? [] : [`${scheme}:`]; + const style = remoteUrlStyleForEditor(id); + return scheme === undefined || style === undefined ? [] : [[`${scheme}:`, style] as const]; }), ); -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; +const isRemoteEditorUrl = (url: URL) => { + if (url.username.length > 0 || url.password.length > 0) { + return false; + } + const style = REMOTE_EDITOR_STYLES_BY_PROTOCOL.get(url.protocol); + switch (style) { + case "vscode-remote": + return ( + url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length + ); + case "zed-ssh": { + const [remoteHost, ...pathSegments] = url.pathname.slice(1).split("/"); + return url.host === "ssh" && remoteHost !== "" && pathSegments.some(Boolean); + } + case undefined: + return false; + } +}; 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..495ebe1b6384 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 hotlink for stable Zed or Zed Preview", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol/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: "trae", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5993fca5b352..b8435fb67590 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -136,6 +136,18 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments. +#### Open a Remote Worktree in a Local Editor + +From a remote thread, **Open** connects a supported editor on the viewing machine to the worktree +over SSH. The local machine must be able to authenticate to the SSH host shown by T3 Code. The +desktop app lists supported editors whose command-line tools it can find locally. + +Zed and Zed Preview both support this through Zed's `zed://ssh/...` links. They share the same +`zed://` system handler, so the Zed release channel that registered the scheme is the one that opens. +To use Preview when both channels are installed, open the command palette in Zed Preview and run +**cli: register zed scheme**. Install its command-line tool as well so the T3 Code desktop app can +detect it. + #### SSH Launch Troubleshooting The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch//`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop. diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index d714a0e02664..78c98e65f6fb 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; +export type RemoteEditorUrlStyle = "vscode-remote" | "zed-ssh"; type EditorDefinition = { readonly id: string; @@ -10,12 +11,9 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; - /** - * 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. - */ + /** URL scheme and shape for opening an SSH workspace in a local editor. */ readonly remoteScheme?: string; + readonly remoteUrlStyle?: RemoteEditorUrlStyle; }; export const EDITORS = [ @@ -25,6 +23,7 @@ export const EDITORS = [ commands: ["cursor"], launchStyle: "goto", remoteScheme: "cursor", + remoteUrlStyle: "vscode-remote", }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, @@ -34,6 +33,7 @@ export const EDITORS = [ commands: ["code"], launchStyle: "goto", remoteScheme: "vscode", + remoteUrlStyle: "vscode-remote", }, { id: "vscode-insiders", @@ -41,6 +41,7 @@ export const EDITORS = [ commands: ["code-insiders"], launchStyle: "goto", remoteScheme: "vscode-insiders", + remoteUrlStyle: "vscode-remote", }, { id: "vscodium", @@ -48,8 +49,16 @@ export const EDITORS = [ commands: ["codium"], launchStyle: "goto", remoteScheme: "vscodium", + remoteUrlStyle: "vscode-remote", + }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + remoteUrlStyle: "zed-ssh", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { 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" }, @@ -76,10 +85,12 @@ export const LaunchEditorInput = Schema.Struct({ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; +const remoteUrlStyleOf = (editor: EditorDefinition): RemoteEditorUrlStyle | undefined => + editor.remoteUrlStyle; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +/** Editors that can open an SSH workspace through a local deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => - remoteSchemeOf(editor) !== undefined ? [editor.id] : [], + remoteSchemeOf(editor) !== undefined && remoteUrlStyleOf(editor) !== undefined ? [editor.id] : [], ); export const remoteSchemeForEditor = (id: EditorId): string | undefined => { @@ -87,10 +98,15 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { return editor === undefined ? undefined : remoteSchemeOf(editor); }; +export const remoteUrlStyleForEditor = (id: EditorId): RemoteEditorUrlStyle | undefined => { + const editor = EDITORS.find((candidate) => candidate.id === id); + return editor === undefined ? undefined : remoteUrlStyleOf(editor); +}; + /** - * 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 an editor-specific deep 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; @@ -98,14 +114,21 @@ export const buildRemoteOpenUrl = (input: { readonly absolutePath: string; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); - if (scheme === undefined) { + const style = remoteUrlStyleForEditor(input.editor); + if (scheme === undefined || style === undefined) { return undefined; } // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. 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); + switch (style) { + case "vscode-remote": + return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; + case "zed-ssh": + return `${scheme}://ssh/${encodedHost}${encodedPath}`; + } }; /**