Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down
43 changes: 31 additions & 12 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
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";
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<string, RemoteEditorUrlStyle>(
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<string> {
if (typeof rawUrl !== "string") {
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Expand Down
12 changes: 12 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host-key>/`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop.
Expand Down
49 changes: 36 additions & 13 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,17 @@ 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;
readonly label: string;
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
* (`<scheme>://vscode-remote/ssh-remote+<host><path>`). 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 = [
Expand All @@ -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" },
Expand All @@ -34,22 +33,32 @@ export const EDITORS = [
commands: ["code"],
launchStyle: "goto",
remoteScheme: "vscode",
remoteUrlStyle: "vscode-remote",
},
{
id: "vscode-insiders",
label: "VS Code Insiders",
commands: ["code-insiders"],
launchStyle: "goto",
remoteScheme: "vscode-insiders",
remoteUrlStyle: "vscode-remote",
},
{
id: "vscodium",
label: "VSCodium",
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" },
Expand All @@ -76,36 +85,50 @@ 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<EditorId> = EDITORS.flatMap((editor) =>
remoteSchemeOf(editor) !== undefined ? [editor.id] : [],
remoteSchemeOf(editor) !== undefined && remoteUrlStyleOf(editor) !== undefined ? [editor.id] : [],
);

export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
const editor = EDITORS.find((candidate) => candidate.id === id);
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 `<scheme>://vscode-remote/ssh-remote+<host><path>` 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;
readonly host: string;
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}`;
}
};

/**
Expand Down
Loading