diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 17f3e06039b6..ae0d2256e64e 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -1,13 +1,18 @@ import { assert, describe, it } from "@effect/vitest"; +import { buildRemoteOpenUrl } from "@t3tools/contracts"; 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 +25,34 @@ 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("treats failed protocol lookups as unavailable", () => + Effect.gen(function* () { + getApplicationNameForProtocolMock.mockImplementation(() => { + throw new Error("protocol lookup unavailable"); + }); + + const electronShell = yield* ElectronShell.ElectronShell; + assert.equal(yield* electronShell.hasProtocolHandler("zed"), false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -66,6 +95,39 @@ 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("hands a generated Zed worktree link to the OS without losing path characters", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + const url = buildRemoteOpenUrl({ + editor: "zed", + host: "zeus", + absolutePath: "/home/agent/code/my repo/feature#1%done", + }); + + const electronShell = yield* ElectronShell.ElectronShell; + assert.equal(yield* electronShell.openExternal(url), true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["zed://ssh/zeus/home/agent/code/my%20repo/feature%231%25done"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -109,6 +171,24 @@ 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"), + electronShell.openExternal("zed://ssh//home/user/project"), + electronShell.openExternal("zed://:secret@ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false, 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 0ac4f8f9cc6a..27bf3b073460 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,6 +1,6 @@ import { REMOTE_CAPABLE_EDITOR_IDS, - remoteSchemeForEditor, + remoteOpenDefinitionForEditor, type SystemSettingsPane, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -24,23 +24,31 @@ 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 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") { @@ -61,6 +69,7 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly hasProtocolHandler: (scheme: string) => Effect.Effect; /** Opens a known System Settings pane by identifier, not by URL. */ readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; @@ -79,6 +88,14 @@ export const make = ElectronShell.of({ ), ), }), + hasProtocolHandler: (scheme) => + Effect.sync(() => { + try { + return Electron.app.getApplicationNameForProtocol(`${scheme}://`) !== ""; + } catch { + return false; + } + }), openSystemSettings: (pane) => Effect.promise(() => Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 5585457b78fe..7189f4c24486 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,8 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { vi } from "vite-plus/test"; +import * as Path from "effect/Path"; +import { beforeEach, vi } from "vite-plus/test"; import type * as Electron from "electron"; @@ -10,15 +13,65 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronNotification from "../../electron/ElectronNotification.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL } from "../channels.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, pickProjectFavicon, + probeRemoteEditors, showThreadCompletionNotification, } from "./window.ts"; +vi.mock("@t3tools/shared/shell", async (importOriginal) => ({ + ...(await importOriginal()), + isCommandAvailable: vi.fn(), +})); + +describe("probeRemoteEditors", () => { + beforeEach(() => { + vi.mocked(isCommandAvailable).mockReset(); + vi.mocked(isCommandAvailable).mockReturnValue(Effect.succeed(false)); + }); + + const shellLayer = (registeredSchemes: ReadonlyArray) => + Layer.mergeAll( + Layer.succeed(ElectronShell.ElectronShell, { + hasProtocolHandler: (scheme) => Effect.succeed(registeredSchemes.includes(scheme)), + openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), + copyText: () => Effect.void, + }), + FileSystem.layerNoop({}), + Path.layer, + ); + + it.effect("finds Zed through its registered handler when no editor CLI is on PATH", () => + Effect.gen(function* () { + assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["zed"]); + }).pipe(Effect.provide(shellLayer(["zed"]))), + ); + + it.effect("keeps CLI discovery and does not duplicate editors with a handler", () => + Effect.gen(function* () { + vi.mocked(isCommandAvailable).mockImplementation((command) => + Effect.succeed(command === "cursor" || command === "zed"), + ); + assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["cursor", "zed"]); + }).pipe(Effect.provide(shellLayer(["zed"]))), + ); + + it.effect("supports the zeditor CLI alias when protocol detection is unavailable", () => + Effect.gen(function* () { + vi.mocked(isCommandAvailable).mockImplementation((command) => + Effect.succeed(command === "zeditor"), + ); + assert.deepEqual(yield* probeRemoteEditors.handler(undefined), ["zed"]); + }).pipe(Effect.provide(shellLayer([]))), + ); +}); + const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"], diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 129bcba25f01..bde8a312d071 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -10,6 +10,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + remoteOpenDefinitionForEditor, SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, @@ -346,13 +347,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 bdd03865c7bf..c5e4b55f9c55 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,6 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + hasProtocolHandler: () => Effect.succeed(false), openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), @@ -381,6 +382,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + hasProtocolHandler: () => Effect.succeed(false), openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), 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 7f23d408844e..6b8c721d1505 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/personal-fork-changes.md b/docs/personal-fork-changes.md index 19e57239ea38..d69a1abd14d9 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -6,6 +6,21 @@ worktree-grouped web/desktop threads, and cross-environment chat transfer. Every This file is both the current inventory and the retirement record used during upstream syncs. +## Pending upstream integration + +### Open remote projects in Zed + +- The web/desktop Open menu offers Zed for environments with an SSH route and opens the current + project or worktree through the local `zed://ssh//` handler. Desktop detection checks + registered protocol handlers as well as editor commands, so a Finder-launched app can find Zed + without its CLI on PATH. +- Adapted from [upstream PR #8866](https://github.com/pingdotgg/t3code/pull/8866), tracked by + [upstream issue #8938](https://github.com/pingdotgg/t3code/issues/8938). Replace this temporary + integration with upstream behavior when that support lands. +- Changes stay in the clients and shared editor URL helpers. Server behavior and wire schemas + remain compatible with unmodified upstream environments. Zed and Zed Preview share the OS URL + handler; the registered application determines which one opens. + ## Maintained differences ### Desktop fork identity diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04e..6603662c141e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -106,6 +106,13 @@ In the desktop app, open **Settings → Connections → Add environment**, choos or reuses a server there and opens the port forward for you. Projects, provider credentials, and agent work stay on the remote machine. +For an environment with an SSH route, **Open in VS Code** or **Open in Zed** +launches the editor on your current device and connects it to the remote project. +VS Code requires the Remote - SSH extension; Zed supports SSH projects directly. +Zed and Zed Preview share the `zed://` handler, so the release channel that last +registered it opens the link. To choose one, run **cli: register zed scheme** from +its command palette, then restart T3 Code Desktop. + The remote host needs a compatible [Node.js installation](./install.md#requirements) and [provider setup](./install.md#providers). If launch cannot find Node or reports an incompatible version, check it through a non-interactive SSH session: diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 4115b07d7aad..29ab032ac511 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 06b2d0611b48..2bc19b573db5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1139,9 +1139,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 VS Code only. + * 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;