From 0104f84a5eb05fe0711365dd4ab5c93537d3f55c Mon Sep 17 00:00:00 2001 From: Alex Tacescu Date: Sun, 30 Aug 2026 23:57:04 -0700 Subject: [PATCH 1/3] feat(desktop,web): support Zed remote project deep links - Detect installed editor protocol handlers on desktop - Generate and validate editor-specific SSH links for Zed - Document remote editor opening behavior --- .../src/electron/ElectronShell.test.ts | 51 ++++++++++++++- apps/desktop/src/electron/ElectronShell.ts | 47 +++++++++----- apps/desktop/src/ipc/methods/window.ts | 15 +++-- apps/desktop/src/window/DesktopWindow.test.ts | 2 + apps/web/src/remoteOpen.test.ts | 12 +++- apps/web/src/remoteOpen.ts | 11 ++-- docs/user/remote-access.md | 15 +++++ packages/contracts/src/editor.ts | 62 ++++++++++++------- packages/contracts/src/ipc.ts | 2 +- 9 files changed, 167 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..c3383e1b5074 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -2,12 +2,16 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ +const { getApplicationNameForProtocolMock, openExternalMock, writeTextMock } = vi.hoisted(() => ({ + getApplicationNameForProtocolMock: vi.fn(), openExternalMock: vi.fn(), writeTextMock: vi.fn(), })); vi.mock("electron", () => ({ + app: { + getApplicationNameForProtocol: getApplicationNameForProtocolMock, + }, shell: { openExternal: openExternalMock, }, @@ -20,10 +24,23 @@ import * as ElectronShell from "./ElectronShell.ts"; describe("ElectronShell", () => { beforeEach(() => { + getApplicationNameForProtocolMock.mockReset(); openExternalMock.mockReset(); writeTextMock.mockReset(); }); + it.effect("detects registered editor protocol handlers", () => + Effect.gen(function* () { + getApplicationNameForProtocolMock.mockImplementation((url: string) => + url === "zed://" ? "Zed" : "", + ); + + const electronShell = yield* ElectronShell.ElectronShell; + assert.equal(yield* electronShell.hasProtocolHandler("zed"), true); + assert.equal(yield* electronShell.hasProtocolHandler("vscode"), false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -52,6 +69,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed remote SSH editor URLs", () => + 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 +128,22 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open non-SSH or malformed Zed URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://file/example.com/home/user/project"), + electronShell.openExternal("zed://user@ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com"), + ]); + + 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..ba7f39ee9db2 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,4 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { REMOTE_CAPABLE_EDITOR_IDS, remoteOpenDefinitionForEditor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,23 +6,31 @@ 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 stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); -const REMOTE_EDITOR_PROTOCOLS = new Set( - REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { - const scheme = remoteSchemeForEditor(id); - return scheme === undefined ? [] : [`${scheme}:`]; - }), -); +const REMOTE_EDITOR_OPEN_DEFINITIONS = REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const definition = remoteOpenDefinitionForEditor(id); + return definition === undefined ? [] : [definition]; +}); + +const hasNoUrlCredentials = (url: URL) => url.username.length === 0 && url.password.length === 0; 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; + hasNoUrlCredentials(url) && + REMOTE_EDITOR_OPEN_DEFINITIONS.some((definition) => { + if (url.protocol !== `${definition.scheme}:` || url.host !== definition.urlHost) { + return false; + } + + const pathPrefix = `/${definition.sshPathPrefix}`; + if (!url.pathname.startsWith(pathPrefix)) { + return false; + } + + const sshTargetAndPath = url.pathname.slice(pathPrefix.length); + return sshTargetAndPath.indexOf("/") > 0; + }); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { @@ -43,6 +51,7 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly hasProtocolHandler: (scheme: string) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +68,14 @@ export const make = ElectronShell.of({ ), ), }), + hasProtocolHandler: (scheme) => + Effect.sync(() => { + try { + return Electron.app.getApplicationNameForProtocol(`${scheme}://`) !== ""; + } catch { + return false; + } + }), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..f66023d2b842 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + remoteOpenDefinitionForEditor, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -302,13 +303,19 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, result: Schema.Array(EditorId), - // Probes THIS machine (where the renderer runs) for remote-capable editor - // CLIs, unlike the server's probe which walks the environment host's PATH. - // A Finder-launched app can miss PATH entries; an empty result makes the - // renderer fall back to VS Code only, so that fails soft. + // Probes THIS machine (where the renderer runs), unlike the server's probe + // which walks the environment host's PATH. Protocol handlers cover packaged + // editors whose optional CLI is absent from a Finder-launched app's PATH. handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { + const shell = yield* ElectronShell.ElectronShell; const available: Array = []; for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { + const remoteOpen = remoteOpenDefinitionForEditor(editorId); + if (remoteOpen !== undefined && (yield* shell.hasProtocolHandler(remoteOpen.scheme))) { + available.push(editorId); + continue; + } + const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; if (!commands) continue; for (const command of commands) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..fb4713bb4aff 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -276,6 +276,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + hasProtocolHandler: () => Effect.succeed(false), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -376,6 +377,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + hasProtocolHandler: () => Effect.succeed(false), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..0f095b31313a 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 a Zed 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/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index c29f8d7389ed..802944ec3a16 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,8 +1,8 @@ /** * Remote open-in-editor: when this client is not on the environment's - * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` - * deep link (local editor connects over SSH) instead of exec'ing an editor - * on the environment host. + * machine, "Open" must hand the OS an editor-specific deep link (the local + * editor connects over SSH) instead of exec'ing an editor on the environment + * host. * * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias * beats server-advertised names; among advertised names the tailnet MagicDNS @@ -124,9 +124,10 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO /** * Editors offered in remote-link mode. The desktop app probes the machine the - * renderer runs on; a browser cannot, so it offers VS Code only. + * renderer runs on; a browser cannot, so it offers the primary supported + * editors and lets the OS protocol handler decide whether they are installed. */ -const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; +const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode", "zed"]; let cachedProbedEditors: ReadonlyArray | null = null; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 40473930c601..43da470edbfc 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -136,6 +136,21 @@ 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. +#### Opening a Remote Project in an Editor + +When a remote environment has an SSH route, **Open in VS Code** and **Open in Zed** launch the +editor on the device viewing T3 Code and connect it to the project over SSH. The project files, +terminals, language servers, and other remote tooling continue to run on the environment machine. + +For desktop-managed SSH environments, T3 Code reuses the saved SSH configuration alias. For other +remote environments, the server can advertise a Tailscale MagicDNS or local mDNS hostname when an +SSH server is listening. The viewing device must be able to SSH to that target with its own keys or +SSH agent. + +VS Code requires the Remote - SSH extension. Zed handles SSH projects directly. Browser clients +cannot detect which editors are installed, so they offer both and hand the selected link to the +operating system. T3 Connect carries T3 Code traffic but does not expose an SSH route by itself. + #### 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 b7a7dd7c7307..2914e473ff50 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,12 +10,13 @@ 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. - */ - readonly remoteScheme?: string; + readonly remoteOpen?: RemoteEditorOpenDefinition; +}; + +export type RemoteEditorOpenDefinition = { + readonly scheme: string; + readonly urlHost: string; + readonly sshPathPrefix: string; }; export const EDITORS = [ @@ -24,7 +25,7 @@ export const EDITORS = [ label: "Cursor", commands: ["cursor"], launchStyle: "goto", - remoteScheme: "cursor", + remoteOpen: { scheme: "cursor", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, @@ -33,23 +34,33 @@ export const EDITORS = [ label: "VS Code", commands: ["code"], launchStyle: "goto", - remoteScheme: "vscode", + remoteOpen: { scheme: "vscode", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, }, { id: "vscode-insiders", label: "VS Code Insiders", commands: ["code-insiders"], launchStyle: "goto", - remoteScheme: "vscode-insiders", + remoteOpen: { + scheme: "vscode-insiders", + urlHost: "vscode-remote", + sshPathPrefix: "ssh-remote+", + }, }, { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto", - remoteScheme: "vscodium", + remoteOpen: { scheme: "vscodium", urlHost: "vscode-remote", sshPathPrefix: "ssh-remote+" }, + }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteOpen: { scheme: "zed", urlHost: "ssh", sshPathPrefix: "" }, }, - { 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" }, @@ -82,37 +93,42 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; +const remoteOpenOf = (editor: EditorDefinition): RemoteEditorOpenDefinition | undefined => + editor.remoteOpen; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +/** Editors that can open a remote workspace over SSH from the viewing machine. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => - remoteSchemeOf(editor) !== undefined ? [editor.id] : [], + remoteOpenOf(editor) !== undefined ? [editor.id] : [], ); -export const remoteSchemeForEditor = (id: EditorId): string | undefined => { +export const remoteOpenDefinitionForEditor = ( + id: EditorId, +): RemoteEditorOpenDefinition | undefined => { const editor = EDITORS.find((candidate) => candidate.id === id); - return editor === undefined ? undefined : remoteSchemeOf(editor); + return editor === undefined ? undefined : remoteOpenOf(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 the editor-specific deep link that opens `absolutePath` on `host` + * from the viewing machine over SSH. Returns undefined for editors without + * remote deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; readonly absolutePath: string; }): string | undefined => { - const scheme = remoteSchemeForEditor(input.editor); - if (scheme === undefined) { + const remoteOpen = remoteOpenDefinitionForEditor(input.editor); + if (remoteOpen === undefined) { return undefined; } - // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + // Windows server paths (`C:\...`) appear as `/C:/...` in remote editor 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); + + return `${remoteOpen.scheme}://${remoteOpen.urlHost}/${remoteOpen.sshPathPrefix}${encodedHost}${encodedPath}`; }; /** diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 19300aac328d..81e13ac96f7d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1135,7 +1135,7 @@ export interface DesktopBridge { /** * Probe this desktop machine for installed remote-capable editor CLIs * (used for remote open-in-editor deep links). Optional: older desktop - * builds lack it; callers fall back to VS Code only. + * builds lack it; callers fall back to the primary supported editors. */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; From e677829f802d736a5c00396b7bc83360480a17b2 Mon Sep 17 00:00:00 2001 From: Alex Tacescu Date: Fri, 4 Sep 2026 20:19:43 -0700 Subject: [PATCH 2/3] docs(remote): explain Zed Preview URL handler --- docs/user/remote-access.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 43da470edbfc..e57f69b9eb6d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -147,9 +147,7 @@ remote environments, the server can advertise a Tailscale MagicDNS or local mDNS SSH server is listening. The viewing device must be able to SSH to that target with its own keys or SSH agent. -VS Code requires the Remote - SSH extension. Zed handles SSH projects directly. Browser clients -cannot detect which editors are installed, so they offer both and hand the selected link to the -operating system. T3 Connect carries T3 Code traffic but does not expose an SSH route by itself. +VS Code requires the Remote - SSH extension. Zed handles projects on remote computers over SSH without the need of any additional plugin. Zed and Zed Preview share the `zed://` URL handler, so whichever instance has last been connected to the CLI will be used. To choose an instance, open its command palette and run **cli: register zed scheme**, then restart T3 Code Desktop afterward. #### SSH Launch Troubleshooting From 970cfaec6cc6f1fd0e2c95c0b4cbdec3ca299d99 Mon Sep 17 00:00:00 2001 From: Alex Tacescu Date: Mon, 7 Sep 2026 20:51:58 -0700 Subject: [PATCH 3/3] docs(contracts): clarify remote editor probing --- packages/contracts/src/ipc.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09b380d4e672..ce7df5f6c16f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1132,9 +1132,10 @@ export interface DesktopBridge { */ openSystemSettings?: (pane: SystemSettingsPane) => Promise; /** - * Probe this desktop machine for installed remote-capable editor CLIs - * (used for remote open-in-editor deep links). Optional: older desktop - * builds lack it; callers fall back to the primary supported editors. + * Probe this desktop machine for installed remote editor CLIs or registered + * protocol handlers. A handler can make an editor available without its CLI + * on PATH. Optional: older desktop builds lack it; callers fall back to the + * primary supported editors. */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void;