From 7b5931bc36d9665434aaaa72de58900eb9af10a5 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 5 Jul 2026 01:16:46 +0200 Subject: [PATCH 01/10] Add VS Code tunnel editor support --- apps/server/src/vscodeTunnel.ts | 111 +++++++++++++++++ apps/server/src/ws.ts | 114 ++++++++++++----- apps/web/src/components/ChatView.tsx | 6 + apps/web/src/components/chat/ChatHeader.tsx | 14 ++- apps/web/src/components/chat/OpenInPicker.tsx | 117 +++++++++++++++--- .../settings/ConnectionsSettings.tsx | 72 +++++++++++ .../client-runtime/src/rpc/session.test.ts | 7 ++ .../client-runtime/src/state/server.test.ts | 38 ++++++ packages/client-runtime/src/state/server.ts | 9 ++ packages/contracts/src/server.ts | 42 +++++++ packages/contracts/src/settings.ts | 6 + 11 files changed, 485 insertions(+), 51 deletions(-) create mode 100644 apps/server/src/vscodeTunnel.ts diff --git a/apps/server/src/vscodeTunnel.ts b/apps/server/src/vscodeTunnel.ts new file mode 100644 index 00000000000..b0114dfd5fb --- /dev/null +++ b/apps/server/src/vscodeTunnel.ts @@ -0,0 +1,111 @@ +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 { ProcessRunner } from "./processRunner.ts"; + +const VSCODE_TUNNEL_STATUS_TIMEOUT = Duration.millis(1_500); + +const VSCodeTunnelStatusJson = Schema.Struct({ + tunnel: Schema.optional( + 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), +); + +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; + readonly networkAccessEnabled: boolean; +}) { + if (!input.enabled || !input.networkAccessEnabled) { + return { + tunnel: null, + status: UNCHECKED_STATUS, + } satisfies ResolvedVSCodeTunnel; + } + + const runner = yield* 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(output.stdout.trim()); + 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..5319d68cc75 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,13 @@ const makeWsRpcLayer = ( ); }; + const networkAccessEnabled = config.host !== undefined || config.tailscaleServeEnabled; + const resolveVSCodeTunnelForSettings = (settings: { enableVSCodeRemoteTunnels: boolean }) => + VSCodeTunnel.resolveVSCodeTunnel({ + enabled: settings.enableVSCodeRemoteTunnels, + networkAccessEnabled, + }); + const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; @@ -912,6 +923,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const vscodeTunnel = yield* resolveVSCodeTunnelForSettings(settings); return { environment, @@ -922,6 +934,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 +1706,81 @@ 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 = + serverSettings.streamChanges.pipe( + Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), + 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.catchCause((cause) => + Effect.logWarning("failed to refresh VS Code tunnel settings", { + cause, + }).pipe(Effect.as({ enableVSCodeRemoteTunnels: false })), + ), + Effect.flatMap(toVSCodeTunnelUpdateEvent), + ), + ), + ); 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 +1869,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..073bd7d3df3 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,4 +1,9 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ServerVSCodeTunnel, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; @@ -34,9 +39,23 @@ import { import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { readLocalApi } from "~/localApi"; + +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 +170,31 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray availableEditorSet.has(option.value)); }; +function encodeVSCodeTunnelPath(cwd: string): string { + return cwd + .replaceAll("\\", "/") + .split("/") + .filter((segment, index) => index > 0 || segment.length > 0) + .map(encodeURIComponent) + .join("/"); +} + +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 +202,49 @@ 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 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 = + 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; + const openOption = useCallback( + (option: PickerOption | null) => { + if (!openInCwd || !option) return; + if (isVSCodeTunnelOption(option)) { + const url = openVSCodeRemoteTunnelsInDesktop + ? buildVSCodeTunnelDesktopUrl(option.vscodeTunnel.machineName, openInCwd) + : buildVSCodeTunnelUrl(option.vscodeTunnel.machineName, openInCwd); + return readLocalApi()?.shell.openExternal(url); + } + + const editor = option.value; const result = openInEditorMutation({ environmentId, input: { @@ -189,7 +255,13 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + openInCwd, + openInEditorMutation, + openVSCodeRemoteTunnelsInDesktop, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -230,8 +302,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 &&