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.
This commit is contained in:
Neil
2026-10-15 23:21:23 -07:00
committed by GitHub
parent 37c00630d4
commit 180d9e27eb
6 changed files with 225 additions and 3 deletions
@@ -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 <SshPortsPanel key={host.connectionId} activeConnectionId={host.connectionId} />
case 'endpoint':
return <LocalWorkspacePortsPanel isVisible={isVisible} runtimeTarget={host.target} />
return (
<LocalWorkspacePortsPanel
isVisible={isVisible}
runtimeTarget={host.target}
sshForwardTargetId={sshForwardTargetId}
/>
)
case 'host-scoped':
case 'unknown':
return (
@@ -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({
</Tooltip>
</div>
{sshForwardTargetId && <SshForwardedPortsSection connectionId={sshForwardTargetId} />}
{displayScan?.unavailableReason && (
<div className="px-3 py-2 text-xs text-muted-foreground border-b border-border">
{translate(
@@ -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)
})
})
@@ -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
}
@@ -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(
<TooltipProvider>
<SshForwardedPortsSection connectionId="box" />
</TooltipProvider>
)
}
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')
})
})
@@ -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<PortForwardDialogState>({ mode: 'closed' })
return (
<div className="px-3 pt-2" data-ssh-forwarded-ports="">
<div className="mb-1 flex items-center gap-1">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1 text-left"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
>
<ChevronRight
size={12}
className={cn('text-muted-foreground transition-transform', !collapsed && 'rotate-90')}
/>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{translate('auto.components.right.sidebar.PortsPanel.ddbe58d74e', 'Forwarded')}
</span>
{forwards.length > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground/60">{forwards.length}</span>
)}
</button>
<Button
type="button"
variant="ghost"
size="xs"
disabled={!connected}
onClick={() => setDialogState({ mode: 'add', defaults: { targetId: connectionId } })}
>
<Plus />
{translate('auto.components.right.sidebar.PortsPanel.a103dae837', 'Add')}
</Button>
</div>
{!collapsed &&
(forwards.length > 0 ? (
forwards.map((entry) => (
<SshForwardedPortRow
key={entry.id}
entry={entry}
onEdit={() => 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))
}
/>
))
) : (
<p className="py-1 text-xs text-muted-foreground">
{translate(
'auto.components.right.sidebar.PortsPanel.04efd3dad4',
'Forward a port to access remote services on your local machine.'
)}
</p>
))}
<SshPortForwardDialog
state={dialogState}
activeConnectionId={connectionId}
onClose={() => setDialogState({ mode: 'closed' })}
/>
</div>
)
}