From 180d9e27ebbe49f1e2b750dd560fcedf87d07c2b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:21:23 -0700 Subject: [PATCH] fix(ports): keep port forwarding controls for an SSH host that moved to its managed server (#29427) A moved host's workspaces are owned by its managed server, so the Ports panel showed the server's port scan and dropped the forwarding list and Add button, though the SSH connection under the server's tunnel still carries and restores this client's forwards. Show that host's forwards and Add control above the server's scan. A forward listens on this machine, so its rows open in the system browser here. --- .../components/right-sidebar/PortsPanel.tsx | 17 +++- .../local-workspace-ports-panel.tsx | 8 +- .../right-sidebar/ports-panel-host.test.ts | 30 +++++++ .../right-sidebar/ports-panel-host.ts | 21 +++++ .../ssh-forwarded-ports-section.test.tsx | 65 ++++++++++++++ .../ssh-forwarded-ports-section.tsx | 87 +++++++++++++++++++ 6 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index 6abac438baa..18483ddb9fc 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -6,7 +6,11 @@ import { translate } from '@/i18n/i18n' import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel' import { SshPortsPanel } from './ssh-ports-panel' import { HostScopedPortsPanel } from './host-scoped-ports-panel' -import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey } from './ports-panel-host' +import { + getPortsPanelOwnerKey, + portsPanelForwardTargetId, + portsPanelHostForOwnerKey +} from './ports-panel-host' export { getLocalWorkspacePortSections } from './local-workspace-port-sections' export { @@ -20,6 +24,9 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React const workspaceId = activeWorktree?.id ?? null const ownerKey = useAppStore((state) => getPortsPanelOwnerKey(state, workspaceId)) const host = useMemo(() => portsPanelHostForOwnerKey(ownerKey), [ownerKey]) + const sshForwardTargetId = useAppStore((state) => + portsPanelForwardTargetId(host, state.runtimeEnvironments) + ) const scopedHost = useMemo( () => host.kind === 'host-scoped' && workspaceId @@ -38,7 +45,13 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React case 'direct-ssh': return case 'endpoint': - return + return ( + + ) case 'host-scoped': case 'unknown': return ( diff --git a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx index ab32cc0073d..84d541d8a4e 100644 --- a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx +++ b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx @@ -25,15 +25,19 @@ import { } from './local-workspace-port-sections' import { LocalPortSection } from './local-port-section' import { LocalPortDetailsDialog } from './local-port-details-dialog' +import { SshForwardedPortsSection } from './ssh-forwarded-ports-section' /** Right-sidebar Ports panel for a workspace that runs on an endpoint's own machine. */ export function LocalWorkspacePortsPanel({ isVisible, - runtimeTarget + runtimeTarget, + sshForwardTargetId = null }: { isVisible: boolean /** The endpoint that owns the active workspace, even if the sidebar is focused elsewhere. */ runtimeTarget: RuntimeClientTarget | null + /** A managed server's SSH host, whose connection carries this client's forwards. */ + sshForwardTargetId?: string | null }): React.JSX.Element { const activeWorktree = useActiveWorktree() const activeRepo = useRepoById(activeWorktree?.repoId ?? null) @@ -227,6 +231,8 @@ export function LocalWorkspacePortsPanel({ + {sshForwardTargetId && } + {displayScan?.unavailableReason && (
{translate( diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts index 1e5b31fb54f..1b503417315 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts @@ -6,6 +6,7 @@ import type { } from '@/lib/worktree-operation-route' import { getPortsPanelOwnerKey, + portsPanelForwardTargetId, portsPanelHostForOwnerKey, portsPanelReachesSshHost, type PortsPanelHost @@ -187,3 +188,32 @@ describe('Ports tab visibility', () => { expect(portsPanelReachesSshHost(host, environments)).toBe(true) }) }) + +describe('Ports panel forwards', () => { + const ports = { localPort: 41_000, remotePort: 6768 } + const environments = [ + { id: 'env-paired' }, + { id: 'env-ssh', orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1, ...ports } }, + { id: 'env-wsl', orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', ...ports } } + ] + const onServer = (environmentId: string): PortsPanelHost => ({ + kind: 'endpoint', + target: { kind: 'environment', environmentId } + }) + + // #29153: a moved host's workspace is owned by its server, yet forwards still ride its SSH host. + it.each<[string, PortsPanelHost, string | null]>([ + ['a workspace on a managed server reached over SSH', onServer('env-ssh'), 'box'], + ['a workspace on a managed WSL server', onServer('env-wsl'), null], + ['a workspace on a paired server', onServer('env-paired'), null], + ['a workspace on an unknown server', onServer('env-gone'), null], + ['a local workspace', { kind: 'endpoint', target: { kind: 'local' } }, null], + [ + 'a direct SSH workspace, which lists its own forwards', + { kind: 'direct-ssh', connectionId: 'box' }, + null + ] + ])('%s', (_name, host, targetId) => { + expect(portsPanelForwardTargetId(host, environments)).toBe(targetId) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.ts index 91853b59eb8..616386c1daf 100644 --- a/src/renderer/src/components/right-sidebar/ports-panel-host.ts +++ b/src/renderer/src/components/right-sidebar/ports-panel-host.ts @@ -2,6 +2,10 @@ import { resolveOwner } from '@/lib/resolve-owner' import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' import { hostRouteForAuthority, type HostRoute } from '@/runtime/runtime-client-target' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + getOrcadSshDeployment, + type OrcadDeploymentLink +} from '../../../../shared/runtime-environments' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import { authorityKey, @@ -77,3 +81,20 @@ export function portsPanelReachesSshHost( return false } } + +/** + * The SSH host whose connection carries this client's forwards for a managed server's workspace. + * Why: the server scans its own ports, but a forward listens on this machine, so it rides the SSH + * connection under the server's tunnel, as it did before the host moved (#29153). + */ +export function portsPanelForwardTargetId( + host: PortsPanelHost, + environments: readonly { id: string; orcadDeployment?: OrcadDeploymentLink }[] +): string | null { + if (host.kind !== 'endpoint' || host.target.kind !== 'environment') { + return null + } + const { environmentId } = host.target + const environment = environments.find((candidate) => candidate.id === environmentId) + return (environment && getOrcadSshDeployment(environment)?.sshTargetId) || null +} diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx new file mode 100644 index 00000000000..48d4c443846 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PortForwardEntry } from '../../../../shared/ssh-types' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { SshForwardedPortsSection } from './ssh-forwarded-ports-section' + +const initialState = useAppStore.getInitialState() + +const forward: PortForwardEntry = { + id: 'fwd-1', + connectionId: 'box', + localPort: 3000, + remoteHost: 'localhost', + remotePort: 3000, + label: 'web' +} +const remapped: PortForwardEntry = { ...forward, id: 'fwd-2', localPort: 13000, label: 'remapped' } + +function renderSection(status: 'connected' | 'disconnected', forwards: PortForwardEntry[]) { + useAppStore.setState({ + portForwardsByConnection: { box: forwards }, + sshConnectionStates: new Map([ + ['box', { targetId: 'box', status, error: null, reconnectAttempt: 0 }] + ]) + }) + return render( + + + + ) +} + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('SSH forwards for a managed server workspace', () => { + it("lists the host's saved forwards", () => { + renderSection('connected', [forward]) + expect(screen.getByText('web')).toBeTruthy() + }) + + it('opens the forward dialog for the SSH host under the server', () => { + renderSection('connected', []) + fireEvent.click(screen.getByRole('button', { name: /Add/ })) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('cannot add a forward while the SSH host is down', () => { + renderSection('disconnected', []) + expect(screen.getByRole('button', { name: /Add/ }).hasAttribute('disabled')).toBe(true) + }) + + it("opens a forward's local address on this machine, where it listens", () => { + const openUrl = vi.fn(async () => undefined) + Object.defineProperty(window, 'api', { value: { shell: { openUrl } }, configurable: true }) + renderSection('connected', [remapped]) + fireEvent.click(screen.getByTitle(/Open in Browser/)) + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:13000') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx new file mode 100644 index 00000000000..b7a4eda8352 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react' +import { ChevronRight, Plus } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import type { PortForwardEntry } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { SshForwardedPortRow } from './ssh-forwarded-port-row' +import { SshPortForwardDialog, type PortForwardDialogState } from './ssh-port-forward-dialog' +import { browserUrlForPortForwardEntry } from '@/lib/workspace-port-urls' + +const NO_FORWARDS: readonly PortForwardEntry[] = [] + +/** This client's forwards through an SSH host whose ports another endpoint scans. */ +export function SshForwardedPortsSection({ + connectionId +}: { + connectionId: string +}): React.JSX.Element { + const forwards = useAppStore((s) => s.portForwardsByConnection[connectionId] ?? NO_FORWARDS) + const connected = useAppStore( + (s) => s.sshConnectionStates.get(connectionId)?.status === 'connected' + ) + const [collapsed, setCollapsed] = useState(false) + const [dialogState, setDialogState] = useState({ mode: 'closed' }) + + return ( +
+
+ + +
+ {!collapsed && + (forwards.length > 0 ? ( + forwards.map((entry) => ( + setDialogState({ mode: 'edit', entry })} + // Why the system browser: the forward listens on this machine, while the workspace's + // Orca browser may be the server's or routed through the SSH host. + onOpenInBrowser={() => + void window.api.shell.openUrl(browserUrlForPortForwardEntry(entry)) + } + /> + )) + ) : ( +

+ {translate( + 'auto.components.right.sidebar.PortsPanel.04efd3dad4', + 'Forward a port to access remote services on your local machine.' + )} +

+ ))} + setDialogState({ mode: 'closed' })} + /> +
+ ) +}