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' })} + /> +
+ ) +}