| author | |
| committer | |
| log | 57a299a7852b430613dfdd97ba76249ee9f374d5 |
| tree | 311db911020f881694e85d0657d82a851e4ba1d3 |
| parent | 74f7b434865c2d758c7b1cd5f52f4c96b76d03fb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>24 files changed, 806 insertions(+), 46 deletions(-)
apps/desktop/src/electron/ElectronShell.ts+11-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; | ... | @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; |
| 5 | 6 | ||
| 6 | import * as Electron from "electron"; | 7 | import * as Electron from "electron"; |
| 7 | 8 | ||
| 8 | const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); | 9 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) |
| 10 | // must reach the OS handler; every other non-web scheme stays blocked. | ||
| 11 | const SAFE_EXTERNAL_PROTOCOLS = new Set([ | ||
| 12 | "http:", | ||
| 13 | "https:", | ||
| 14 | ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { | ||
| 15 | const scheme = remoteSchemeForEditor(id); | ||
| 16 | return scheme === undefined ? [] : [`${scheme}:`]; | ||
| 17 | }), | ||
| 18 | ]); | ||
| 9 | 19 | ||
| 10 | export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { | 20 | export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { |
| 11 | if (typeof rawUrl !== "string") { | 21 | if (typeof rawUrl !== "string") { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | getLocalEnvironmentBearerToken, | 36 | getLocalEnvironmentBearerToken, |
| 37 | getWindowFullscreenState, | 37 | getWindowFullscreenState, |
| 38 | openExternal, | 38 | openExternal, |
| 39 | probeRemoteEditors, | ||
| 39 | pickFolder, | 40 | pickFolder, |
| 40 | pickThemeFiles, | 41 | pickThemeFiles, |
| 41 | setTheme, | 42 | setTheme, |
| ... | @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 83 | yield* ipc.handle(setTheme); | 84 | yield* ipc.handle(setTheme); |
| 84 | yield* ipc.handle(showContextMenu); | 85 | yield* ipc.handle(showContextMenu); |
| 85 | yield* ipc.handle(openExternal); | 86 | yield* ipc.handle(openExternal); |
| 87 | yield* ipc.handle(probeRemoteEditors); | ||
| 86 | yield* ipc.handle(getUpdateState); | 88 | yield* ipc.handle(getUpdateState); |
| 87 | yield* ipc.handle(setUpdateChannel); | 89 | yield* ipc.handle(setUpdateChannel); |
| 88 | yield* ipc.handle(downloadUpdate); | 90 | yield* ipc.handle(downloadUpdate); |
apps/desktop/src/ipc/channels.ts+1| ... | @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | ... | @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; | ||
| 6 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 7 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 7 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; | 8 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
| 8 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; | 9 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; |
apps/desktop/src/ipc/methods/window.ts+28| ... | @@ -3,12 +3,16 @@ import { | ... | @@ -3,12 +3,16 @@ import { |
| 3 | DesktopAppBrandingSchema, | 3 | DesktopAppBrandingSchema, |
| 4 | DesktopEnvironmentBootstrapSchema, | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | DesktopThemeSchema, | 5 | DesktopThemeSchema, |
| 6 | EDITORS, | ||
| 7 | EditorId, | ||
| 6 | PickedThemeFileSchema, | 8 | PickedThemeFileSchema, |
| 7 | PickFolderOptionsSchema, | 9 | PickFolderOptionsSchema, |
| 8 | PRIMARY_LOCAL_ENVIRONMENT_ID, | 10 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 11 | REMOTE_CAPABLE_EDITOR_IDS, | ||
| 9 | type DesktopEnvironmentBootstrap, | 12 | type DesktopEnvironmentBootstrap, |
| 10 | type PickedThemeFile, | 13 | type PickedThemeFile, |
| 11 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { isCommandAvailable } from "@t3tools/shared/shell"; | ||
| 12 | import * as NodeOS from "node:os"; | 16 | import * as NodeOS from "node:os"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 17 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Path from "effect/Path"; | 18 | import * as Path from "effect/Path"; |
| ... | @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ | ... | @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ |
| 261 | }), | 265 | }), |
| 262 | }); | 266 | }); |
| 263 | 267 | ||
| 268 | export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ | ||
| 269 | channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, | ||
| 270 | payload: Schema.Undefined, | ||
| 271 | result: Schema.Array(EditorId), | ||
| 272 | // Probes THIS machine (where the renderer runs) for remote-capable editor | ||
| 273 | // CLIs, unlike the server's probe which walks the environment host's PATH. | ||
| 274 | // A Finder-launched app can miss PATH entries; an empty result makes the | ||
| 275 | // renderer fall back to VS Code only, so that fails soft. | ||
| 276 | handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { | ||
| 277 | const available: Array<EditorId> = []; | ||
| 278 | for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { | ||
| 279 | const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; | ||
| 280 | if (!commands) continue; | ||
| 281 | for (const command of commands) { | ||
| 282 | if (yield* isCommandAvailable(command, { env: process.env })) { | ||
| 283 | available.push(editorId); | ||
| 284 | break; | ||
| 285 | } | ||
| 286 | } | ||
| 287 | } | ||
| 288 | return available; | ||
| 289 | }), | ||
| 290 | }); | ||
| 291 | |||
| 264 | /** Theme files are a few KB; anything larger returns empty text and lets the | 292 | /** Theme files are a few KB; anything larger returns empty text and lets the |
| 265 | * renderer reject it by size without the contents ever crossing the bridge. */ | 293 | * renderer reject it by size without the contents ever crossing the bridge. */ |
| 266 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; | 294 | const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; |
apps/desktop/src/preload.ts+1| ... | @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 105 | ...(position === undefined ? {} : { position }), | 105 | ...(position === undefined ? {} : { position }), |
| 106 | }), | 106 | }), |
| 107 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), | 107 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), |
| 108 | probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), | ||
| 108 | onMenuAction: (listener) => { | 109 | onMenuAction: (listener) => { |
| 109 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | 110 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 110 | if (typeof action !== "string") return; | 111 | if (typeof action !== "string") return; |
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1| ... | @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( | ... | @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( |
| 77 | const netLayer = Layer.succeed(NetService.NetService, { | 77 | const netLayer = Layer.succeed(NetService.NetService, { |
| 78 | canListenOnHost: () => Effect.succeed(true), | 78 | canListenOnHost: () => Effect.succeed(true), |
| 79 | isPortAvailableOnLoopback: () => Effect.succeed(true), | 79 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 80 | hasListenerOnHost: () => Effect.succeed(false), | ||
| 80 | reserveLoopbackPort: () => Effect.succeed(41773), | 81 | reserveLoopbackPort: () => Effect.succeed(41773), |
| 81 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 82 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 82 | } satisfies NetService.NetService["Service"]); | 83 | } satisfies NetService.NetService["Service"]); |
apps/server/src/environment/RemoteOpenTargets.test.ts created+126| ... | @@ -0,0 +1,126 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { HostProcessHostname } from "@t3tools/shared/hostProcess"; | ||
| 3 | import * as NetService from "@t3tools/shared/Net"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Sink from "effect/Sink"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 9 | import { describe, expect } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; | ||
| 12 | |||
| 13 | const encoder = new TextEncoder(); | ||
| 14 | |||
| 15 | const TAILSCALE_STATUS_JSON = JSON.stringify({ | ||
| 16 | Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, | ||
| 17 | }); | ||
| 18 | |||
| 19 | /** Spawner whose `tailscale status --json` exits with the given output. */ | ||
| 20 | const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => | ||
| 21 | Layer.succeed( | ||
| 22 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 23 | ChildProcessSpawner.make(() => | ||
| 24 | Effect.succeed( | ||
| 25 | ChildProcessSpawner.makeHandle({ | ||
| 26 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 27 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), | ||
| 28 | isRunning: Effect.succeed(false), | ||
| 29 | kill: () => Effect.void, | ||
| 30 | unref: Effect.succeed(Effect.void), | ||
| 31 | stdin: Sink.drain, | ||
| 32 | stdout: Stream.make(encoder.encode(input.stdout)), | ||
| 33 | stderr: Stream.empty, | ||
| 34 | all: Stream.empty, | ||
| 35 | getInputFd: () => Sink.drain, | ||
| 36 | getOutputFd: () => Stream.empty, | ||
| 37 | }), | ||
| 38 | ), | ||
| 39 | ), | ||
| 40 | ); | ||
| 41 | |||
| 42 | const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => | ||
| 43 | Layer.succeed(NetService.NetService, { | ||
| 44 | canListenOnHost: () => Effect.succeed(true), | ||
| 45 | isPortAvailableOnLoopback: () => Effect.succeed(true), | ||
| 46 | hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), | ||
| 47 | reserveLoopbackPort: () => Effect.succeed(40_000), | ||
| 48 | findAvailablePort: (preferred) => Effect.succeed(preferred), | ||
| 49 | }); | ||
| 50 | |||
| 51 | const resolveTargets = (input: { | ||
| 52 | readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; | ||
| 53 | readonly tailscale: { readonly exitCode: number; readonly stdout: string }; | ||
| 54 | readonly hostname: string; | ||
| 55 | }) => | ||
| 56 | Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( | ||
| 57 | Effect.provideService(HostProcessHostname, input.hostname), | ||
| 58 | Effect.provide( | ||
| 59 | RemoteOpenTargets.layer.pipe( | ||
| 60 | Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), | ||
| 61 | ), | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | |||
| 65 | const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; | ||
| 66 | const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; | ||
| 67 | |||
| 68 | describe("RemoteOpenTargets", () => { | ||
| 69 | it.effect("advertises nothing when no sshd accepts on either loopback", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const targets = yield* resolveTargets({ | ||
| 72 | sshd: { ipv4: false, ipv6: false }, | ||
| 73 | tailscale: TAILSCALE_UP, | ||
| 74 | hostname: "bb-1", | ||
| 75 | }); | ||
| 76 | expect(targets).toEqual([]); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | it.effect("orders the tailnet name before the mDNS name", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const targets = yield* resolveTargets({ | ||
| 83 | sshd: { ipv4: true, ipv6: true }, | ||
| 84 | tailscale: TAILSCALE_UP, | ||
| 85 | hostname: "bb-1", | ||
| 86 | }); | ||
| 87 | expect(targets).toEqual([ | ||
| 88 | { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, | ||
| 89 | { kind: "mdns", host: "bb-1.local" }, | ||
| 90 | ]); | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | it.effect("accepts an sshd bound to IPv6 loopback only", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const targets = yield* resolveTargets({ | ||
| 97 | sshd: { ipv4: false, ipv6: true }, | ||
| 98 | tailscale: TAILSCALE_DOWN, | ||
| 99 | hostname: "bb-1", | ||
| 100 | }); | ||
| 101 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | |||
| 105 | it.effect("falls back to mDNS alone when tailscale is unavailable", () => | ||
| 106 | Effect.gen(function* () { | ||
| 107 | const targets = yield* resolveTargets({ | ||
| 108 | sshd: { ipv4: true, ipv6: false }, | ||
| 109 | tailscale: TAILSCALE_DOWN, | ||
| 110 | hostname: "bb-1", | ||
| 111 | }); | ||
| 112 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | |||
| 116 | it.effect("shortens an FQDN hostname to its first label for mDNS", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const targets = yield* resolveTargets({ | ||
| 119 | sshd: { ipv4: true, ipv6: true }, | ||
| 120 | tailscale: TAILSCALE_DOWN, | ||
| 121 | hostname: "bb-1.example.com", | ||
| 122 | }); | ||
| 123 | expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | }); | ||
apps/server/src/environment/RemoteOpenTargets.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | /** | ||
| 2 | * RemoteOpenTargets - resolves the SSH hostnames this environment advertises | ||
| 3 | * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). | ||
| 4 | * | ||
| 5 | * The server can only check itself: sshd listening locally, tailscaled | ||
| 6 | * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a | ||
| 7 | * given name resolves from the viewer's machine is inherently client-side. | ||
| 8 | * Targets are ordered most-reachable first (tailnet name works from anywhere | ||
| 9 | * on the tailnet; `<hostname>.local` only on the same LAN). | ||
| 10 | */ | ||
| 11 | import { type RemoteOpenTarget } from "@t3tools/contracts"; | ||
| 12 | import { HostProcessHostname } from "@t3tools/shared/hostProcess"; | ||
| 13 | import * as NetService from "@t3tools/shared/Net"; | ||
| 14 | import { readTailscaleStatus } from "@t3tools/tailscale"; | ||
| 15 | import * as Context from "effect/Context"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 19 | |||
| 20 | const SSH_PORT = 22; | ||
| 21 | |||
| 22 | export class RemoteOpenTargets extends Context.Service< | ||
| 23 | RemoteOpenTargets, | ||
| 24 | { | ||
| 25 | readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>; | ||
| 26 | } | ||
| 27 | >()("t3/environment/RemoteOpenTargets") {} | ||
| 28 | |||
| 29 | export const make = Effect.gen(function* () { | ||
| 30 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 31 | const net = yield* NetService.NetService; | ||
| 32 | |||
| 33 | const resolveTargets = Effect.gen(function* () { | ||
| 34 | // No local sshd means no name can work; advertise nothing so clients | ||
| 35 | // render a clear "no SSH route" state instead of links that hang. | ||
| 36 | // Check both loopback families: sshd can be bound IPv6-only. | ||
| 37 | const sshdListening = yield* Effect.zipWith( | ||
| 38 | net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), | ||
| 39 | net.hasListenerOnHost(SSH_PORT, "::1"), | ||
| 40 | (ipv4, ipv6) => ipv4 || ipv6, | ||
| 41 | ); | ||
| 42 | if (!sshdListening) { | ||
| 43 | return []; | ||
| 44 | } | ||
| 45 | |||
| 46 | const targets: Array<RemoteOpenTarget> = []; | ||
| 47 | |||
| 48 | // Tailscale absent or down is the common case, not an error. | ||
| 49 | const magicDnsName = yield* readTailscaleStatus.pipe( | ||
| 50 | Effect.map((status) => status.magicDnsName), | ||
| 51 | Effect.orElseSucceed(() => null), | ||
| 52 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 53 | ); | ||
| 54 | if (magicDnsName !== null) { | ||
| 55 | targets.push({ kind: "tailscale", host: magicDnsName }); | ||
| 56 | } | ||
| 57 | |||
| 58 | // os.hostname() may already be an FQDN (macOS often reports | ||
| 59 | // "Name.local"); mDNS names are always `<first-label>.local`. | ||
| 60 | const hostname = yield* HostProcessHostname; | ||
| 61 | const shortHostname = hostname.split(".")[0]?.trim(); | ||
| 62 | if (shortHostname !== undefined && shortHostname.length > 0) { | ||
| 63 | targets.push({ kind: "mdns", host: `${shortHostname}.local` }); | ||
| 64 | } | ||
| 65 | |||
| 66 | return targets; | ||
| 67 | }); | ||
| 68 | |||
| 69 | return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | export const layer = Layer.effect(RemoteOpenTargets, make); | ||
apps/server/src/preview/PortScanner.test.ts+3| ... | @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; | ... | @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; |
| 47 | const TestIntegrationNet = Layer.succeed(Net.NetService, { | 47 | const TestIntegrationNet = Layer.succeed(Net.NetService, { |
| 48 | canListenOnHost: () => Effect.succeed(true), | 48 | canListenOnHost: () => Effect.succeed(true), |
| 49 | isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), | 49 | isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), |
| 50 | hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), | ||
| 50 | reserveLoopbackPort: () => Effect.succeed(40_000), | 51 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 51 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 52 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 52 | }); | 53 | }); |
| ... | @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( | ... | @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( |
| 62 | Layer.succeed(Net.NetService, { | 63 | Layer.succeed(Net.NetService, { |
| 63 | canListenOnHost: () => Effect.succeed(true), | 64 | canListenOnHost: () => Effect.succeed(true), |
| 64 | isPortAvailableOnLoopback: () => Effect.succeed(true), | 65 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 66 | hasListenerOnHost: () => Effect.succeed(false), | ||
| 65 | reserveLoopbackPort: () => Effect.succeed(40_000), | 67 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 66 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 68 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 67 | }), | 69 | }), |
| ... | @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { | ... | @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { |
| 107 | Layer.succeed(Net.NetService, { | 109 | Layer.succeed(Net.NetService, { |
| 108 | canListenOnHost: () => Effect.succeed(true), | 110 | canListenOnHost: () => Effect.succeed(true), |
| 109 | isPortAvailableOnLoopback: () => Effect.succeed(true), | 111 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 112 | hasListenerOnHost: () => Effect.succeed(false), | ||
| 110 | reserveLoopbackPort: () => Effect.succeed(40_000), | 113 | reserveLoopbackPort: () => Effect.succeed(40_000), |
| 111 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 114 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 112 | }), | 115 | }), |
apps/server/src/server.test.ts+10-4| ... | @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | ... | @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 107 | import * as GitManager from "./git/GitManager.ts"; | 107 | import * as GitManager from "./git/GitManager.ts"; |
| 108 | import * as Keybindings from "./keybindings.ts"; | 108 | import * as Keybindings from "./keybindings.ts"; |
| 109 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 109 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 110 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | ||
| 110 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | 111 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 111 | import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; | 112 | import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; |
| 112 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 113 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| ... | @@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: { | ... | @@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: { |
| 650 | }), | 651 | }), |
| 651 | ), | 652 | ), |
| 652 | Layer.provide( | 653 | Layer.provide( |
| 653 | Layer.mock(ExternalLauncher.ExternalLauncher)({ | 654 | Layer.mergeAll( |
| 654 | resolveAvailableEditors: () => Effect.succeed([]), | 655 | Layer.mock(ExternalLauncher.ExternalLauncher)({ |
| 655 | ...options?.layers?.externalLauncher, | 656 | resolveAvailableEditors: () => Effect.succeed([]), |
| 656 | }), | 657 | ...options?.layers?.externalLauncher, |
| 658 | }), | ||
| 659 | Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ | ||
| 660 | resolveTargets: () => Effect.succeed([]), | ||
| 661 | }), | ||
| 662 | ), | ||
| 657 | ), | 663 | ), |
| 658 | Layer.provide( | 664 | Layer.provide( |
| 659 | Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ | 665 | Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ |
apps/server/src/server.ts+2| ... | @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe | ... | @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe |
| 81 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 81 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 82 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; | 82 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 83 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 83 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 84 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | ||
| 84 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 85 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 85 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 86 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 86 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 87 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| ... | @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | ... | @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| 420 | Layer.provideMerge(TraceDiagnostics.layer), | 421 | Layer.provideMerge(TraceDiagnostics.layer), |
| 421 | Layer.provideMerge(AnalyticsService.layer), | 422 | Layer.provideMerge(AnalyticsService.layer), |
| 422 | Layer.provideMerge(ExternalLauncher.layer), | 423 | Layer.provideMerge(ExternalLauncher.layer), |
| 424 | Layer.provideMerge(RemoteOpenTargets.layer), | ||
| 423 | Layer.provideMerge(ServerLifecycleEvents.layer), | 425 | Layer.provideMerge(ServerLifecycleEvents.layer), |
| 424 | Layer.provide(NetService.layer), | 426 | Layer.provide(NetService.layer), |
| 425 | ); | 427 | ); |
apps/server/src/ws.ts+7| ... | @@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | ... | @@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 99 | import * as ReviewService from "./review/ReviewService.ts"; | 99 | import * as ReviewService from "./review/ReviewService.ts"; |
| 100 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 100 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 101 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 101 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 102 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | ||
| 102 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | 103 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 103 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 104 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 104 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | 105 | import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| ... | @@ -361,6 +362,7 @@ const makeWsRpcLayer = ( | ... | @@ -361,6 +362,7 @@ const makeWsRpcLayer = ( |
| 361 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; | 362 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 362 | const keybindings = yield* Keybindings.Keybindings; | 363 | const keybindings = yield* Keybindings.Keybindings; |
| 363 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | 364 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 365 | const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; | ||
| 364 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; | 366 | const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; |
| 365 | const review = yield* ReviewService.ReviewService; | 367 | const review = yield* ReviewService.ReviewService; |
| 366 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; | 368 | const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; |
| ... | @@ -1010,6 +1012,11 @@ const makeWsRpcLayer = ( | ... | @@ -1010,6 +1012,11 @@ const makeWsRpcLayer = ( |
| 1010 | availableEditors: yield* resolveAvailableEditorsForConfig( | 1012 | availableEditors: yield* resolveAvailableEditorsForConfig( |
| 1011 | externalLauncher.resolveAvailableEditors(), | 1013 | externalLauncher.resolveAvailableEditors(), |
| 1012 | ), | 1014 | ), |
| 1015 | // Same discovery-with-timeout treatment as editors: a slow probe | ||
| 1016 | // must not stall server.getConfig, so it degrades to no targets. | ||
| 1017 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( | ||
| 1018 | remoteOpenTargets.resolveTargets(), | ||
| 1019 | ), | ||
| 1013 | observability: { | 1020 | observability: { |
| 1014 | logsDirectoryPath: config.logsDir, | 1021 | logsDirectoryPath: config.logsDir, |
| 1015 | localTracingEnabled: true, | 1022 | localTracingEnabled: true, |
apps/web/src/components/chat/ChatHeader.test.ts+18-3| ... | @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { | ... | @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { |
| 12 | activeProjectName: "codething-mvp", | 12 | activeProjectName: "codething-mvp", |
| 13 | activeThreadEnvironmentId: primaryEnvironmentId, | 13 | activeThreadEnvironmentId: primaryEnvironmentId, |
| 14 | primaryEnvironmentId, | 14 | primaryEnvironmentId, |
| 15 | remoteOpenMode: "local-exec", | ||
| 15 | }), | 16 | }), |
| 16 | ).toBe(true); | 17 | ).toBe(true); |
| 17 | }); | 18 | }); |
| 18 | 19 | ||
| 19 | it("hides the picker when hosted static mode has no primary environment", () => { | 20 | it("shows the picker for remote environments in deep-link mode", () => { |
| 21 | expect( | ||
| 22 | shouldShowOpenInPicker({ | ||
| 23 | activeProjectName: "codething-mvp", | ||
| 24 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | ||
| 25 | primaryEnvironmentId, | ||
| 26 | remoteOpenMode: "remote-links", | ||
| 27 | }), | ||
| 28 | ).toBe(true); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("shows the picker's unavailable state for remote environments without an SSH route", () => { | ||
| 20 | expect( | 32 | expect( |
| 21 | shouldShowOpenInPicker({ | 33 | shouldShowOpenInPicker({ |
| 22 | activeProjectName: "codething-mvp", | 34 | activeProjectName: "codething-mvp", |
| 23 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | 35 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), |
| 24 | primaryEnvironmentId: null, | 36 | primaryEnvironmentId: null, |
| 37 | remoteOpenMode: "remote-unavailable", | ||
| 25 | }), | 38 | }), |
| 26 | ).toBe(false); | 39 | ).toBe(true); |
| 27 | }); | 40 | }); |
| 28 | 41 | ||
| 29 | it("hides the picker for remote environments", () => { | 42 | it("hides the picker for non-primary local backends", () => { |
| 30 | expect( | 43 | expect( |
| 31 | shouldShowOpenInPicker({ | 44 | shouldShowOpenInPicker({ |
| 32 | activeProjectName: "codething-mvp", | 45 | activeProjectName: "codething-mvp", |
| 33 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), | 46 | activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), |
| 34 | primaryEnvironmentId, | 47 | primaryEnvironmentId, |
| 48 | remoteOpenMode: "local-exec", | ||
| 35 | }), | 49 | }), |
| 36 | ).toBe(false); | 50 | ).toBe(false); |
| 37 | }); | 51 | }); |
| ... | @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { | ... | @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { |
| 42 | activeProjectName: undefined, | 56 | activeProjectName: undefined, |
| 43 | activeThreadEnvironmentId: primaryEnvironmentId, | 57 | activeThreadEnvironmentId: primaryEnvironmentId, |
| 44 | primaryEnvironmentId, | 58 | primaryEnvironmentId, |
| 59 | remoteOpenMode: "remote-links", | ||
| 45 | }), | 60 | }), |
| 46 | ).toBe(false); | 61 | ).toBe(false); |
| 47 | }); | 62 | }); |
apps/web/src/components/chat/ChatHeader.tsx+13-3| ... | @@ -30,6 +30,7 @@ import ProjectScriptsControl, { | ... | @@ -30,6 +30,7 @@ import ProjectScriptsControl, { |
| 30 | type ProjectScriptActionResult, | 30 | type ProjectScriptActionResult, |
| 31 | } from "../ProjectScriptsControl"; | 31 | } from "../ProjectScriptsControl"; |
| 32 | import { OpenInPicker } from "./OpenInPicker"; | 32 | import { OpenInPicker } from "./OpenInPicker"; |
| 33 | import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; | ||
| 33 | import { usePrimaryEnvironmentId } from "../../state/environments"; | 34 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 34 | import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; | 35 | import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; |
| 35 | import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; | 36 | import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; |
| ... | @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { | ... | @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { |
| 91 | readonly activeProjectName: string | undefined; | 92 | readonly activeProjectName: string | undefined; |
| 92 | readonly activeThreadEnvironmentId: EnvironmentId; | 93 | readonly activeThreadEnvironmentId: EnvironmentId; |
| 93 | readonly primaryEnvironmentId: EnvironmentId | null; | 94 | readonly primaryEnvironmentId: EnvironmentId | null; |
| 95 | readonly remoteOpenMode: RemoteOpenMode; | ||
| 94 | }): boolean { | 96 | }): boolean { |
| 95 | return ( | 97 | if (!input.activeProjectName) return false; |
| 96 | Boolean(input.activeProjectName) && | 98 | if ( |
| 97 | input.primaryEnvironmentId !== null && | 99 | input.primaryEnvironmentId !== null && |
| 98 | input.activeThreadEnvironmentId === input.primaryEnvironmentId | 100 | input.activeThreadEnvironmentId === input.primaryEnvironmentId |
| 99 | ); | 101 | ) { |
| 102 | return true; | ||
| 103 | } | ||
| 104 | // Remote environments get the picker in deep-link mode (or its explicit | ||
| 105 | // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it | ||
| 106 | // hidden, matching pre-remote behavior. | ||
| 107 | return input.remoteOpenMode !== "local-exec"; | ||
| 100 | } | 108 | } |
| 101 | 109 | ||
| 102 | export const ChatHeader = memo(function ChatHeader({ | 110 | export const ChatHeader = memo(function ChatHeader({ |
| ... | @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ |
| 128 | activeThreadEnvironmentId, | 136 | activeThreadEnvironmentId, |
| 129 | activeProjectScripts ? activeProjectCwd : null, | 137 | activeProjectScripts ? activeProjectCwd : null, |
| 130 | ); | 138 | ); |
| 139 | const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); | ||
| 131 | const showOpenInPicker = shouldShowOpenInPicker({ | 140 | const showOpenInPicker = shouldShowOpenInPicker({ |
| 132 | activeProjectName, | 141 | activeProjectName, |
| 133 | activeThreadEnvironmentId, | 142 | activeThreadEnvironmentId, |
| 134 | primaryEnvironmentId, | 143 | primaryEnvironmentId, |
| 144 | remoteOpenMode: remoteOpenState.mode, | ||
| 135 | }); | 145 | }); |
| 136 | const activeThreadRef = useMemo( | 146 | const activeThreadRef = useMemo( |
| 137 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), | 147 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
apps/web/src/components/chat/OpenInPicker.tsx+70-30| ... | @@ -1,7 +1,19 @@ | ... | @@ -1,7 +1,19 @@ |
| 1 | import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 1 | import { |
| 2 | buildRemoteOpenUrl, | ||
| 3 | EditorId, | ||
| 4 | type EnvironmentId, | ||
| 5 | type ResolvedKeybindingsConfig, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { memo, useCallback, useEffect, useMemo } from "react"; | 7 | import { memo, useCallback, useEffect, useMemo } from "react"; |
| 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; | 8 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 4 | import { usePreferredEditor } from "../../editorPreferences"; | 9 | import { usePreferredEditor } from "../../editorPreferences"; |
| 10 | import { | ||
| 11 | openRemoteEditorUrl, | ||
| 12 | useRemoteCapableEditors, | ||
| 13 | useRemoteOpenHint, | ||
| 14 | useRemoteOpenState, | ||
| 15 | } from "../../remoteOpen"; | ||
| 16 | import { useEnvironment } from "../../state/environments"; | ||
| 5 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; | 17 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; |
| 6 | import { Button } from "../ui/button"; | 18 | import { Button } from "../ui/button"; |
| 7 | import { Group, GroupSeparator } from "../ui/group"; | 19 | import { Group, GroupSeparator } from "../ui/group"; |
| ... | @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 199 | enableShortcut?: boolean; | 211 | enableShortcut?: boolean; |
| 200 | }) { | 212 | }) { |
| 201 | const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); | 213 | const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); |
| 202 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); | 214 | const remote = useRemoteOpenState(environmentId); |
| 215 | const remoteCapableEditors = useRemoteCapableEditors(); | ||
| 216 | const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); | ||
| 217 | const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; | ||
| 218 | // Remote mode ignores the server's PATH probe: what matters is what runs on | ||
| 219 | // the viewing machine, which only the desktop app can probe. | ||
| 220 | const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; | ||
| 221 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); | ||
| 203 | const options = useMemo( | 222 | const options = useMemo( |
| 204 | () => resolveOptions(navigator.platform, availableEditors), | 223 | () => resolveOptions(navigator.platform, effectiveEditors), |
| 205 | [availableEditors], | 224 | [effectiveEditors], |
| 206 | ); | 225 | ); |
| 207 | const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; | 226 | const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; |
| 208 | 227 | ||
| ... | @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 211 | if (!openInCwd) return; | 230 | if (!openInCwd) return; |
| 212 | const editor = editorId ?? preferredEditor; | 231 | const editor = editorId ?? preferredEditor; |
| 213 | if (!editor) return; | 232 | if (!editor) return; |
| 233 | if (remote.mode === "remote-unavailable") return; | ||
| 234 | if (remote.mode === "remote-links") { | ||
| 235 | const url = buildRemoteOpenUrl({ | ||
| 236 | editor, | ||
| 237 | host: remote.host.host, | ||
| 238 | absolutePath: openInCwd, | ||
| 239 | }); | ||
| 240 | if (url === undefined) return; | ||
| 241 | // Only record hint-seen/preferred when the shell actually accepted | ||
| 242 | // the URL (an older desktop build can refuse the editor scheme). | ||
| 243 | void openRemoteEditorUrl(url).then((opened) => { | ||
| 244 | if (!opened) return; | ||
| 245 | markRemoteHintSeen(); | ||
| 246 | setPreferredEditor(editor); | ||
| 247 | }); | ||
| 248 | return; | ||
| 249 | } | ||
| 214 | const result = openInEditorMutation({ | 250 | const result = openInEditorMutation({ |
| 215 | environmentId, | 251 | environmentId, |
| 216 | input: { | 252 | input: { |
| ... | @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 221 | setPreferredEditor(editor); | 257 | setPreferredEditor(editor); |
| 222 | return result; | 258 | return result; |
| 223 | }, | 259 | }, |
| 224 | [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], | 260 | [ |
| 261 | environmentId, | ||
| 262 | markRemoteHintSeen, | ||
| 263 | openInCwd, | ||
| 264 | openInEditorMutation, | ||
| 265 | preferredEditor, | ||
| 266 | remote, | ||
| 267 | setPreferredEditor, | ||
| 268 | ], | ||
| 225 | ); | 269 | ); |
| 226 | 270 | ||
| 227 | const openFavoriteEditorShortcutLabel = useMemo( | 271 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 237 | if (!preferredEditor) return; | 281 | if (!preferredEditor) return; |
| 238 | 282 | ||
| 239 | e.preventDefault(); | 283 | e.preventDefault(); |
| 240 | void openInEditorMutation({ | 284 | void openInEditor(preferredEditor); |
| 241 | environmentId, | ||
| 242 | input: { | ||
| 243 | cwd: openInCwd, | ||
| 244 | editor: preferredEditor, | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | }; | 285 | }; |
| 248 | window.addEventListener("keydown", handler); | 286 | window.addEventListener("keydown", handler); |
| 249 | return () => window.removeEventListener("keydown", handler); | 287 | return () => window.removeEventListener("keydown", handler); |
| 250 | }, [ | 288 | }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); |
| 251 | enableShortcut, | ||
| 252 | environmentId, | ||
| 253 | keybindings, | ||
| 254 | openInCwd, | ||
| 255 | openInEditorMutation, | ||
| 256 | preferredEditor, | ||
| 257 | ]); | ||
| 258 | 289 | ||
| 259 | return ( | 290 | return ( |
| 260 | <Group aria-label="Open in editor"> | 291 | <Group aria-label="Open in editor"> |
| ... | @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 263 | className="ps-[8.5px]" | 294 | className="ps-[8.5px]" |
| 264 | size="xs" | 295 | size="xs" |
| 265 | variant="outline" | 296 | variant="outline" |
| 266 | disabled={!preferredEditor || !openInCwd} | 297 | disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} |
| 267 | onClick={() => openInEditor(preferredEditor)} | 298 | onClick={() => openInEditor(preferredEditor)} |
| 268 | > | 299 | > |
| 269 | {primaryOption?.Icon && ( | 300 | {primaryOption?.Icon && ( |
| ... | @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 296 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | 327 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
| 297 | </MenuTrigger> | 328 | </MenuTrigger> |
| 298 | <MenuPopup align="end"> | 329 | <MenuPopup align="end"> |
| 299 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | 330 | {remote.mode === "remote-unavailable" ? ( |
| 300 | {options.map(({ label, Icon, value, kind }) => ( | 331 | <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem> |
| 301 | <MenuItem key={value} onClick={() => openInEditor(value)}> | 332 | ) : ( |
| 302 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | 333 | <> |
| 303 | {label} | 334 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} |
| 304 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | 335 | {options.map(({ label, Icon, value, kind }) => ( |
| 305 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | 336 | <MenuItem key={value} onClick={() => openInEditor(value)}> |
| 337 | <Icon aria-hidden="true" className={getOpenInIconClass(kind)} /> | ||
| 338 | {label} | ||
| 339 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | ||
| 340 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | ||
| 341 | )} | ||
| 342 | </MenuItem> | ||
| 343 | ))} | ||
| 344 | {remote.mode === "remote-links" && !remoteHintSeen && ( | ||
| 345 | <MenuItem disabled>Opens over SSH. Needs your key on {environmentLabel}</MenuItem> | ||
| 306 | )} | 346 | )} |
| 307 | </MenuItem> | 347 | </> |
| 308 | ))} | 348 | )} |
| 309 | </MenuPopup> | 349 | </MenuPopup> |
| 310 | </Menu> | 350 | </Menu> |
| 311 | </Group> | 351 | </Group> |
apps/web/src/components/files/FilePreviewPanel.tsx+4-1| ... | @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre | ... | @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre |
| 20 | import { useAssetUrlState } from "~/assets/assetUrls"; | 20 | import { useAssetUrlState } from "~/assets/assetUrls"; |
| 21 | import ChatMarkdown from "~/components/ChatMarkdown"; | 21 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 22 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; | 22 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 23 | import { useRemoteOpenState } from "~/remoteOpen"; | ||
| 23 | import { useClientSettings } from "~/hooks/useSettings"; | 24 | import { useClientSettings } from "~/hooks/useSettings"; |
| 24 | import { useTheme } from "~/hooks/useTheme"; | 25 | import { useTheme } from "~/hooks/useTheme"; |
| 25 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; | 26 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; |
| ... | @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ | ... | @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ |
| 771 | const { resolvedTheme } = useTheme(); | 772 | const { resolvedTheme } = useTheme(); |
| 772 | const wordWrap = useClientSettings((settings) => settings.wordWrap); | 773 | const wordWrap = useClientSettings((settings) => settings.wordWrap); |
| 773 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 774 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 775 | const remoteOpenState = useRemoteOpenState(environmentId); | ||
| 774 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); | 776 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); |
| 775 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 777 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 776 | reportFailure: false, | 778 | reportFailure: false, |
| ... | @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ | ... | @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ |
| 893 | ))} | 895 | ))} |
| 894 | </div> | 896 | </div> |
| 895 | </ScrollArea> | 897 | </ScrollArea> |
| 896 | {absolutePath && environmentId === primaryEnvironmentId ? ( | 898 | {absolutePath && |
| 899 | (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( | ||
| 897 | <OpenInPicker | 900 | <OpenInPicker |
| 898 | environmentId={environmentId} | 901 | environmentId={environmentId} |
| 899 | keybindings={keybindings} | 902 | keybindings={keybindings} |
apps/web/src/remoteOpen.test.ts created+149| ... | @@ -0,0 +1,149 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionTarget, | ||
| 3 | PrimaryConnectionTarget, | ||
| 4 | RelayConnectionTarget, | ||
| 5 | SshConnectionTarget, | ||
| 6 | } from "@t3tools/client-runtime/connection"; | ||
| 7 | import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { resolveRemoteOpenState } from "./remoteOpen"; | ||
| 11 | |||
| 12 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 13 | |||
| 14 | const primaryTarget = (httpBaseUrl: string) => | ||
| 15 | new PrimaryConnectionTarget({ | ||
| 16 | environmentId, | ||
| 17 | label: "sol", | ||
| 18 | httpBaseUrl, | ||
| 19 | wsBaseUrl: httpBaseUrl.replace("http", "ws"), | ||
| 20 | }); | ||
| 21 | |||
| 22 | const TAILSCALE_TARGETS = [ | ||
| 23 | { kind: "tailscale", host: "sol.tail1234.ts.net" }, | ||
| 24 | { kind: "mdns", host: "sol.local" }, | ||
| 25 | ] as const; | ||
| 26 | |||
| 27 | describe("resolveRemoteOpenState", () => { | ||
| 28 | it("keeps exec behavior for a loopback primary target", () => { | ||
| 29 | expect( | ||
| 30 | resolveRemoteOpenState({ | ||
| 31 | target: primaryTarget("http://127.0.0.1:8000"), | ||
| 32 | sshAlias: null, | ||
| 33 | isDesktopRenderer: false, | ||
| 34 | remoteOpenTargets: TAILSCALE_TARGETS, | ||
| 35 | }), | ||
| 36 | ).toEqual({ mode: "local-exec" }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("uses deep links for a primary target reached over the network", () => { | ||
| 40 | expect( | ||
| 41 | resolveRemoteOpenState({ | ||
| 42 | target: primaryTarget("https://sol.tail1234.ts.net"), | ||
| 43 | sshAlias: null, | ||
| 44 | isDesktopRenderer: false, | ||
| 45 | remoteOpenTargets: TAILSCALE_TARGETS, | ||
| 46 | }), | ||
| 47 | ).toEqual({ | ||
| 48 | mode: "remote-links", | ||
| 49 | host: { kind: "tailscale", host: "sol.tail1234.ts.net" }, | ||
| 50 | }); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => { | ||
| 54 | // wsl-only mode binds the primary to the WSL2 NAT address; it is still | ||
| 55 | // this machine because the desktop app manages its own primary backend. | ||
| 56 | expect( | ||
| 57 | resolveRemoteOpenState({ | ||
| 58 | target: primaryTarget("http://172.29.112.1:14369"), | ||
| 59 | sshAlias: null, | ||
| 60 | isDesktopRenderer: true, | ||
| 61 | remoteOpenTargets: TAILSCALE_TARGETS, | ||
| 62 | }), | ||
| 63 | ).toEqual({ mode: "local-exec" }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("keeps exec behavior for desktop-local secondary backends", () => { | ||
| 67 | expect( | ||
| 68 | resolveRemoteOpenState({ | ||
| 69 | target: new BearerConnectionTarget({ | ||
| 70 | environmentId, | ||
| 71 | label: "WSL (Ubuntu)", | ||
| 72 | connectionId: "local:wsl-1", | ||
| 73 | }), | ||
| 74 | sshAlias: null, | ||
| 75 | isDesktopRenderer: false, | ||
| 76 | remoteOpenTargets: TAILSCALE_TARGETS, | ||
| 77 | }), | ||
| 78 | ).toEqual({ mode: "local-exec" }); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("prefers the desktop SSH alias over server-advertised hosts", () => { | ||
| 82 | expect( | ||
| 83 | resolveRemoteOpenState({ | ||
| 84 | target: new SshConnectionTarget({ | ||
| 85 | environmentId, | ||
| 86 | label: "sol", | ||
| 87 | connectionId: "ssh-1", | ||
| 88 | }), | ||
| 89 | sshAlias: "sol", | ||
| 90 | isDesktopRenderer: true, | ||
| 91 | remoteOpenTargets: TAILSCALE_TARGETS, | ||
| 92 | }), | ||
| 93 | ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("reports unavailable when a remote environment advertises no hosts", () => { | ||
| 97 | for (const remoteOpenTargets of [[], undefined] as const) { | ||
| 98 | expect( | ||
| 99 | resolveRemoteOpenState({ | ||
| 100 | target: new RelayConnectionTarget({ environmentId, label: "sol" }), | ||
| 101 | sshAlias: null, | ||
| 102 | isDesktopRenderer: false, | ||
| 103 | remoteOpenTargets, | ||
| 104 | }), | ||
| 105 | ).toEqual({ mode: "remote-unavailable" }); | ||
| 106 | } | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("falls back to exec when the environment has no catalog entry", () => { | ||
| 110 | expect( | ||
| 111 | resolveRemoteOpenState({ | ||
| 112 | target: null, | ||
| 113 | sshAlias: null, | ||
| 114 | isDesktopRenderer: false, | ||
| 115 | remoteOpenTargets: undefined, | ||
| 116 | }), | ||
| 117 | ).toEqual({ mode: "local-exec" }); | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | |||
| 121 | describe("buildRemoteOpenUrl", () => { | ||
| 122 | it("builds a vscode-remote deep link", () => { | ||
| 123 | expect( | ||
| 124 | buildRemoteOpenUrl({ | ||
| 125 | editor: "vscode", | ||
| 126 | host: "sol.tail1234.ts.net", | ||
| 127 | absolutePath: "/home/theo/code/my repo", | ||
| 128 | }), | ||
| 129 | ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("uses the fork's scheme", () => { | ||
| 133 | expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( | ||
| 134 | "cursor://vscode-remote/ssh-remote+sol/tmp/x", | ||
| 135 | ); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("roots Windows paths", () => { | ||
| 139 | expect( | ||
| 140 | buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), | ||
| 141 | ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("returns undefined for editors without remote support", () => { | ||
| 145 | expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( | ||
| 146 | undefined, | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | }); | ||
apps/web/src/remoteOpen.ts created+189| ... | @@ -0,0 +1,189 @@ | ||
| 1 | /** | ||
| 2 | * Remote open-in-editor: when this client is not on the environment's | ||
| 3 | * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` | ||
| 4 | * deep link (local editor connects over SSH) instead of exec'ing an editor | ||
| 5 | * on the environment host. | ||
| 6 | * | ||
| 7 | * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias | ||
| 8 | * beats server-advertised names; among advertised names the tailnet MagicDNS | ||
| 9 | * name beats mDNS `<hostname>.local` (server sends them in that order). | ||
| 10 | */ | ||
| 11 | import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; | ||
| 12 | import { | ||
| 13 | REMOTE_CAPABLE_EDITOR_IDS, | ||
| 14 | type EditorId, | ||
| 15 | type EnvironmentId, | ||
| 16 | type RemoteOpenTarget, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import { useEffect, useMemo, useState } from "react"; | ||
| 21 | |||
| 22 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | ||
| 23 | import { isLoopbackHostname } from "~/environments/primary/target"; | ||
| 24 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | ||
| 25 | import { useEnvironmentPresentation } from "~/state/presentation"; | ||
| 26 | |||
| 27 | export interface RemoteOpenHost { | ||
| 28 | readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; | ||
| 29 | readonly host: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | export type RemoteOpenState = | ||
| 33 | | { readonly mode: "local-exec" } | ||
| 34 | | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } | ||
| 35 | | { readonly mode: "remote-unavailable" }; | ||
| 36 | |||
| 37 | export type RemoteOpenMode = RemoteOpenState["mode"]; | ||
| 38 | |||
| 39 | const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; | ||
| 40 | const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; | ||
| 41 | |||
| 42 | function parseHostname(url: string): string | null { | ||
| 43 | try { | ||
| 44 | return new URL(url).hostname; | ||
| 45 | } catch { | ||
| 46 | return null; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | export function resolveRemoteOpenState(input: { | ||
| 51 | readonly target: ConnectionTarget | null; | ||
| 52 | /** Real ssh alias for desktop-SSH environments; null elsewhere. */ | ||
| 53 | readonly sshAlias: string | null; | ||
| 54 | /** Server-advertised hosts; undefined on servers that predate the feature. */ | ||
| 55 | readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined; | ||
| 56 | /** True when running inside the desktop app's renderer. */ | ||
| 57 | readonly isDesktopRenderer: boolean; | ||
| 58 | }): RemoteOpenState { | ||
| 59 | const { target } = input; | ||
| 60 | // No catalog entry: keep today's exec behavior rather than guessing. | ||
| 61 | if (target === null) { | ||
| 62 | return LOCAL_EXEC; | ||
| 63 | } | ||
| 64 | if (target._tag === "PrimaryConnectionTarget") { | ||
| 65 | // The desktop app manages its own primary backend, so it is always on | ||
| 66 | // this machine even when its URL is not loopback (wsl-only mode binds | ||
| 67 | // the WSL2 NAT address). In a browser, a loopback primary means the | ||
| 68 | // browser runs on the serving machine; a tailnet/LAN URL means remote. | ||
| 69 | if (input.isDesktopRenderer) { | ||
| 70 | return LOCAL_EXEC; | ||
| 71 | } | ||
| 72 | const hostname = parseHostname(target.httpBaseUrl); | ||
| 73 | if (hostname !== null && isLoopbackHostname(hostname)) { | ||
| 74 | return LOCAL_EXEC; | ||
| 75 | } | ||
| 76 | } else if (isDesktopLocalConnectionTarget(target)) { | ||
| 77 | return LOCAL_EXEC; | ||
| 78 | } | ||
| 79 | |||
| 80 | if (input.sshAlias !== null && input.sshAlias.length > 0) { | ||
| 81 | return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } }; | ||
| 82 | } | ||
| 83 | const advertised = input.remoteOpenTargets?.[0]; | ||
| 84 | if (advertised !== undefined) { | ||
| 85 | return { mode: "remote-links", host: advertised }; | ||
| 86 | } | ||
| 87 | return REMOTE_UNAVAILABLE; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { | ||
| 91 | const { presentation } = useEnvironmentPresentation(environmentId); | ||
| 92 | |||
| 93 | return useMemo(() => { | ||
| 94 | if (presentation === null) { | ||
| 95 | return LOCAL_EXEC; | ||
| 96 | } | ||
| 97 | const profile = Option.getOrNull(presentation.entry.profile); | ||
| 98 | const sshAlias = | ||
| 99 | profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; | ||
| 100 | return resolveRemoteOpenState({ | ||
| 101 | target: presentation.entry.target, | ||
| 102 | sshAlias, | ||
| 103 | remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, | ||
| 104 | isDesktopRenderer: window.desktopBridge !== undefined, | ||
| 105 | }); | ||
| 106 | }, [presentation]); | ||
| 107 | } | ||
| 108 | |||
| 109 | /** | ||
| 110 | * Editors offered in remote-link mode. The desktop app probes the machine the | ||
| 111 | * renderer runs on; a browser cannot, so it offers VS Code only. | ||
| 112 | */ | ||
| 113 | const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"]; | ||
| 114 | |||
| 115 | let cachedProbedEditors: ReadonlyArray<EditorId> | null = null; | ||
| 116 | |||
| 117 | export function __resetRemoteEditorProbeForTests(): void { | ||
| 118 | cachedProbedEditors = null; | ||
| 119 | } | ||
| 120 | |||
| 121 | export function useRemoteCapableEditors(): ReadonlyArray<EditorId> { | ||
| 122 | const [editors, setEditors] = useState<ReadonlyArray<EditorId>>( | ||
| 123 | () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, | ||
| 124 | ); | ||
| 125 | |||
| 126 | useEffect(() => { | ||
| 127 | if (cachedProbedEditors !== null) { | ||
| 128 | return; | ||
| 129 | } | ||
| 130 | const probe = window.desktopBridge?.probeRemoteEditors; | ||
| 131 | if (probe === undefined) { | ||
| 132 | cachedProbedEditors = REMOTE_FALLBACK_EDITORS; | ||
| 133 | return; | ||
| 134 | } | ||
| 135 | let cancelled = false; | ||
| 136 | probe().then( | ||
| 137 | (ids) => { | ||
| 138 | const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); | ||
| 139 | cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS; | ||
| 140 | if (!cancelled) { | ||
| 141 | setEditors(cachedProbedEditors); | ||
| 142 | } | ||
| 143 | }, | ||
| 144 | () => { | ||
| 145 | cachedProbedEditors = REMOTE_FALLBACK_EDITORS; | ||
| 146 | }, | ||
| 147 | ); | ||
| 148 | return () => { | ||
| 149 | cancelled = true; | ||
| 150 | }; | ||
| 151 | }, []); | ||
| 152 | |||
| 153 | return editors; | ||
| 154 | } | ||
| 155 | |||
| 156 | /** | ||
| 157 | * Fire a remote editor deep link. In desktop, route through the Electron | ||
| 158 | * shell so the OS handler opens without navigating the renderer; in a | ||
| 159 | * browser, assign the location — unlike window.open this does not leave a | ||
| 160 | * blank tab behind. | ||
| 161 | * | ||
| 162 | * Resolves false when the desktop shell refused the URL (e.g. an older | ||
| 163 | * build whose protocol allowlist predates editor schemes) so callers do not | ||
| 164 | * record a successful open that never happened. | ||
| 165 | */ | ||
| 166 | export async function openRemoteEditorUrl(url: string): Promise<boolean> { | ||
| 167 | const bridge = window.desktopBridge; | ||
| 168 | if (bridge !== undefined) { | ||
| 169 | try { | ||
| 170 | return await bridge.openExternal(url); | ||
| 171 | } catch { | ||
| 172 | return false; | ||
| 173 | } | ||
| 174 | } | ||
| 175 | window.location.assign(url); | ||
| 176 | return true; | ||
| 177 | } | ||
| 178 | |||
| 179 | /** | ||
| 180 | * One-time "you need SSH keys on that machine" hint, shown in the picker menu | ||
| 181 | * until the first remote open fires (we cannot observe SSH success from here, | ||
| 182 | * so first click is the dismiss signal). | ||
| 183 | */ | ||
| 184 | const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; | ||
| 185 | |||
| 186 | export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { | ||
| 187 | const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean); | ||
| 188 | return [seen, () => setSeen(true)] as const; | ||
| 189 | } | ||
packages/contracts/src/editor.ts+76-3| ... | @@ -10,20 +10,45 @@ type EditorDefinition = { | ... | @@ -10,20 +10,45 @@ type EditorDefinition = { |
| 10 | readonly commands: readonly [string, ...string[]] | null; | 10 | readonly commands: readonly [string, ...string[]] | null; |
| 11 | readonly baseArgs?: readonly string[]; | 11 | readonly baseArgs?: readonly string[]; |
| 12 | readonly launchStyle: EditorLaunchStyle; | 12 | readonly launchStyle: EditorLaunchStyle; |
| 13 | /** | ||
| 14 | * URL scheme for editors that support VS Code's remote deep links | ||
| 15 | * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code | ||
| 16 | * and forks that ship the Remote-SSH machinery. | ||
| 17 | */ | ||
| 18 | readonly remoteScheme?: string; | ||
| 13 | }; | 19 | }; |
| 14 | 20 | ||
| 15 | export const EDITORS = [ | 21 | export const EDITORS = [ |
| 16 | { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, | 22 | { |
| 23 | id: "cursor", | ||
| 24 | label: "Cursor", | ||
| 25 | commands: ["cursor"], | ||
| 26 | launchStyle: "goto", | ||
| 27 | remoteScheme: "cursor", | ||
| 28 | }, | ||
| 17 | { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, | 29 | { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, |
| 18 | { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, | 30 | { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, |
| 19 | { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, | 31 | { |
| 32 | id: "vscode", | ||
| 33 | label: "VS Code", | ||
| 34 | commands: ["code"], | ||
| 35 | launchStyle: "goto", | ||
| 36 | remoteScheme: "vscode", | ||
| 37 | }, | ||
| 20 | { | 38 | { |
| 21 | id: "vscode-insiders", | 39 | id: "vscode-insiders", |
| 22 | label: "VS Code Insiders", | 40 | label: "VS Code Insiders", |
| 23 | commands: ["code-insiders"], | 41 | commands: ["code-insiders"], |
| 24 | launchStyle: "goto", | 42 | launchStyle: "goto", |
| 43 | remoteScheme: "vscode-insiders", | ||
| 44 | }, | ||
| 45 | { | ||
| 46 | id: "vscodium", | ||
| 47 | label: "VSCodium", | ||
| 48 | commands: ["codium"], | ||
| 49 | launchStyle: "goto", | ||
| 50 | remoteScheme: "vscodium", | ||
| 25 | }, | 51 | }, |
| 26 | { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, | ||
| 27 | { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, | 52 | { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, |
| 28 | { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, | 53 | { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, |
| 29 | { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, | 54 | { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, |
| ... | @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ | ... | @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ |
| 50 | }); | 75 | }); |
| 51 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; | 76 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 52 | 77 | ||
| 78 | const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; | ||
| 79 | |||
| 80 | /** Editors that can open a remote workspace via `vscode-remote` deep links. */ | ||
| 81 | export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) => | ||
| 82 | remoteSchemeOf(editor) !== undefined ? [editor.id] : [], | ||
| 83 | ); | ||
| 84 | |||
| 85 | export const remoteSchemeForEditor = (id: EditorId): string | undefined => { | ||
| 86 | const editor = EDITORS.find((candidate) => candidate.id === id); | ||
| 87 | return editor === undefined ? undefined : remoteSchemeOf(editor); | ||
| 88 | }; | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that | ||
| 92 | * opens `absolutePath` on `host` in the local editor over SSH. Returns | ||
| 93 | * undefined for editors without remote deep-link support. | ||
| 94 | */ | ||
| 95 | export const buildRemoteOpenUrl = (input: { | ||
| 96 | readonly editor: EditorId; | ||
| 97 | readonly host: string; | ||
| 98 | readonly absolutePath: string; | ||
| 99 | }): string | undefined => { | ||
| 100 | const scheme = remoteSchemeForEditor(input.editor); | ||
| 101 | if (scheme === undefined) { | ||
| 102 | return undefined; | ||
| 103 | } | ||
| 104 | // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. | ||
| 105 | const posixPath = input.absolutePath.replaceAll("\\", "/"); | ||
| 106 | const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; | ||
| 107 | const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); | ||
| 108 | return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; | ||
| 109 | }; | ||
| 110 | |||
| 111 | /** | ||
| 112 | * SSH hostnames an environment advertises for remote open links. Reachability | ||
| 113 | * is client-side; the server only advertises names that resolve to itself and | ||
| 114 | * gates them on a local sshd listen check. Ordered most-reachable first | ||
| 115 | * (tailnet MagicDNS name, then mDNS `<hostname>.local`). | ||
| 116 | */ | ||
| 117 | export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); | ||
| 118 | export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; | ||
| 119 | |||
| 120 | export const RemoteOpenTarget = Schema.Struct({ | ||
| 121 | kind: RemoteOpenTargetKind, | ||
| 122 | host: TrimmedNonEmptyString, | ||
| 123 | }); | ||
| 124 | export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; | ||
| 125 | |||
| 53 | export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()( | 126 | export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()( |
| 54 | "ExternalLauncherUnknownEditorError", | 127 | "ExternalLauncherUnknownEditorError", |
| 55 | { | 128 | { |
packages/contracts/src/ipc.ts+7| ... | @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr | ... | @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr |
| 92 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 92 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 93 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 93 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 94 | import type { ClientSettings } from "./settings.ts"; | 94 | import type { ClientSettings } from "./settings.ts"; |
| 95 | import type { EditorId } from "./editor.ts"; | ||
| 95 | import type { | 96 | import type { |
| 96 | SourceControlCloneRepositoryInput, | 97 | SourceControlCloneRepositoryInput, |
| 97 | SourceControlCloneRepositoryResult, | 98 | SourceControlCloneRepositoryResult, |
| ... | @@ -1072,6 +1073,12 @@ export interface DesktopBridge { | ... | @@ -1072,6 +1073,12 @@ export interface DesktopBridge { |
| 1072 | position?: { x: number; y: number }, | 1073 | position?: { x: number; y: number }, |
| 1073 | ) => Promise<T | null>; | 1074 | ) => Promise<T | null>; |
| 1074 | openExternal: (url: string) => Promise<boolean>; | 1075 | openExternal: (url: string) => Promise<boolean>; |
| 1076 | /** | ||
| 1077 | * Probe this desktop machine for installed remote-capable editor CLIs | ||
| 1078 | * (used for remote open-in-editor deep links). Optional: older desktop | ||
| 1079 | * builds lack it; callers fall back to VS Code only. | ||
| 1080 | */ | ||
| 1081 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; | ||
| 1075 | onMenuAction: (listener: (action: string) => void) => () => void; | 1082 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1076 | getWindowFullscreenState: () => boolean; | 1083 | getWindowFullscreenState: () => boolean; |
| 1077 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; | 1084 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
packages/contracts/src/server.ts+7-1| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | KeybindingWhen, | 17 | KeybindingWhen, |
| 18 | ResolvedKeybindingsConfig, | 18 | ResolvedKeybindingsConfig, |
| 19 | } from "./keybindings.ts"; | 19 | } from "./keybindings.ts"; |
| 20 | import { EditorId } from "./editor.ts"; | 20 | import { EditorId, RemoteOpenTarget } from "./editor.ts"; |
| 21 | import { ModelCapabilities } from "./model.ts"; | 21 | import { ModelCapabilities } from "./model.ts"; |
| 22 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 22 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 23 | import { ServerSettings } from "./settings.ts"; | 23 | import { ServerSettings } from "./settings.ts"; |
| ... | @@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({ |
| 428 | // Editor ids grow over time; drop ones this build does not know rather than | 428 | // Editor ids grow over time; drop ones this build does not know rather than |
| 429 | // failing the whole config decode. | 429 | // failing the whole config decode. |
| 430 | availableEditors: ForwardCompatibleArray(EditorId), | 430 | availableEditors: ForwardCompatibleArray(EditorId), |
| 431 | /** | ||
| 432 | * SSH hosts this environment advertises for remote open-in-editor links. | ||
| 433 | * Absent on servers that predate the feature; empty when the machine has no | ||
| 434 | * sshd or no advertisable name. | ||
| 435 | */ | ||
| 436 | remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), | ||
| 431 | observability: ServerObservability, | 437 | observability: ServerObservability, |
| 432 | settings: ServerSettings, | 438 | settings: ServerSettings, |
| 433 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ | 439 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ |
packages/shared/src/Net.ts+7| ... | @@ -39,6 +39,12 @@ export interface NetServiceShape { | ... | @@ -39,6 +39,12 @@ export interface NetServiceShape { |
| 39 | */ | 39 | */ |
| 40 | readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>; | 40 | readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>; |
| 41 | 41 | ||
| 42 | /** | ||
| 43 | * Returns true when something accepts TCP connections on {host, port}. | ||
| 44 | * Unlike the bind-side checks this works for privileged ports (<1024). | ||
| 45 | */ | ||
| 46 | readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect<boolean>; | ||
| 47 | |||
| 42 | /** | 48 | /** |
| 43 | * Reserve an ephemeral loopback port and release it immediately. | 49 | * Reserve an ephemeral loopback port and release it immediately. |
| 44 | */ | 50 | */ |
| ... | @@ -183,6 +189,7 @@ export const make = () => { | ... | @@ -183,6 +189,7 @@ export const make = () => { |
| 183 | return { | 189 | return { |
| 184 | canListenOnHost, | 190 | canListenOnHost, |
| 185 | isPortAvailableOnLoopback, | 191 | isPortAvailableOnLoopback, |
| 192 | hasListenerOnHost, | ||
| 186 | reserveLoopbackPort, | 193 | reserveLoopbackPort, |
| 187 | findAvailablePort: (preferred) => | 194 | findAvailablePort: (preferred) => |
| 188 | Effect.gen(function* () { | 195 | Effect.gen(function* () { |
packages/ssh/src/tunnel.test.ts+1| ... | @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); | ... | @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); |
| 80 | const testNetService = NetService.NetService.of({ | 80 | const testNetService = NetService.NetService.of({ |
| 81 | canListenOnHost: () => Effect.succeed(true), | 81 | canListenOnHost: () => Effect.succeed(true), |
| 82 | isPortAvailableOnLoopback: () => Effect.succeed(true), | 82 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 83 | hasListenerOnHost: () => Effect.succeed(false), | ||
| 83 | reserveLoopbackPort: () => Effect.succeed(41_773), | 84 | reserveLoopbackPort: () => Effect.succeed(41_773), |
| 84 | findAvailablePort: (preferred) => Effect.succeed(preferred), | 85 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 85 | }); | 86 | }); |
scripts/dev-runner.test.ts+1| ... | @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } | ... | @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } |
| 35 | const netServiceLayer = Layer.succeed(NetService.NetService, { | 35 | const netServiceLayer = Layer.succeed(NetService.NetService, { |
| 36 | canListenOnHost: () => Effect.succeed(true), | 36 | canListenOnHost: () => Effect.succeed(true), |
| 37 | isPortAvailableOnLoopback: () => Effect.succeed(true), | 37 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 38 | hasListenerOnHost: () => Effect.succeed(false), | ||
| 38 | reserveLoopbackPort: () => Effect.succeed(49_152), | 39 | reserveLoopbackPort: () => Effect.succeed(49_152), |
| 39 | findAvailablePort: (port) => Effect.succeed(port), | 40 | findAvailablePort: (port) => Effect.succeed(port), |
| 40 | }); | 41 | }); |