diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f5c85769cee..162704e360d 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -46,6 +46,32 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens VS Code protocol URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "vscode://vscode-remote/tunnel+devbox/workspaces/demo", + ); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["vscode://vscode-remote/tunnel+devbox/workspaces/demo"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open non-tunnel VS Code URLs", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("vscode://file/etc/passwd"); + + assert.equal(result, 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 316d3138bfa..e715512a67b 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -7,6 +7,14 @@ import * as Electron from "electron"; const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +function isSafeVSCodeTunnelUrl(url: URL): boolean { + return ( + url.protocol === "vscode:" && + url.hostname === "vscode-remote" && + url.pathname.startsWith("/tunnel+") + ); +} + export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { return Option.none(); @@ -14,7 +22,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) || isSafeVSCodeTunnelUrl(url) + ? Option.some(url.href) + : Option.none(); } catch { return Option.none(); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 26528c84d34..94f9938d4af 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4246,7 +4246,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const events = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.filter( + (event) => event.type === "snapshot" || event.type === "keybindingsUpdated", + ), + Stream.take(2), + Stream.runCollect, + ), ), ); @@ -4310,7 +4316,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const events = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + client[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.filter( + (event) => event.type === "snapshot" || event.type === "providerStatuses", + ), + Stream.take(2), + Stream.runCollect, + ), ), ); diff --git a/apps/server/src/vscodeTunnel.test.ts b/apps/server/src/vscodeTunnel.test.ts new file mode 100644 index 00000000000..7c3b007e8e4 --- /dev/null +++ b/apps/server/src/vscodeTunnel.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "./processRunner.ts"; +import * as VSCodeTunnel from "./vscodeTunnel.ts"; + +describe("vscode tunnel status resolution", () => { + it.effect("parses connected status when JSON is preceded by non-JSON output", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.status.checked).toBe(true); + expect(resolved.status.connected).toBe(true); + expect(resolved.status.machineName).toBe("devbox"); + expect(resolved.status.serviceInstalled).toBe(true); + expect(resolved.tunnel).toEqual({ machineName: "devbox" }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: [ + "Some informational output...", + '{"tunnel":{"name":"devbox","tunnel":"connected"},"service_installed":true}', + ].join("\n"), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + + it.effect("parses connected status when JSON is pretty-printed", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.status.checked).toBe(true); + expect(resolved.status.connected).toBe(true); + expect(resolved.status.machineName).toBe("devbox"); + expect(resolved.status.serviceInstalled).toBe(true); + expect(resolved.tunnel).toEqual({ machineName: "devbox" }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: [ + "Some informational output...", + "{", + ' "tunnel": {', + ' "name": "devbox",', + ' "tunnel": "connected"', + " },", + ' "service_installed": true', + "}", + "Trailing output line", + ].join("\n"), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + + it.effect("accepts tunnel:null and preserves service status", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.tunnel).toBeNull(); + expect(resolved.status).toEqual({ + checked: true, + connected: false, + machineName: null, + serviceInstalled: true, + }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: '{"tunnel":null,"service_installed":true}', + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + + it.effect("skips unrelated JSON and parses the status payload", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.status.checked).toBe(true); + expect(resolved.status.connected).toBe(true); + expect(resolved.status.machineName).toBe("devbox"); + expect(resolved.status.serviceInstalled).toBe(true); + expect(resolved.tunnel).toEqual({ machineName: "devbox" }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: [ + '{"log":"starting"}', + '{"tunnel":{"name":"devbox","tunnel":"connected"},"service_installed":true}', + ].join("\n"), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + + it.effect("parses status JSON even when stray closing braces precede the payload", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.status.checked).toBe(true); + expect(resolved.status.connected).toBe(true); + expect(resolved.status.machineName).toBe("devbox"); + expect(resolved.status.serviceInstalled).toBe(true); + expect(resolved.tunnel).toEqual({ machineName: "devbox" }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: [ + "noise } }", + '{"tunnel":{"name":"devbox","tunnel":"connected"},"service_installed":true}', + ].join("\n"), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + + it.effect("returns unavailable status when no JSON object is present", () => + Effect.gen(function* () { + const resolved = yield* VSCodeTunnel.resolveVSCodeTunnel({ + enabled: true, + }); + + expect(resolved.tunnel).toBeNull(); + expect(resolved.status).toEqual({ + checked: true, + connected: false, + machineName: null, + serviceInstalled: null, + }); + }).pipe( + Effect.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + Effect.succeed({ + stdout: "status unavailable", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); +}); diff --git a/apps/server/src/vscodeTunnel.ts b/apps/server/src/vscodeTunnel.ts new file mode 100644 index 00000000000..966d967b54e --- /dev/null +++ b/apps/server/src/vscodeTunnel.ts @@ -0,0 +1,183 @@ +import { type ServerVSCodeTunnel, type ServerVSCodeTunnelStatus } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "./processRunner.ts"; + +const VSCODE_TUNNEL_STATUS_TIMEOUT = Duration.millis(1_500); + +const VSCodeTunnelStatusJson = Schema.Struct({ + tunnel: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.String), + tunnel: Schema.optional(Schema.String), + }), + ), + ), + service_installed: Schema.optional(Schema.Boolean), +}); + +const decodeVSCodeTunnelStatusJson = Schema.decodeUnknownOption( + Schema.fromJsonString(VSCodeTunnelStatusJson), +); + +function extractVSCodeTunnelStatusJson(stdout: string): string { + const trimmed = stdout.trim(); + let start = -1; + let depth = 0; + let inString = false; + let escaped = false; + const candidates: Array = []; + + for (let index = 0; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (char === undefined) break; + + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + if (depth === 0) { + start = index; + } + depth += 1; + continue; + } + + if (char === "}") { + if (depth === 0) { + continue; + } + depth -= 1; + if (depth === 0 && start >= 0) { + candidates.push(trimmed.slice(start, index + 1)); + start = -1; + } + } + } + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if ( + typeof parsed === "object" && + parsed !== null && + (Object.prototype.hasOwnProperty.call(parsed, "tunnel") || + Object.prototype.hasOwnProperty.call(parsed, "service_installed")) + ) { + return candidate; + } + } catch { + // Ignore invalid JSON fragments and continue scanning. + } + } + + return ""; +} + +const UNCHECKED_STATUS: ServerVSCodeTunnelStatus = { + checked: false, + connected: false, + machineName: null, + serviceInstalled: null, +}; + +const CHECKED_UNAVAILABLE_STATUS: ServerVSCodeTunnelStatus = { + checked: true, + connected: false, + machineName: null, + serviceInstalled: null, +}; + +export interface ResolvedVSCodeTunnel { + readonly tunnel: ServerVSCodeTunnel | null; + readonly status: ServerVSCodeTunnelStatus; +} + +export const resolveVSCodeTunnel = Effect.fn("vscodeTunnel.resolve")(function* (input: { + readonly enabled: boolean; +}) { + if (!input.enabled) { + return { + tunnel: null, + status: UNCHECKED_STATUS, + } satisfies ResolvedVSCodeTunnel; + } + + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner + .run({ + command: "code", + args: ["tunnel", "status"], + timeout: VSCODE_TUNNEL_STATUS_TIMEOUT, + maxOutputBytes: 16 * 1024, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + + if (Option.isNone(result)) { + return { + tunnel: null, + status: CHECKED_UNAVAILABLE_STATUS, + } satisfies ResolvedVSCodeTunnel; + } + const output = result.value; + if (output.timedOut || output.code !== 0) { + return { + tunnel: null, + status: CHECKED_UNAVAILABLE_STATUS, + } satisfies ResolvedVSCodeTunnel; + } + + const decoded = decodeVSCodeTunnelStatusJson(extractVSCodeTunnelStatusJson(output.stdout)); + if (Option.isNone(decoded)) { + return { + tunnel: null, + status: CHECKED_UNAVAILABLE_STATUS, + } satisfies ResolvedVSCodeTunnel; + } + const tunnel = decoded.value.tunnel; + const machineName = tunnel?.name?.trim() ?? ""; + const connected = Boolean(machineName) && tunnel?.tunnel?.toLowerCase() === "connected"; + const status: ServerVSCodeTunnelStatus = { + checked: true, + connected, + machineName: machineName || null, + serviceInstalled: decoded.value.service_installed ?? null, + }; + + if (!connected) { + return { + tunnel: null, + status, + } satisfies ResolvedVSCodeTunnel; + } + + return { + tunnel: { + machineName, + }, + status, + } satisfies ResolvedVSCodeTunnel; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..51e06b35af2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -37,6 +37,7 @@ import { type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, + type ServerConfigStreamEvent, ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, @@ -93,10 +94,12 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as VSCodeTunnel from "./vscodeTunnel.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as ProcessRunner from "./processRunner.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -115,6 +118,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const VSCODE_TUNNEL_REFRESH_INTERVAL = Duration.minutes(1); function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); @@ -904,6 +908,11 @@ const makeWsRpcLayer = ( ); }; + const resolveVSCodeTunnelForSettings = (settings: { enableVSCodeRemoteTunnels: boolean }) => + VSCodeTunnel.resolveVSCodeTunnel({ + enabled: settings.enableVSCodeRemoteTunnels, + }); + const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; @@ -912,6 +921,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const vscodeTunnel = yield* resolveVSCodeTunnelForSettings(settings); return { environment, @@ -922,6 +932,8 @@ const makeWsRpcLayer = ( issues: keybindingsConfig.issues, providers, availableEditors: yield* externalLauncher.resolveAvailableEditors(), + vscodeTunnel: vscodeTunnel.tunnel, + vscodeTunnelStatus: vscodeTunnel.status, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1692,40 +1704,104 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { - const keybindingsUpdates = keybindings.streamChanges.pipe( - Stream.map((event) => ({ - version: 1 as const, - type: "keybindingsUpdated" as const, - payload: { - keybindings: event.keybindings, - issues: event.issues, - }, - })), - ); - const providerStatuses = providerRegistry.streamChanges.pipe( - Stream.map((providers) => ({ - version: 1 as const, - type: "providerStatuses" as const, - payload: { providers }, - })), - Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), - ); - const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), - ); + const processRunner = yield* ProcessRunner.ProcessRunner; + const keybindingsUpdates: Stream.Stream = + keybindings.streamChanges.pipe( + Stream.map((event) => ({ + version: 1 as const, + type: "keybindingsUpdated" as const, + payload: { + keybindings: event.keybindings, + issues: event.issues, + }, + })), + ); + const providerStatuses: Stream.Stream = + providerRegistry.streamChanges.pipe( + Stream.map((providers) => ({ + version: 1 as const, + type: "providerStatuses" as const, + payload: { providers }, + })), + Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), + ); + const settingsUpdates: Stream.Stream = + serverSettings.streamChanges.pipe( + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.map((settings) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings }, + })), + ); + const toVSCodeTunnelUpdateEvent = (settings: { + readonly enableVSCodeRemoteTunnels: boolean; + }) => + resolveVSCodeTunnelForSettings(settings).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.map((vscodeTunnel) => ({ + version: 1 as const, + type: "vscodeTunnelUpdated" as const, + payload: { + vscodeTunnel: vscodeTunnel.tunnel, + vscodeTunnelStatus: vscodeTunnel.status, + }, + })), + ); + const vscodeTunnelSettingsUpdates: Stream.Stream = (() => { + let previousTunnelEnabled: boolean | undefined; + return serverSettings.streamChanges.pipe( + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + Stream.filter((settings) => { + const changed = + previousTunnelEnabled === undefined || + previousTunnelEnabled !== settings.enableVSCodeRemoteTunnels; + previousTunnelEnabled = settings.enableVSCodeRemoteTunnels; + return changed; + }), + Stream.mapEffect(toVSCodeTunnelUpdateEvent), + ); + })(); + const vscodeTunnelPeriodicUpdates: Stream.Stream = + Stream.tick(VSCODE_TUNNEL_REFRESH_INTERVAL).pipe( + Stream.mapEffect(() => + serverSettings.getSettings.pipe( + Effect.map((settings) => + ServerSettings.redactServerSettingsForClient(settings), + ), + Effect.flatMap(toVSCodeTunnelUpdateEvent), + Effect.catchCause((cause) => + Effect.logWarning("failed to refresh VS Code tunnel settings", { + cause, + }).pipe( + Effect.as({ + version: 1 as const, + type: "vscodeTunnelUpdated" as const, + payload: { + vscodeTunnel: null, + vscodeTunnelStatus: { + checked: true, + connected: false, + machineName: null, + serviceInstalled: null, + }, + }, + }), + ), + ), + ), + ), + ); yield* providerRegistry .refresh() .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - const liveUpdates = Stream.merge( - keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + const liveUpdates: Stream.Stream = keybindingsUpdates.pipe( + Stream.merge(providerStatuses), + Stream.merge(settingsUpdates), + Stream.merge(vscodeTunnelSettingsUpdates), + Stream.merge(vscodeTunnelPeriodicUpdates), ); return Stream.concat( @@ -1814,6 +1890,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provideMerge(ProcessRunner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscovery.layer.pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..8d2ae7c2326 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5047,6 +5047,12 @@ function ChatViewContent(props: ChatViewProps) { } keybindings={keybindings} availableEditors={availableEditors} + vscodeTunnel={ + serverConfig?.settings.enableVSCodeRemoteTunnels ? serverConfig.vscodeTunnel : null + } + openVSCodeRemoteTunnelsInDesktop={ + serverConfig?.settings.openVSCodeRemoteTunnelsInDesktop ?? false + } rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} onRunProjectScript={runProjectScript} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ef3ec863d0b..3f6d90609bc 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -3,6 +3,7 @@ import { type EditorId, type ProjectScript, type ResolvedKeybindingsConfig, + type ServerVSCodeTunnel, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -29,6 +30,8 @@ interface ChatHeaderProps { preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + vscodeTunnel: ServerVSCodeTunnel | null; + openVSCodeRemoteTunnelsInDesktop: boolean; rightPanelOpen: boolean; gitCwd: string | null; onRunProjectScript: (script: ProjectScript) => void; @@ -63,6 +66,8 @@ export const ChatHeader = memo(function ChatHeader({ preferredScriptId, keybindings, availableEditors, + vscodeTunnel, + openVSCodeRemoteTunnelsInDesktop, rightPanelOpen, gitCwd, onRunProjectScript, @@ -76,6 +81,9 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const showTunnelPicker = Boolean( + !showOpenInPicker && activeProjectName && vscodeTunnel && openInCwd, + ); return (
@@ -111,12 +119,14 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteScript={onDeleteProjectScript} /> )} - {showOpenInPicker && ( + {(showOpenInPicker || showTunnelPicker) && ( )} {activeProjectName && ( diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9def7a4646c..f74bf7e0841 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,5 +1,10 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo } from "react"; +import { + EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ServerVSCodeTunnel, +} from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; @@ -34,9 +39,24 @@ import { import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { readLocalApi } from "~/localApi"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +type EditorPickerOption = { label: string; Icon: Icon; value: EditorId }; +type VSCodeTunnelPickerOption = { + label: string; + Icon: Icon; + value: "vscode-tunnel"; + vscodeTunnel: ServerVSCodeTunnel; +}; +type PickerOption = EditorPickerOption | VSCodeTunnelPickerOption; + +function isVSCodeTunnelOption(option: PickerOption): option is VSCodeTunnelPickerOption { + return option.value === "vscode-tunnel"; +} const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ + const baseOptions: ReadonlyArray = [ { label: "Cursor", Icon: CursorIcon, @@ -151,10 +171,33 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray availableEditorSet.has(option.value)); }; +function encodeVSCodeTunnelPath(cwd: string): string { + const normalized = cwd.replaceAll("\\", "/"); + const hasLeadingSlash = normalized.startsWith("/"); + const encodedSegments = normalized + .split("/") + .filter((segment) => segment.length > 0) + .map(encodeURIComponent) + .join("/"); + return hasLeadingSlash ? `/${encodedSegments}` : encodedSegments; +} + +function buildVSCodeTunnelUrl(machineName: string, cwd: string): string { + const encodedPath = encodeVSCodeTunnelPath(cwd); + return `https://vscode.dev/tunnel/${encodeURIComponent(machineName)}/${encodedPath}`; +} + +function buildVSCodeTunnelDesktopUrl(machineName: string, cwd: string): string { + const encodedPath = encodeVSCodeTunnelPath(cwd); + return `vscode://vscode-remote/tunnel+${encodeURIComponent(machineName)}/${encodedPath}`; +} + export const OpenInPicker = memo(function OpenInPicker({ environmentId, keybindings, availableEditors, + vscodeTunnel = null, + openVSCodeRemoteTunnelsInDesktop = false, openInCwd, compact = false, enableShortcut = true, @@ -162,23 +205,87 @@ export const OpenInPicker = memo(function OpenInPicker({ environmentId: EnvironmentId; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + vscodeTunnel?: ServerVSCodeTunnel | null; + openVSCodeRemoteTunnelsInDesktop?: boolean; openInCwd: string | null; compact?: boolean; enableShortcut?: boolean; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); - const options = useMemo( + const [preferVSCodeTunnel, setPreferVSCodeTunnel] = useState(false); + const latestEditorSelectionRef = useRef(0); + const editorOptions = useMemo( () => resolveOptions(navigator.platform, availableEditors), [availableEditors], ); - const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; + const vscodeTunnelOption = useMemo( + () => + vscodeTunnel && openInCwd + ? { + label: `VS Code Tunnel (${vscodeTunnel.machineName})`, + Icon: VisualStudioCode, + value: "vscode-tunnel", + vscodeTunnel, + } + : null, + [openInCwd, vscodeTunnel], + ); + const options = useMemo( + () => (vscodeTunnelOption ? [...editorOptions, vscodeTunnelOption] : editorOptions), + [editorOptions, vscodeTunnelOption], + ); + const primaryOption = + (preferVSCodeTunnel ? vscodeTunnelOption : null) ?? + editorOptions.find(({ value }) => value === preferredEditor) ?? + (editorOptions.length === 0 ? vscodeTunnelOption : null); - const openInEditor = useCallback( - (editorId: EditorId | null) => { - if (!openInCwd) return; - const editor = editorId ?? preferredEditor; - if (!editor) return; + useEffect(() => { + if (!vscodeTunnelOption && preferVSCodeTunnel) { + setPreferVSCodeTunnel(false); + } + }, [preferVSCodeTunnel, vscodeTunnelOption]); + + const openOption = useCallback( + (option: PickerOption | null) => { + if (!openInCwd || !option) return; + if (isVSCodeTunnelOption(option)) { + const editorSelectionVersion = latestEditorSelectionRef.current; + const url = openVSCodeRemoteTunnelsInDesktop + ? buildVSCodeTunnelDesktopUrl(option.vscodeTunnel.machineName, openInCwd) + : buildVSCodeTunnelUrl(option.vscodeTunnel.machineName, openInCwd); + const localApi = readLocalApi(); + if (!localApi) { + setPreferVSCodeTunnel(false); + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + void localApi.shell + .openExternal(url) + .then(() => { + if (latestEditorSelectionRef.current !== editorSelectionVersion) return; + setPreferVSCodeTunnel(true); + }) + .catch((error) => { + if (latestEditorSelectionRef.current !== editorSelectionVersion) return; + setPreferVSCodeTunnel(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open tunnel link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + + const editor = option.value; + latestEditorSelectionRef.current += 1; + setPreferVSCodeTunnel(false); const result = openInEditorMutation({ environmentId, input: { @@ -189,7 +296,13 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + openInCwd, + openInEditorMutation, + openVSCodeRemoteTunnelsInDesktop, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -202,27 +315,14 @@ export const OpenInPicker = memo(function OpenInPicker({ const handler = (e: globalThis.KeyboardEvent) => { if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!openInCwd) return; - if (!preferredEditor) return; + if (!primaryOption) return; e.preventDefault(); - void openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor: preferredEditor, - }, - }); + void openOption(primaryOption); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [ - enableShortcut, - environmentId, - keybindings, - openInCwd, - openInEditorMutation, - preferredEditor, - ]); + }, [enableShortcut, keybindings, openInCwd, openOption, primaryOption]); return ( @@ -230,8 +330,8 @@ export const OpenInPicker = memo(function OpenInPicker({ aria-label={compact ? "Open file in preferred editor" : undefined} size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd} - onClick={() => openInEditor(preferredEditor)} + disabled={!primaryOption || !openInCwd} + onClick={() => openOption(primaryOption)} > {primaryOption?.Icon &&