diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index bcf1a900d49..e9919831588 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -43,7 +43,6 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx -inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/settings/AccountsPane.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index cd925cc1303..f77c27f2aff 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -19,7 +19,9 @@ const HOVER_REVEAL_FILES = [ resolve(__dirname, 'github-project/ProjectRow.tsx'), resolve(__dirname, 'right-sidebar/AiVaultSessionRow.tsx'), resolve(__dirname, 'right-sidebar/ChecksPanel.tsx'), - resolve(__dirname, 'right-sidebar/PortsPanel.tsx'), + resolve(__dirname, 'right-sidebar/local-port-row.tsx'), + resolve(__dirname, 'right-sidebar/ssh-detected-port-row.tsx'), + resolve(__dirname, 'right-sidebar/ssh-forwarded-port-row.tsx'), resolve(__dirname, 'right-sidebar/SourceControl.tsx'), resolve(__dirname, 'right-sidebar/checks-panel/comment-row.tsx'), resolve(__dirname, 'settings/MobilePairingQrSection.tsx'), diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx index 2f481e8014d..571086d6596 100644 --- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx @@ -1,152 +1,15 @@ -/* oxlint-disable max-lines -- Why: co-locates forwarded list, detected list, modal form, and -per-entry actions in one file to keep the data flow straightforward. */ -import React, { useCallback, useMemo, useState } from 'react' -import { - ExternalLink, - Copy, - Trash2, - Plus, - Unplug, - ChevronRight, - Pencil, - RefreshCw, - Server, - Box, - Info -} from 'lucide-react' -import { toast } from 'sonner' -import { useAppStore } from '@/store' +import React from 'react' import { useActiveWorktree, useRepoById } from '@/store/selectors' -import { cn } from '@/lib/utils' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - killWorkspacePortForTarget, - getPortOpenBrowserTooltipLabel, - openWorkspacePortInBrowser, - refreshWorkspacePortScanAfterStop, - resolvePortOpenInOrcaBrowser, - scanWorkspacePortsForTarget, - workspacePortRuntimeTargetKey -} from '@/lib/workspace-port-actions' -import { - addressForPort, - addressForPortForwardEntry, - advertisedBrowserUrlForDetectedPort, - advertisedBrowserUrlForForwardedRow, - browserUrlForPortForwardEntry -} from '@/lib/workspace-port-urls' -import { resolveLocalhostLabelRouteForPort } from '@/lib/workspace-port-localhost-label-selector' -import { useMountedRef } from '@/hooks/useMountedRef' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import type { PortForwardEntry, EnrichedDetectedPort } from '../../../../shared/ssh-types' -import type { WorkspacePort } from '../../../../shared/workspace-ports' -import { translate } from '@/i18n/i18n' -import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' +import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel' +import { SshPortsPanel } from './ssh-ports-panel' +export { getLocalWorkspacePortSections } from './local-workspace-port-sections' export { killWorkspacePortForTarget, openWorkspacePortInBrowser, scanWorkspacePortsForTarget } from '@/lib/workspace-port-actions' -export function getLocalWorkspacePortSections( - scan: { ports: WorkspacePort[] } | null | undefined, - activeRepoId: string | null | undefined, - activeWorktreeId: string | null | undefined -): { - activePorts: WorkspacePort[] - otherWorkspacePorts: WorkspacePort[] - externalPorts: WorkspacePort[] -} { - const ports = scan?.ports ?? [] - return { - activePorts: ports.filter( - (port) => - port.kind === 'workspace' && - port.owner.repoId === activeRepoId && - port.owner.worktreeId === activeWorktreeId - ), - otherWorkspacePorts: ports.filter( - (port) => - port.kind === 'workspace' && - port.owner.repoId === activeRepoId && - port.owner.worktreeId !== activeWorktreeId - ), - // Why: the old repo-scoped scan showed listeners from other repos as - // External, without workspace-only actions or cross-worktree activation. - // Keep that behavior now that the shared scan can attribute them globally. - externalPorts: ports.flatMap((port) => { - if (port.kind !== 'workspace') { - return [port] - } - return port.owner.repoId === activeRepoId ? [] : [workspacePortAsExternal(port)] - }) - } -} - -function workspacePortAsExternal(port: WorkspacePort & { kind: 'workspace' }): WorkspacePort { - return { - id: port.id, - bindHost: port.bindHost, - connectHost: port.connectHost, - port: port.port, - pid: port.pid, - processName: port.processName, - protocol: port.protocol, - kind: 'external' - } -} - -const LOCAL_PORT_MENU_CONTENT_CLASS = - '!rounded-md !border-border/60 !bg-popover !text-popover-foreground !shadow-[0_10px_24px_rgba(0,0,0,0.18)] !backdrop-blur-none' -const LOCAL_PORT_MENU_ITEM_CLASS = - 'rounded-md focus:bg-accent focus:text-accent-foreground dark:focus:bg-accent' -const LOCAL_PORT_MENU_LABEL_CLASS = 'px-2 py-1 text-[11px] font-semibold text-muted-foreground' - -// Why: ports < 1024 require root to bind on the local machine. Remap them -// to a high port so the default "Forward" action doesn't fail with EACCES. -function safeLocalPort(remotePort: number): number { - if (remotePort < 1024) { - return remotePort + 10000 - } - return remotePort -} - -// Why: forwarded SSH ports and detected remote ports may report the same loopback -// endpoint using different textual hosts. Normalize for deduping only. -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']) -function normalizeHost(host: string | undefined): string { - if (!host || LOOPBACK_HOSTS.has(host)) { - return 'localhost' - } - return host -} - -type PortForwardDialogState = - | { mode: 'closed' } - | { - mode: 'add' - defaults: { remotePort?: number; remoteHost?: string; label?: string; targetId?: string } - } - | { mode: 'edit'; entry: PortForwardEntry } - export default function PortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element { const activeWorktree = useActiveWorktree() const activeRepo = useRepoById(activeWorktree?.repoId ?? null) @@ -157,1364 +20,3 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React return } - -function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element { - const activeWorktree = useActiveWorktree() - const activeRepo = useRepoById(activeWorktree?.repoId ?? null) - const settings = useAppStore((s) => s.settings) - const createBrowserTab = useAppStore((s) => s.createBrowserTab) - const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) - const scansByKey = useAppStore((s) => s.workspacePortScansByKey) - const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) - const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) - const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) - const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) - const [detailsPort, setDetailsPort] = useState(null) - const [collapsedSections, setCollapsedSections] = useState>({ - other: true, - external: true - }) - - const runtimeTarget = useMemo(() => { - const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( - useAppStore.getState(), - activeWorktree?.id - ) - // Why: the Ports panel acts on the active workspace; use that workspace's - // host owner even if the sidebar is focused elsewhere. - return getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId }) - }, [activeWorktree?.id, settings]) - const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` - - const refresh = useCallback(() => { - if (!activeRepo) { - return Promise.resolve() - } - setWorkspacePortScanRefreshing(true) - const promise = scanWorkspacePortsForTarget(runtimeTarget) - .then((nextScan) => { - setWorkspacePortScanForKey(scanKey, nextScan) - setWorkspacePortScan({ key: scanKey, result: nextScan }) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - toast.error( - translate( - 'auto.components.right.sidebar.PortsPanel.a00f3a2840', - 'Failed to refresh ports' - ), - { - description: - message || - translate( - 'auto.components.right.sidebar.PortsPanel.740aca88ab', - 'Workspace port scan failed.' - ) - } - ) - }) - .finally(() => { - setWorkspacePortScanRefreshing(false) - }) - return promise - }, [ - activeRepo, - runtimeTarget, - scanKey, - setWorkspacePortScan, - setWorkspacePortScanForKey, - setWorkspacePortScanRefreshing - ]) - - // Why: WorkspacePortScanner already owns the 30s all-worktree poll. The - // panel scopes that shared result instead of starting a second scan loop. - const displayScan = isVisible ? (scansByKey[scanKey] ?? null) : null - - const toggleSection = useCallback((sectionId: string) => { - setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] })) - }, []) - - const handleStopPort = useCallback( - async (port: WorkspacePort) => { - if (!activeRepo || !port.pid) { - return - } - const result = await killWorkspacePortForTarget(runtimeTarget, { - repoId: activeRepo.id, - pid: port.pid, - port: port.port - }) - if (!result.ok) { - toast.error(result.reason) - return - } - toast.success( - translate( - 'auto.components.right.sidebar.PortsPanel.97b562d21d', - 'Stopped process on :{{value0}}', - { value0: port.port } - ) - ) - const refreshResult = await refreshWorkspacePortScanAfterStop({ - runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, - getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, - setWorkspacePortScanRefreshing - }) - if (!refreshResult.ok) { - toast.error( - translate( - 'auto.components.right.sidebar.PortsPanel.a00f3a2840', - 'Failed to refresh ports' - ), - { - description: refreshResult.reason - } - ) - } - }, - [ - activeRepo, - runtimeTarget, - setWorkspacePortScan, - setWorkspacePortScanForKey, - setWorkspacePortScanRefreshing - ] - ) - - const handleOpenPortInBrowser = useCallback( - async (port: WorkspacePort, event?: React.MouseEvent) => { - const result = await openWorkspacePortInBrowser({ - port, - activeWorktreeId: activeWorktree?.id, - runtimeTarget, - createBrowserTab, - setRemoteBrowserPageHandle, - openInOrcaBrowser: resolvePortOpenInOrcaBrowser({ - settings, - event, - isMac: navigator.userAgent.includes('Mac') - }), - localhostLabelRoute: resolveLocalhostLabelRouteForPort(useAppStore.getState(), port) - }) - if (!result.ok) { - toast.error( - translate( - 'auto.components.right.sidebar.PortsPanel.98e9a414f8', - 'Failed to open browser' - ), - { description: result.reason } - ) - } - }, - [activeWorktree?.id, createBrowserTab, runtimeTarget, setRemoteBrowserPageHandle, settings] - ) - - const { activePorts, otherWorkspacePorts, externalPorts } = useMemo( - () => getLocalWorkspacePortSections(displayScan, activeRepo?.id, activeWorktree?.id), - [activeRepo?.id, activeWorktree?.id, displayScan] - ) - - if (!activeRepo) { - return ( -
- -

- {translate( - 'auto.components.right.sidebar.PortsPanel.c1b115c375', - 'No workspace selected' - )} -

-
- ) - } - - return ( -
-
- - {translate('auto.components.right.sidebar.PortsPanel.6bc058dbe1', 'Ports')} - - - - - - - {translate('auto.components.right.sidebar.PortsPanel.7822e3edc6', 'Refresh Ports')} - - -
- - {displayScan?.unavailableReason && ( -
- {translate( - 'auto.components.right.sidebar.PortsPanel.f59c783b7a', - 'Port scan unavailable on {{value0}}: {{value1}}', - { - value0: displayScan.platform, - value1: displayScan.unavailableReason - } - )} -
- )} - - {!displayScan?.unavailableReason && ( - <> - toggleSection('active')} - onStopPort={(port) => void handleStopPort(port)} - onShowDetails={setDetailsPort} - onOpenInBrowser={handleOpenPortInBrowser} - /> - toggleSection('other')} - onStopPort={(port) => void handleStopPort(port)} - onShowDetails={setDetailsPort} - onOpenInBrowser={handleOpenPortInBrowser} - /> - toggleSection('external')} - onStopPort={(port) => void handleStopPort(port)} - onShowDetails={setDetailsPort} - onOpenInBrowser={handleOpenPortInBrowser} - /> - - )} - - {!displayScan?.unavailableReason && - displayScan && - activePorts.length === 0 && - otherWorkspacePorts.length === 0 && - externalPorts.length === 0 && ( -
- -

- {translate( - 'auto.components.right.sidebar.PortsPanel.a2a9fc6899', - 'No local ports detected' - )} -

-
- )} - - setDetailsPort(null)} /> -
- ) -} - -function LocalPortSection({ - id, - title, - ports, - emptyText, - collapsed, - onToggle, - onStopPort, - onShowDetails, - onOpenInBrowser -}: { - id: string - title: string - ports: WorkspacePort[] - emptyText?: string - collapsed: boolean - onToggle: () => void - onStopPort: (port: WorkspacePort) => void - onShowDetails: (port: WorkspacePort) => void - onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void -}): React.JSX.Element | null { - if (ports.length === 0 && !emptyText) { - return null - } - - return ( -
- - {!collapsed && ( -
- {ports.length > 0 - ? ports.map((port) => ( - - )) - : emptyText &&
{emptyText}
} -
- )} -
- ) -} - -function LocalPortRow({ - port, - onStop, - onShowDetails, - onOpenInBrowser -}: { - port: WorkspacePort - onStop: (port: WorkspacePort) => void - onShowDetails: (port: WorkspacePort) => void - onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void -}): React.JSX.Element { - const handleCopy = useCallback(() => { - void window.api.ui.writeClipboardText(addressForPort(port)) - }, [port]) - - const handleOpenBrowser = useCallback( - (event?: React.MouseEvent) => { - void onOpenInBrowser(port, event) - }, - [onOpenInBrowser, port] - ) - - const handleCopyButtonClick = useCallback( - (event: React.MouseEvent) => { - handleCopy() - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [handleCopy] - ) - - const handleOpenBrowserButtonClick = useCallback( - (event: React.MouseEvent) => { - // Why: keyboard activations have detail=0; only pointer clicks carry - // the modifier intent for the system-browser escape hatch. - handleOpenBrowser(event.detail > 0 ? event : undefined) - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [handleOpenBrowser] - ) - - const handleStopButtonClick = useCallback( - (event: React.MouseEvent) => { - onStop(port) - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [onStop, port] - ) - - const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') - const address = addressForPort(port) - const ownerLabel = - port.kind === 'workspace' - ? port.owner.displayName - : port.kind === 'container' - ? 'Container or forwarded service' - : 'Unassigned' - const openBrowserLabel = translate( - 'auto.components.right.sidebar.PortsPanel.b22b128b2a', - 'Open in Browser' - ) - const confidenceLabel = - port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null - const canStopProcess = - port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron' - - return ( - -
- -
-
- {port.kind === 'container' ? : } -
-
-
- :{port.port} - {processLabel} -
-
- {address} -
-
- {ownerLabel} - {confidenceLabel && ( - {confidenceLabel} - )} -
-
-
-
- -
- - - - - - {getPortOpenBrowserTooltipLabel(openBrowserLabel)} - - - - - - - - {translate( - 'auto.components.right.sidebar.PortsPanel.1004af16ab', - 'Copy {{value0}}', - { value0: address } - )} - - - {canStopProcess && ( - - - - - - {translate('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} - - - )} -
-
-
- - {`:${port.port}`} - handleOpenBrowser()} - > - - {openBrowserLabel} - - - - {translate('auto.components.right.sidebar.PortsPanel.792baeb7ed', 'Copy Address')} - - { - void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2)) - }} - > - - {translate('auto.components.right.sidebar.PortsPanel.bdac206faf', 'Copy Details')} - - onShowDetails(port)} - > - - {translate('auto.components.right.sidebar.PortsPanel.a223459512', 'Show Details')} - - - onStop(port)} - > - - {translate('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} - - -
- ) -} - -function LocalPortDetailsDialog({ - port, - onClose -}: { - port: WorkspacePort | null - onClose: () => void -}): React.JSX.Element { - return ( - !open && onClose()}> - - - - {port - ? translate( - 'auto.components.right.sidebar.PortsPanel.472054d94c', - 'Port :{{value0}}', - { value0: port.port } - ) - : translate('auto.components.right.sidebar.PortsPanel.d41a8241ec', 'Port')} - - - {port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''} - - - {port && ( -
-
- {translate('auto.components.right.sidebar.PortsPanel.1c1c18cefc', 'Address')} -
-
{addressForPort(port)}
-
- {translate('auto.components.right.sidebar.PortsPanel.0f1d8cd324', 'Bind')} -
-
{`${port.bindHost}:${port.port}`}
-
- {translate('auto.components.right.sidebar.PortsPanel.729be0b4e5', 'Kind')} -
-
{port.kind}
-
- {translate('auto.components.right.sidebar.PortsPanel.b1ff94fa27', 'Protocol')} -
-
{port.protocol}
-
- {translate('auto.components.right.sidebar.PortsPanel.5dd86dcf2f', 'Process')} -
-
- {port.processName ?? - translate('auto.components.right.sidebar.PortsPanel.3e13cb63ee', 'Unknown')} -
-
- {translate('auto.components.right.sidebar.PortsPanel.57d930fa45', 'PID')} -
-
- {port.pid ?? - translate('auto.components.right.sidebar.PortsPanel.3e13cb63ee', 'Unknown')} -
- {port.kind === 'workspace' && ( - <> -
- {translate('auto.components.right.sidebar.PortsPanel.c7b4702b7b', 'Workspace')} -
-
{port.owner.displayName}
-
- {translate('auto.components.right.sidebar.PortsPanel.153145e675', 'Evidence')} -
-
{port.owner.confidence}
- - )} -
- )} -
-
- ) -} - -function SshPortsPanel(): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const portForwardsByConnection = useAppStore((s) => s.portForwardsByConnection) - const detectedPortsByConnection = useAppStore((s) => s.detectedPortsByConnection) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - // Why: scope the panel to the active worktree's SSH connection so - // actions target the correct machine and the disconnected state - // reflects the active worktree, not some other SSH session. - const activeWorktree = useActiveWorktree() - const activeRepo = useRepoById(activeWorktree?.repoId ?? null) - const activeConnectionId = activeRepo?.connectionId ?? null - - const isDisconnected = activeConnectionId - ? sshConnectionStates.get(activeConnectionId)?.status !== 'connected' - : true - - const allForwards = useMemo(() => { - if (!activeConnectionId) { - return [] - } - return portForwardsByConnection[activeConnectionId] ?? [] - }, [portForwardsByConnection, activeConnectionId]) - - const forwardedKeys = useMemo(() => { - const set = new Set() - for (const f of allForwards) { - set.add(`${normalizeHost(f.remoteHost)}:${f.remotePort}`) - } - return set - }, [allForwards]) - - const allDetected = useMemo(() => { - if (!activeConnectionId) { - return [] - } - const ports = detectedPortsByConnection[activeConnectionId] ?? [] - return ports - .filter((p) => !forwardedKeys.has(`${normalizeHost(p.host)}:${p.port}`)) - .map((p) => ({ ...p, targetId: activeConnectionId })) - .sort((a, b) => a.port - b.port) - }, [detectedPortsByConnection, activeConnectionId, forwardedKeys]) - - const [forwardedCollapsed, setForwardedCollapsed] = useState(false) - const [detectedCollapsed, setDetectedCollapsed] = useState(false) - const [dialogState, setDialogState] = useState({ mode: 'closed' }) - - const handleForwardDetected = useCallback((port: EnrichedDetectedPort & { targetId: string }) => { - setDialogState({ - mode: 'add', - defaults: { - remotePort: port.port, - remoteHost: normalizeHost(port.host), - label: port.processName, - targetId: port.targetId - } - }) - }, []) - - const handleEdit = useCallback((entry: PortForwardEntry) => { - setDialogState({ mode: 'edit', entry }) - }, []) - - const handleOpenForwardInBrowser = useCallback( - (entry: PortForwardEntry, event?: React.MouseEvent) => { - const url = browserUrlForPortForwardEntry(entry) - if ( - !resolvePortOpenInOrcaBrowser({ - settings, - event, - isMac: navigator.userAgent.includes('Mac') - }) - ) { - void window.api.shell.openUrl(url) - return - } - if (!activeWorktree?.id) { - toast.error( - translate( - 'auto.components.right.sidebar.PortsPanel.409afcc145', - 'No workspace selected for the browser.' - ) - ) - return - } - void openWorkspaceBrowserTab({ - workspaceId: activeWorktree.id, - url, - intent: { kind: 'url' } - }).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }, - [activeWorktree?.id, settings] - ) - - const handleDialogClose = useCallback(() => { - setDialogState({ mode: 'closed' }) - }, []) - - if (isDisconnected) { - return ( -
- -

- {translate('auto.components.right.sidebar.PortsPanel.a2f1a47f42', 'SSH connection lost')} -

-

- {translate('auto.components.right.sidebar.PortsPanel.d4c3cd679c', 'Reconnecting...')} -

-
- ) - } - - return ( -
- {/* Header */} -
- - {translate('auto.components.right.sidebar.PortsPanel.6bc058dbe1', 'Ports')} - - -
- - {/* Forwarded ports */} - {allForwards.length > 0 && ( -
- - {!forwardedCollapsed && - allForwards.map((entry) => ( - handleEdit(entry)} - onOpenInBrowser={(event) => handleOpenForwardInBrowser(entry, event)} - /> - ))} -
- )} - - {/* Detected ports */} - {allDetected.length > 0 && ( -
- - {!detectedCollapsed && - allDetected.map((port) => ( - handleForwardDetected(port)} - /> - ))} -
- )} - - {/* Empty state */} - {allForwards.length === 0 && allDetected.length === 0 && ( -
-

- {translate('auto.components.right.sidebar.PortsPanel.1f0d2a24f9', 'No forwarded ports')} -

-

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

- -
- )} - - -
- ) -} - -function ForwardedPortRow({ - entry, - onEdit, - onOpenInBrowser -}: { - entry: PortForwardEntry - onEdit: () => void - onOpenInBrowser: (event?: React.MouseEvent) => void -}): React.JSX.Element { - const [removing, setRemoving] = useState(false) - const mountedRef = useMountedRef() - const forwardedAddress = addressForPortForwardEntry(entry) - - const handleRemove = useCallback(async () => { - setRemoving(true) - try { - await window.api.ssh.removePortForward({ id: entry.id }) - } catch { - // broadcast will update state - } - if (mountedRef.current) { - setRemoving(false) - } - }, [entry.id, mountedRef]) - - const handleCopy = useCallback(() => { - void window.api.ui.writeClipboardText(forwardedAddress) - }, [forwardedAddress]) - - const handleOpenBrowser = useCallback( - (event?: React.MouseEvent) => { - onOpenInBrowser(event) - }, - [onOpenInBrowser] - ) - - const handleCopyButtonClick = useCallback( - (event: React.MouseEvent) => { - handleCopy() - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [handleCopy] - ) - - const handleOpenBrowserButtonClick = useCallback( - (event: React.MouseEvent) => { - // Why: keyboard activations have detail=0; only pointer clicks carry - // the modifier intent for the system-browser escape hatch. - handleOpenBrowser(event.detail > 0 ? event : undefined) - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [handleOpenBrowser] - ) - - const handleEditButtonClick = useCallback( - (event: React.MouseEvent) => { - onEdit() - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [onEdit] - ) - - const handleRemoveButtonClick = useCallback( - (event: React.MouseEvent) => { - void handleRemove() - if (event.detail > 0) { - event.currentTarget.blur() - } - }, - [handleRemove] - ) - - const advertisedBrowserUrl = advertisedBrowserUrlForForwardedRow(entry) - const openBrowserLabel = translate( - 'auto.components.right.sidebar.PortsPanel.b22b128b2a', - 'Open in Browser' - ) - const openBrowserTitle = getPortOpenBrowserTooltipLabel( - advertisedBrowserUrl - ? translate( - 'auto.components.right.sidebar.PortsPanel.75aeea592f', - 'Open {{value0}} in Browser', - { - value0: advertisedBrowserUrl - } - ) - : openBrowserLabel - ) - - return ( -
-
-
- {entry.label && ( - {entry.label} - )} - - :{entry.localPort} → :{entry.remotePort} - -
- {advertisedBrowserUrl && ( -
- {translate('auto.components.right.sidebar.PortsPanel.de349d4560', 'opens {{value0}}', { - value0: advertisedBrowserUrl - })} -
- )} -
-
- - - - -
-
- ) -} - -function DetectedPortRow({ - port, - onForward -}: { - port: EnrichedDetectedPort & { targetId: string } - onForward: () => void -}): React.JSX.Element { - const advertisedBrowserUrl = advertisedBrowserUrlForDetectedPort(port) - return ( -
-
-
- :{port.port} - {port.processName && ( - {port.processName} - )} -
- {advertisedBrowserUrl && ( -
- {translate( - 'auto.components.right.sidebar.PortsPanel.c7e920aa7c', - 'advertised as {{value0}}', - { value0: advertisedBrowserUrl } - )} -
- )} -
- -
- ) -} - -function digitsOnly(value: string): string { - return value.replace(/\D/g, '') -} - -const INPUT_CLASS = - 'block w-full mt-0.5 px-2 py-1.5 text-xs rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-ring' - -function PortForwardDialog({ - state, - activeConnectionId, - onClose -}: { - state: PortForwardDialogState - activeConnectionId: string | null - onClose: () => void -}): React.JSX.Element { - const isOpen = state.mode !== 'closed' - const isEdit = state.mode === 'edit' - - const initialRemotePort = - state.mode === 'edit' - ? state.entry.remotePort.toString() - : state.mode === 'add' - ? (state.defaults.remotePort?.toString() ?? '') - : '' - - const initialLocalPort = - state.mode === 'edit' - ? state.entry.localPort.toString() - : state.mode === 'add' && state.defaults.remotePort != null - ? safeLocalPort(state.defaults.remotePort).toString() - : '' - - const initialRemoteHost = - state.mode === 'edit' - ? state.entry.remoteHost - : state.mode === 'add' - ? (state.defaults.remoteHost ?? 'localhost') - : 'localhost' - - const initialLabel = - state.mode === 'edit' - ? (state.entry.label ?? '') - : state.mode === 'add' - ? (state.defaults.label ?? '') - : '' - - // Why: capture the target at dialog-open time via defaults.targetId so - // switching worktrees while the dialog is open doesn't redirect the - // forward to the wrong SSH connection. - const targetId = - state.mode === 'edit' - ? state.entry.connectionId - : state.mode === 'add' - ? (state.defaults.targetId ?? activeConnectionId ?? '') - : (activeConnectionId ?? '') - - return ( - { - if (!open) { - onClose() - } - }} - > - - - - {isEdit - ? translate( - 'auto.components.right.sidebar.PortsPanel.80206251c8', - 'Edit Port Forward' - ) - : translate('auto.components.right.sidebar.PortsPanel.907eb53ed2', 'Forward a Port')} - - - {isEdit - ? translate( - 'auto.components.right.sidebar.PortsPanel.10360598a4', - 'Update the port forwarding configuration.' - ) - : translate( - 'auto.components.right.sidebar.PortsPanel.31e80cff2d', - 'Forward a remote port to your local machine.' - )} - - - {isOpen && ( - - )} - - - ) -} - -function PortForwardForm({ - mode, - editId, - initialRemotePort, - initialLocalPort, - initialRemoteHost, - initialLabel, - targetId, - onClose -}: { - mode: 'add' | 'edit' - editId?: string - initialRemotePort: string - initialLocalPort: string - initialRemoteHost: string - initialLabel: string - targetId: string - onClose: () => void -}): React.JSX.Element { - const [remotePort, setRemotePort] = useState(initialRemotePort) - const [localPort, setLocalPort] = useState(initialLocalPort) - const [remoteHost, setRemoteHost] = useState(initialRemoteHost) - const [label, setLabel] = useState(initialLabel) - const [error, setError] = useState(null) - const [submitting, setSubmitting] = useState(false) - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - setError(null) - - const rPort = Number.parseInt(remotePort, 10) - const lPort = Number.parseInt(localPort || remotePort, 10) - - if (Number.isNaN(rPort) || rPort < 1 || rPort > 65535) { - setError('Remote port must be 1\u201365535') - return - } - if (Number.isNaN(lPort) || lPort < 1 || lPort > 65535) { - setError('Local port must be 1\u201365535') - return - } - - setSubmitting(true) - try { - await (mode === 'edit' && editId - ? window.api.ssh.updatePortForward({ - id: editId, - targetId, - localPort: lPort, - remoteHost: remoteHost || 'localhost', - remotePort: rPort, - label: label || undefined - }) - : window.api.ssh.addPortForward({ - targetId, - localPort: lPort, - remoteHost: remoteHost || 'localhost', - remotePort: rPort, - label: label || undefined - })) - onClose() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (msg.includes('EADDRINUSE') || msg.includes('already in use')) { - setError(`Port ${lPort} is already in use. Choose a different local port.`) - } else if (msg.includes('EACCES') || msg.includes('permission denied')) { - setError(`Port ${lPort} requires elevated privileges. Use a local port \u2265 1024.`) - } else { - setError(msg) - } - } - setSubmitting(false) - }, - [mode, editId, remotePort, localPort, remoteHost, label, targetId, onClose] - ) - - return ( -
-
- - - - - - - -
- - {error &&
{error}
} - -
- - -
-
- ) -} diff --git a/src/renderer/src/components/right-sidebar/local-port-details-dialog.tsx b/src/renderer/src/components/right-sidebar/local-port-details-dialog.tsx new file mode 100644 index 00000000000..141f15d4d96 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-port-details-dialog.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { addressForPort } from '@/lib/workspace-port-urls' +import type { WorkspacePort } from '../../../../shared/workspace-ports' +import { translate } from '@/i18n/i18n' + +export function LocalPortDetailsDialog({ + port, + onClose +}: { + port: WorkspacePort | null + onClose: () => void +}): React.JSX.Element { + return ( + !open && onClose()}> + + + + {port + ? translate( + 'auto.components.right.sidebar.PortsPanel.472054d94c', + 'Port :{{value0}}', + { value0: port.port } + ) + : translate('auto.components.right.sidebar.PortsPanel.d41a8241ec', 'Port')} + + + {port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''} + + + {port && ( +
+
+ {translate('auto.components.right.sidebar.PortsPanel.1c1c18cefc', 'Address')} +
+
{addressForPort(port)}
+
+ {translate('auto.components.right.sidebar.PortsPanel.0f1d8cd324', 'Bind')} +
+
{`${port.bindHost}:${port.port}`}
+
+ {translate('auto.components.right.sidebar.PortsPanel.729be0b4e5', 'Kind')} +
+
{port.kind}
+
+ {translate('auto.components.right.sidebar.PortsPanel.b1ff94fa27', 'Protocol')} +
+
{port.protocol}
+
+ {translate('auto.components.right.sidebar.PortsPanel.5dd86dcf2f', 'Process')} +
+
+ {port.processName ?? + translate('auto.components.right.sidebar.PortsPanel.3e13cb63ee', 'Unknown')} +
+
+ {translate('auto.components.right.sidebar.PortsPanel.57d930fa45', 'PID')} +
+
+ {port.pid ?? + translate('auto.components.right.sidebar.PortsPanel.3e13cb63ee', 'Unknown')} +
+ {port.kind === 'workspace' && ( + <> +
+ {translate('auto.components.right.sidebar.PortsPanel.c7b4702b7b', 'Workspace')} +
+
{port.owner.displayName}
+
+ {translate('auto.components.right.sidebar.PortsPanel.153145e675', 'Evidence')} +
+
{port.owner.confidence}
+ + )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/local-port-row.tsx b/src/renderer/src/components/right-sidebar/local-port-row.tsx new file mode 100644 index 00000000000..644ed000297 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-port-row.tsx @@ -0,0 +1,241 @@ +import React, { useCallback } from 'react' +import { Box, Copy, ExternalLink, Info, Server, Trash2 } from 'lucide-react' +import { getPortOpenBrowserTooltipLabel } from '@/lib/workspace-port-actions' +import { addressForPort } from '@/lib/workspace-port-urls' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import type { WorkspacePort } from '../../../../shared/workspace-ports' +import { translate } from '@/i18n/i18n' + +const LOCAL_PORT_MENU_CONTENT_CLASS = + '!rounded-md !border-border/60 !bg-popover !text-popover-foreground !shadow-[0_10px_24px_rgba(0,0,0,0.18)] !backdrop-blur-none' +const LOCAL_PORT_MENU_ITEM_CLASS = + 'rounded-md focus:bg-accent focus:text-accent-foreground dark:focus:bg-accent' +const LOCAL_PORT_MENU_LABEL_CLASS = 'px-2 py-1 text-[11px] font-semibold text-muted-foreground' + +export function LocalPortRow({ + port, + onStop, + onShowDetails, + onOpenInBrowser +}: { + port: WorkspacePort + onStop: (port: WorkspacePort) => void + onShowDetails: (port: WorkspacePort) => void + onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void +}): React.JSX.Element { + const handleCopy = useCallback(() => { + void window.api.ui.writeClipboardText(addressForPort(port)) + }, [port]) + + const handleOpenBrowser = useCallback( + (event?: React.MouseEvent) => { + void onOpenInBrowser(port, event) + }, + [onOpenInBrowser, port] + ) + + const handleCopyButtonClick = useCallback( + (event: React.MouseEvent) => { + handleCopy() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleCopy] + ) + + const handleOpenBrowserButtonClick = useCallback( + (event: React.MouseEvent) => { + // Why: keyboard activations have detail=0; only pointer clicks carry + // the modifier intent for the system-browser escape hatch. + handleOpenBrowser(event.detail > 0 ? event : undefined) + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleOpenBrowser] + ) + + const handleStopButtonClick = useCallback( + (event: React.MouseEvent) => { + onStop(port) + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [onStop, port] + ) + + const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process') + const address = addressForPort(port) + const ownerLabel = + port.kind === 'workspace' + ? port.owner.displayName + : port.kind === 'container' + ? 'Container or forwarded service' + : 'Unassigned' + const openBrowserLabel = translate( + 'auto.components.right.sidebar.PortsPanel.b22b128b2a', + 'Open in Browser' + ) + const confidenceLabel = + port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null + const canStopProcess = + port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron' + + return ( + +
+ +
+
+ {port.kind === 'container' ? : } +
+
+
+ :{port.port} + {processLabel} +
+
+ {address} +
+
+ {ownerLabel} + {confidenceLabel && ( + {confidenceLabel} + )} +
+
+
+
+ +
+ + + + + + {getPortOpenBrowserTooltipLabel(openBrowserLabel)} + + + + + + + + {translate( + 'auto.components.right.sidebar.PortsPanel.1004af16ab', + 'Copy {{value0}}', + { value0: address } + )} + + + {canStopProcess && ( + + + + + + {translate('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} + + + )} +
+
+
+ + {`:${port.port}`} + handleOpenBrowser()} + > + + {openBrowserLabel} + + + + {translate('auto.components.right.sidebar.PortsPanel.792baeb7ed', 'Copy Address')} + + { + void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2)) + }} + > + + {translate('auto.components.right.sidebar.PortsPanel.bdac206faf', 'Copy Details')} + + onShowDetails(port)} + > + + {translate('auto.components.right.sidebar.PortsPanel.a223459512', 'Show Details')} + + + onStop(port)} + > + + {translate('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} + + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/local-port-section.tsx b/src/renderer/src/components/right-sidebar/local-port-section.tsx new file mode 100644 index 00000000000..22287ae4909 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-port-section.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { WorkspacePort } from '../../../../shared/workspace-ports' +import { LocalPortRow } from './local-port-row' + +export function LocalPortSection({ + id, + title, + ports, + emptyText, + collapsed, + onToggle, + onStopPort, + onShowDetails, + onOpenInBrowser +}: { + id: string + title: string + ports: WorkspacePort[] + emptyText?: string + collapsed: boolean + onToggle: () => void + onStopPort: (port: WorkspacePort) => void + onShowDetails: (port: WorkspacePort) => void + onOpenInBrowser: (port: WorkspacePort, event?: React.MouseEvent) => void +}): React.JSX.Element | null { + if (ports.length === 0 && !emptyText) { + return null + } + + return ( +
+ + {!collapsed && ( +
+ {ports.length > 0 + ? ports.map((port) => ( + + )) + : emptyText &&
{emptyText}
} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts new file mode 100644 index 00000000000..d968bb85144 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-workspace-port-sections.ts @@ -0,0 +1,49 @@ +import type { WorkspacePort } from '../../../../shared/workspace-ports' + +export function getLocalWorkspacePortSections( + scan: { ports: WorkspacePort[] } | null | undefined, + activeRepoId: string | null | undefined, + activeWorktreeId: string | null | undefined +): { + activePorts: WorkspacePort[] + otherWorkspacePorts: WorkspacePort[] + externalPorts: WorkspacePort[] +} { + const ports = scan?.ports ?? [] + return { + activePorts: ports.filter( + (port) => + port.kind === 'workspace' && + port.owner.repoId === activeRepoId && + port.owner.worktreeId === activeWorktreeId + ), + otherWorkspacePorts: ports.filter( + (port) => + port.kind === 'workspace' && + port.owner.repoId === activeRepoId && + port.owner.worktreeId !== activeWorktreeId + ), + // Why: the old repo-scoped scan showed listeners from other repos as + // External, without workspace-only actions or cross-worktree activation. + // Keep that behavior now that the shared scan can attribute them globally. + externalPorts: ports.flatMap((port) => { + if (port.kind !== 'workspace') { + return [port] + } + return port.owner.repoId === activeRepoId ? [] : [workspacePortAsExternal(port)] + }) + } +} + +function workspacePortAsExternal(port: WorkspacePort & { kind: 'workspace' }): WorkspacePort { + return { + id: port.id, + bindHost: port.bindHost, + connectHost: port.connectHost, + port: port.port, + pid: port.pid, + processName: port.processName, + protocol: port.protocol, + kind: 'external' + } +} 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 new file mode 100644 index 00000000000..0955e89031b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx @@ -0,0 +1,308 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { RefreshCw, Server } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { cn } from '@/lib/utils' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + killWorkspacePortForTarget, + openWorkspacePortInBrowser, + refreshWorkspacePortScanAfterStop, + resolvePortOpenInOrcaBrowser, + scanWorkspacePortsForTarget, + workspacePortRuntimeTargetKey +} from '@/lib/workspace-port-actions' +import { resolveLocalhostLabelRouteForPort } from '@/lib/workspace-port-localhost-label-selector' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { WorkspacePort } from '../../../../shared/workspace-ports' +import { translate } from '@/i18n/i18n' +import { getLocalWorkspacePortSections } from './local-workspace-port-sections' +import { LocalPortSection } from './local-port-section' +import { LocalPortDetailsDialog } from './local-port-details-dialog' + +export function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element { + const activeWorktree = useActiveWorktree() + const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const settings = useAppStore((s) => s.settings) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) + const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const scansByKey = useAppStore((s) => s.workspacePortScansByKey) + const refreshing = useAppStore((s) => s.workspacePortScanRefreshing) + const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) + const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) + const [detailsPort, setDetailsPort] = useState(null) + const [collapsedSections, setCollapsedSections] = useState>({ + other: true, + external: true + }) + + const runtimeTarget = useMemo(() => { + const activeRuntimeEnvironmentId = getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + activeWorktree?.id + ) + // Why: the Ports panel acts on the active workspace; use that workspace's + // host owner even if the sidebar is focused elsewhere. + return getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId }) + }, [activeWorktree?.id, settings]) + const scanKey = `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` + + const refresh = useCallback(() => { + if (!activeRepo) { + return Promise.resolve() + } + setWorkspacePortScanRefreshing(true) + const promise = scanWorkspacePortsForTarget(runtimeTarget) + .then((nextScan) => { + setWorkspacePortScanForKey(scanKey, nextScan) + setWorkspacePortScan({ key: scanKey, result: nextScan }) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + toast.error( + translate( + 'auto.components.right.sidebar.PortsPanel.a00f3a2840', + 'Failed to refresh ports' + ), + { + description: + message || + translate( + 'auto.components.right.sidebar.PortsPanel.740aca88ab', + 'Workspace port scan failed.' + ) + } + ) + }) + .finally(() => { + setWorkspacePortScanRefreshing(false) + }) + return promise + }, [ + activeRepo, + runtimeTarget, + scanKey, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ]) + + // Why: WorkspacePortScanner already owns the 30s all-worktree poll. The + // panel scopes that shared result instead of starting a second scan loop. + const displayScan = isVisible ? (scansByKey[scanKey] ?? null) : null + + const toggleSection = useCallback((sectionId: string) => { + setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] })) + }, []) + + const handleStopPort = useCallback( + async (port: WorkspacePort) => { + if (!activeRepo || !port.pid) { + return + } + const result = await killWorkspacePortForTarget(runtimeTarget, { + repoId: activeRepo.id, + pid: port.pid, + port: port.port + }) + if (!result.ok) { + toast.error(result.reason) + return + } + toast.success( + translate( + 'auto.components.right.sidebar.PortsPanel.97b562d21d', + 'Stopped process on :{{value0}}', + { value0: port.port } + ) + ) + const refreshResult = await refreshWorkspacePortScanAfterStop({ + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanForKey, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, + setWorkspacePortScanRefreshing + }) + if (!refreshResult.ok) { + toast.error( + translate( + 'auto.components.right.sidebar.PortsPanel.a00f3a2840', + 'Failed to refresh ports' + ), + { + description: refreshResult.reason + } + ) + } + }, + [ + activeRepo, + runtimeTarget, + setWorkspacePortScan, + setWorkspacePortScanForKey, + setWorkspacePortScanRefreshing + ] + ) + + const handleOpenPortInBrowser = useCallback( + async (port: WorkspacePort, event?: React.MouseEvent) => { + const result = await openWorkspacePortInBrowser({ + port, + activeWorktreeId: activeWorktree?.id, + runtimeTarget, + createBrowserTab, + setRemoteBrowserPageHandle, + openInOrcaBrowser: resolvePortOpenInOrcaBrowser({ + settings, + event, + isMac: navigator.userAgent.includes('Mac') + }), + localhostLabelRoute: resolveLocalhostLabelRouteForPort(useAppStore.getState(), port) + }) + if (!result.ok) { + toast.error( + translate( + 'auto.components.right.sidebar.PortsPanel.98e9a414f8', + 'Failed to open browser' + ), + { description: result.reason } + ) + } + }, + [activeWorktree?.id, createBrowserTab, runtimeTarget, setRemoteBrowserPageHandle, settings] + ) + + const { activePorts, otherWorkspacePorts, externalPorts } = useMemo( + () => getLocalWorkspacePortSections(displayScan, activeRepo?.id, activeWorktree?.id), + [activeRepo?.id, activeWorktree?.id, displayScan] + ) + + if (!activeRepo) { + return ( +
+ +

+ {translate( + 'auto.components.right.sidebar.PortsPanel.c1b115c375', + 'No workspace selected' + )} +

+
+ ) + } + + return ( +
+
+ + {translate('auto.components.right.sidebar.PortsPanel.6bc058dbe1', 'Ports')} + + + + + + + {translate('auto.components.right.sidebar.PortsPanel.7822e3edc6', 'Refresh Ports')} + + +
+ + {displayScan?.unavailableReason && ( +
+ {translate( + 'auto.components.right.sidebar.PortsPanel.f59c783b7a', + 'Port scan unavailable on {{value0}}: {{value1}}', + { + value0: displayScan.platform, + value1: displayScan.unavailableReason + } + )} +
+ )} + + {!displayScan?.unavailableReason && ( + <> + toggleSection('active')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + toggleSection('other')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + toggleSection('external')} + onStopPort={(port) => void handleStopPort(port)} + onShowDetails={setDetailsPort} + onOpenInBrowser={handleOpenPortInBrowser} + /> + + )} + + {!displayScan?.unavailableReason && + displayScan && + activePorts.length === 0 && + otherWorkspacePorts.length === 0 && + externalPorts.length === 0 && ( +
+ +

+ {translate( + 'auto.components.right.sidebar.PortsPanel.a2a9fc6899', + 'No local ports detected' + )} +

+
+ )} + + setDetailsPort(null)} /> +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ssh-detected-port-row.tsx b/src/renderer/src/components/right-sidebar/ssh-detected-port-row.tsx new file mode 100644 index 00000000000..3991ce9c4dc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-detected-port-row.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { advertisedBrowserUrlForDetectedPort } from '@/lib/workspace-port-urls' +import type { EnrichedDetectedPort } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' + +export function SshDetectedPortRow({ + port, + onForward +}: { + port: EnrichedDetectedPort & { targetId: string } + onForward: () => void +}): React.JSX.Element { + const advertisedBrowserUrl = advertisedBrowserUrlForDetectedPort(port) + return ( +
+
+
+ :{port.port} + {port.processName && ( + {port.processName} + )} +
+ {advertisedBrowserUrl && ( +
+ {translate( + 'auto.components.right.sidebar.PortsPanel.c7e920aa7c', + 'advertised as {{value0}}', + { value0: advertisedBrowserUrl } + )} +
+ )} +
+ +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx new file mode 100644 index 00000000000..7513672bd58 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useState } from 'react' +import { Copy, ExternalLink, Pencil, Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useMountedRef } from '@/hooks/useMountedRef' +import { getPortOpenBrowserTooltipLabel } from '@/lib/workspace-port-actions' +import { + addressForPortForwardEntry, + advertisedBrowserUrlForForwardedRow +} from '@/lib/workspace-port-urls' +import type { PortForwardEntry } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' + +export function SshForwardedPortRow({ + entry, + onEdit, + onOpenInBrowser +}: { + entry: PortForwardEntry + onEdit: () => void + onOpenInBrowser: (event?: React.MouseEvent) => void +}): React.JSX.Element { + const [removing, setRemoving] = useState(false) + const mountedRef = useMountedRef() + const forwardedAddress = addressForPortForwardEntry(entry) + + const handleRemove = useCallback(async () => { + setRemoving(true) + try { + await window.api.ssh.removePortForward({ id: entry.id }) + } catch { + // broadcast will update state + } + if (mountedRef.current) { + setRemoving(false) + } + }, [entry.id, mountedRef]) + + const handleCopy = useCallback(() => { + void window.api.ui.writeClipboardText(forwardedAddress) + }, [forwardedAddress]) + + const handleOpenBrowser = useCallback( + (event?: React.MouseEvent) => { + onOpenInBrowser(event) + }, + [onOpenInBrowser] + ) + + const handleCopyButtonClick = useCallback( + (event: React.MouseEvent) => { + handleCopy() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleCopy] + ) + + const handleOpenBrowserButtonClick = useCallback( + (event: React.MouseEvent) => { + // Why: keyboard activations have detail=0; only pointer clicks carry + // the modifier intent for the system-browser escape hatch. + handleOpenBrowser(event.detail > 0 ? event : undefined) + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleOpenBrowser] + ) + + const handleEditButtonClick = useCallback( + (event: React.MouseEvent) => { + onEdit() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [onEdit] + ) + + const handleRemoveButtonClick = useCallback( + (event: React.MouseEvent) => { + void handleRemove() + if (event.detail > 0) { + event.currentTarget.blur() + } + }, + [handleRemove] + ) + + const advertisedBrowserUrl = advertisedBrowserUrlForForwardedRow(entry) + const openBrowserLabel = translate( + 'auto.components.right.sidebar.PortsPanel.b22b128b2a', + 'Open in Browser' + ) + const openBrowserTitle = getPortOpenBrowserTooltipLabel( + advertisedBrowserUrl + ? translate( + 'auto.components.right.sidebar.PortsPanel.75aeea592f', + 'Open {{value0}} in Browser', + { + value0: advertisedBrowserUrl + } + ) + : openBrowserLabel + ) + + return ( +
+
+
+ {entry.label && ( + {entry.label} + )} + + :{entry.localPort} → :{entry.remotePort} + +
+ {advertisedBrowserUrl && ( +
+ {translate('auto.components.right.sidebar.PortsPanel.de349d4560', 'opens {{value0}}', { + value0: advertisedBrowserUrl + })} +
+ )} +
+
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ssh-port-forward-dialog.tsx b/src/renderer/src/components/right-sidebar/ssh-port-forward-dialog.tsx new file mode 100644 index 00000000000..e1cf0188e62 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-port-forward-dialog.tsx @@ -0,0 +1,313 @@ +import React, { useCallback, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import type { PortForwardEntry } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' + +// Why: ports < 1024 require root to bind on the local machine. Remap them +// to a high port so the default "Forward" action doesn't fail with EACCES. +function safeLocalPort(remotePort: number): number { + if (remotePort < 1024) { + return remotePort + 10000 + } + return remotePort +} + +export type PortForwardDialogState = + | { mode: 'closed' } + | { + mode: 'add' + defaults: { remotePort?: number; remoteHost?: string; label?: string; targetId?: string } + } + | { mode: 'edit'; entry: PortForwardEntry } + +function digitsOnly(value: string): string { + return value.replace(/\D/g, '') +} + +const INPUT_CLASS = + 'block w-full mt-0.5 px-2 py-1.5 text-xs rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-ring' + +export function SshPortForwardDialog({ + state, + activeConnectionId, + onClose +}: { + state: PortForwardDialogState + activeConnectionId: string | null + onClose: () => void +}): React.JSX.Element { + const isOpen = state.mode !== 'closed' + const isEdit = state.mode === 'edit' + + const initialRemotePort = + state.mode === 'edit' + ? state.entry.remotePort.toString() + : state.mode === 'add' + ? (state.defaults.remotePort?.toString() ?? '') + : '' + + const initialLocalPort = + state.mode === 'edit' + ? state.entry.localPort.toString() + : state.mode === 'add' && state.defaults.remotePort != null + ? safeLocalPort(state.defaults.remotePort).toString() + : '' + + const initialRemoteHost = + state.mode === 'edit' + ? state.entry.remoteHost + : state.mode === 'add' + ? (state.defaults.remoteHost ?? 'localhost') + : 'localhost' + + const initialLabel = + state.mode === 'edit' + ? (state.entry.label ?? '') + : state.mode === 'add' + ? (state.defaults.label ?? '') + : '' + + // Why: capture the target at dialog-open time via defaults.targetId so + // switching worktrees while the dialog is open doesn't redirect the + // forward to the wrong SSH connection. + const targetId = + state.mode === 'edit' + ? state.entry.connectionId + : state.mode === 'add' + ? (state.defaults.targetId ?? activeConnectionId ?? '') + : (activeConnectionId ?? '') + + return ( + { + if (!open) { + onClose() + } + }} + > + + + + {isEdit + ? translate( + 'auto.components.right.sidebar.PortsPanel.80206251c8', + 'Edit Port Forward' + ) + : translate('auto.components.right.sidebar.PortsPanel.907eb53ed2', 'Forward a Port')} + + + {isEdit + ? translate( + 'auto.components.right.sidebar.PortsPanel.10360598a4', + 'Update the port forwarding configuration.' + ) + : translate( + 'auto.components.right.sidebar.PortsPanel.31e80cff2d', + 'Forward a remote port to your local machine.' + )} + + + {isOpen && ( + + )} + + + ) +} + +function PortForwardForm({ + mode, + editId, + initialRemotePort, + initialLocalPort, + initialRemoteHost, + initialLabel, + targetId, + onClose +}: { + mode: 'add' | 'edit' + editId?: string + initialRemotePort: string + initialLocalPort: string + initialRemoteHost: string + initialLabel: string + targetId: string + onClose: () => void +}): React.JSX.Element { + const [remotePort, setRemotePort] = useState(initialRemotePort) + const [localPort, setLocalPort] = useState(initialLocalPort) + const [remoteHost, setRemoteHost] = useState(initialRemoteHost) + const [label, setLabel] = useState(initialLabel) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const rPort = Number.parseInt(remotePort, 10) + const lPort = Number.parseInt(localPort || remotePort, 10) + + if (Number.isNaN(rPort) || rPort < 1 || rPort > 65535) { + setError('Remote port must be 1\u201365535') + return + } + if (Number.isNaN(lPort) || lPort < 1 || lPort > 65535) { + setError('Local port must be 1\u201365535') + return + } + + setSubmitting(true) + try { + await (mode === 'edit' && editId + ? window.api.ssh.updatePortForward({ + id: editId, + targetId, + localPort: lPort, + remoteHost: remoteHost || 'localhost', + remotePort: rPort, + label: label || undefined + }) + : window.api.ssh.addPortForward({ + targetId, + localPort: lPort, + remoteHost: remoteHost || 'localhost', + remotePort: rPort, + label: label || undefined + })) + onClose() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('EADDRINUSE') || msg.includes('already in use')) { + setError(`Port ${lPort} is already in use. Choose a different local port.`) + } else if (msg.includes('EACCES') || msg.includes('permission denied')) { + setError(`Port ${lPort} requires elevated privileges. Use a local port \u2265 1024.`) + } else { + setError(msg) + } + } + setSubmitting(false) + }, + [mode, editId, remotePort, localPort, remoteHost, label, targetId, onClose] + ) + + return ( +
+
+ + + + + + + +
+ + {error &&
{error}
} + +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ssh-ports-panel.tsx b/src/renderer/src/components/right-sidebar/ssh-ports-panel.tsx new file mode 100644 index 00000000000..48dfac0f8ae --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ssh-ports-panel.tsx @@ -0,0 +1,255 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { ChevronRight, Plus, Unplug } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { cn } from '@/lib/utils' +import { resolvePortOpenInOrcaBrowser } from '@/lib/workspace-port-actions' +import { browserUrlForPortForwardEntry } from '@/lib/workspace-port-urls' +import type { EnrichedDetectedPort, PortForwardEntry } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' +import { SshForwardedPortRow } from './ssh-forwarded-port-row' +import { SshDetectedPortRow } from './ssh-detected-port-row' +import { SshPortForwardDialog, type PortForwardDialogState } from './ssh-port-forward-dialog' + +// Why: forwarded SSH ports and detected remote ports may report the same loopback +// endpoint using different textual hosts. Normalize for deduping only. +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']) +function normalizeHost(host: string | undefined): string { + if (!host || LOOPBACK_HOSTS.has(host)) { + return 'localhost' + } + return host +} + +export function SshPortsPanel(): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const portForwardsByConnection = useAppStore((s) => s.portForwardsByConnection) + const detectedPortsByConnection = useAppStore((s) => s.detectedPortsByConnection) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + // Why: scope the panel to the active worktree's SSH connection so + // actions target the correct machine and the disconnected state + // reflects the active worktree, not some other SSH session. + const activeWorktree = useActiveWorktree() + const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const activeConnectionId = activeRepo?.connectionId ?? null + + const isDisconnected = activeConnectionId + ? sshConnectionStates.get(activeConnectionId)?.status !== 'connected' + : true + + const allForwards = useMemo(() => { + if (!activeConnectionId) { + return [] + } + return portForwardsByConnection[activeConnectionId] ?? [] + }, [portForwardsByConnection, activeConnectionId]) + + const forwardedKeys = useMemo(() => { + const set = new Set() + for (const f of allForwards) { + set.add(`${normalizeHost(f.remoteHost)}:${f.remotePort}`) + } + return set + }, [allForwards]) + + const allDetected = useMemo(() => { + if (!activeConnectionId) { + return [] + } + const ports = detectedPortsByConnection[activeConnectionId] ?? [] + return ports + .filter((p) => !forwardedKeys.has(`${normalizeHost(p.host)}:${p.port}`)) + .map((p) => ({ ...p, targetId: activeConnectionId })) + .sort((a, b) => a.port - b.port) + }, [detectedPortsByConnection, activeConnectionId, forwardedKeys]) + + const [forwardedCollapsed, setForwardedCollapsed] = useState(false) + const [detectedCollapsed, setDetectedCollapsed] = useState(false) + const [dialogState, setDialogState] = useState({ mode: 'closed' }) + + const handleForwardDetected = useCallback((port: EnrichedDetectedPort & { targetId: string }) => { + setDialogState({ + mode: 'add', + defaults: { + remotePort: port.port, + remoteHost: normalizeHost(port.host), + label: port.processName, + targetId: port.targetId + } + }) + }, []) + + const handleEdit = useCallback((entry: PortForwardEntry) => { + setDialogState({ mode: 'edit', entry }) + }, []) + + const handleOpenForwardInBrowser = useCallback( + (entry: PortForwardEntry, event?: React.MouseEvent) => { + const url = browserUrlForPortForwardEntry(entry) + if ( + !resolvePortOpenInOrcaBrowser({ + settings, + event, + isMac: navigator.userAgent.includes('Mac') + }) + ) { + void window.api.shell.openUrl(url) + return + } + if (!activeWorktree?.id) { + toast.error( + translate( + 'auto.components.right.sidebar.PortsPanel.409afcc145', + 'No workspace selected for the browser.' + ) + ) + return + } + void openWorkspaceBrowserTab({ + workspaceId: activeWorktree.id, + url, + intent: { kind: 'url' } + }).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }, + [activeWorktree?.id, settings] + ) + + const handleDialogClose = useCallback(() => { + setDialogState({ mode: 'closed' }) + }, []) + + if (isDisconnected) { + return ( +
+ +

+ {translate('auto.components.right.sidebar.PortsPanel.a2f1a47f42', 'SSH connection lost')} +

+

+ {translate('auto.components.right.sidebar.PortsPanel.d4c3cd679c', 'Reconnecting...')} +

+
+ ) + } + + return ( +
+ {/* Header */} +
+ + {translate('auto.components.right.sidebar.PortsPanel.6bc058dbe1', 'Ports')} + + +
+ + {/* Forwarded ports */} + {allForwards.length > 0 && ( +
+ + {!forwardedCollapsed && + allForwards.map((entry) => ( + handleEdit(entry)} + onOpenInBrowser={(event) => handleOpenForwardInBrowser(entry, event)} + /> + ))} +
+ )} + + {/* Detected ports */} + {allDetected.length > 0 && ( +
+ + {!detectedCollapsed && + allDetected.map((port) => ( + handleForwardDetected(port)} + /> + ))} +
+ )} + + {/* Empty state */} + {allForwards.length === 0 && allDetected.length === 0 && ( +
+

+ {translate('auto.components.right.sidebar.PortsPanel.1f0d2a24f9', 'No forwarded ports')} +

+

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

+ +
+ )} + + +
+ ) +}