/** * Terminal Connection Dialog * Full connection overlay with host info, progress indicator, and auth/progress content */ import { Fingerprint, Loader2, Plug, TerminalSquare, X } from 'lucide-react'; import React, { useCallback, useEffect, useRef } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { cn } from '../../lib/utils'; import { Host, SSHKey } from '../../types'; import { formatHostPort, resolveTelnetPort } from '../../domain/host'; import { isPluginHostProtocol } from '../../domain/pluginConnection'; import { DistroAvatar } from '../DistroAvatar'; import { Button } from '../ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog'; import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress'; import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification'; import { resolveDisconnectedDialogTerminalRoot, restoreTerminalFocusFromDisconnectedDialog, shouldClaimDisconnectedDialogFocus, shouldReconnectDisconnectedDialogOnEnterKey, shouldRestoreDisconnectedDialogTerminalFocus, } from './terminalHelpers'; export interface ChainProgress { currentHop: number; totalHops: number; currentHostLabel: string; } export interface TerminalConnectionDialogProps { host: Host; status: 'connecting' | 'connected' | 'disconnected'; restoreState?: 'restored-disconnected'; error: string | null; progressValue: number; chainProgress: ChainProgress | null; needsAuth: boolean; showLogs: boolean; _setShowLogs: (show: boolean) => void; // Auth dialog props authProps: Omit; keys: SSHKey[]; onDismissDisconnected?: () => void; showEnterReconnectHint?: boolean; /** False for unfocused split siblings — do not claim body/document focus. */ isFocusedPane?: boolean; hostKeyVerification?: { hostKeyInfo: HostKeyInfo; onClose: () => void; onContinue: () => void; onAddAndContinue: () => void; }; // Progress props progressProps: Omit; } // Helper to get protocol display info const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port: number } => { // Check moshEnabled first since mosh uses protocol: "ssh" with moshEnabled: true if (host.moshEnabled) { return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 }; } // ET likewise uses protocol: "ssh" with etEnabled: true. Show the ET // server port (default 2022) rather than the SSH port: ET connectivity // hinges on the etserver port, so surfacing the SSH port (22) here is // misleading when troubleshooting a connection that is actually stuck on // the ET port. if (host.etEnabled) { return { i18nKey: 'terminal.connection.protocol.et', showPort: true, port: host.etPort || 2022 }; } const protocol = host.protocol || 'ssh'; if (isPluginHostProtocol(protocol)) { return { i18nKey: 'terminal.connection.protocol.plugin', showPort: false, port: 0 }; } switch (protocol) { case 'local': return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 }; case 'telnet': // Telnet uses telnetPort, not port (which is SSH port) return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: resolveTelnetPort(host) }; case 'mosh': return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 }; case 'serial': return { i18nKey: 'terminal.connection.protocol.serial', showPort: false, port: 0 }; case 'ssh': default: return { i18nKey: 'terminal.connection.protocol.ssh', showPort: true, port: host.port || 22 }; } }; export const TerminalConnectionDialog: React.FC = ({ host, status, restoreState, error, progressValue, chainProgress, needsAuth, showLogs, _setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use authProps, keys, onDismissDisconnected, showEnterReconnectHint, isFocusedPane, hostKeyVerification, progressProps, }) => { const { t } = useI18n(); const hasError = Boolean(error); const isRestoredDisconnected = status === 'disconnected' && restoreState === 'restored-disconnected'; const isConnecting = status === 'connecting'; const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected; const protocolInfo = getProtocolInfo(host); const isVerifyingHostKey = Boolean(hostKeyVerification); const isHostKeyChanged = hostKeyVerification?.hostKeyInfo.status === 'changed'; const shouldCompleteProgress = hasError || (!isConnecting && !needsAuth); // When the disconnected overlay is up and Enter-reconnect is advertised, // keep a focus sink on the overlay itself so body/document focus loss cannot // make the hint lie (#2544). Auth/host-key flows keep their own inputs. const onRetry = progressProps.onRetry; const canEnterReconnectFromDialog = Boolean( showEnterReconnectHint && status === 'disconnected' && !needsAuth && !isVerifyingHostKey && onRetry, ); const dialogFocusRef = useRef(null); // Unmount cleanup keeps [] deps; read the latest pane ownership then. const isFocusedPaneRef = useRef(isFocusedPane); isFocusedPaneRef.current = isFocusedPane; // Claim focus only when Enter-reconnect mode turns on — not on every // showLogs/error rerender (those would steal keyboard focus off buttons). useEffect(() => { if (!canEnterReconnectFromDialog) return; const node = dialogFocusRef.current; if (!node) return; const sessionRoot = node.closest("[data-session-id]"); const focusOverlay = () => { if (typeof document === "undefined") return; if (!shouldClaimDisconnectedDialogFocus({ activeElement: document.activeElement, dialogNode: node, sessionRoot, documentBody: document.body, documentElement: document.documentElement, isFocusedPane, })) { return; } node.focus({ preventScroll: true }); }; focusOverlay(); // Re-assert after paint/microtasks so late blur from xterm teardown // cannot leave focus on document.body — still never steals other panes. const timer = window.setTimeout(focusOverlay, 0); return () => window.clearTimeout(timer); }, [canEnterReconnectFromDialog, isFocusedPane]); // Restore xterm focus only when this overlay unmounts (connected / dismiss). // Do not key on Enter-reconnect mode: reconnect may keep the dialog mounted // for connecting / auth / host-key, and restoring then routes keyboard behind // the blocking overlay. useEffect(() => { const node = dialogFocusRef.current; if (!node) return; // Capture the terminal root while the dialog is still mounted — after // unmount, parentElement is null and popup trees have no data-session-id. const sessionRoot = resolveDisconnectedDialogTerminalRoot( node, node.closest("[data-session-id]"), ); return () => { if (typeof document === "undefined") return; if (!shouldRestoreDisconnectedDialogTerminalFocus(node)) return; restoreTerminalFocusFromDisconnectedDialog({ activeElement: document.activeElement, dialogNode: node, sessionRoot, documentBody: document.body, documentElement: document.documentElement, isFocusedPane: isFocusedPaneRef.current, }); }; }, []); const handleDialogKeyDown = useCallback((event: React.KeyboardEvent) => { if (!shouldReconnectDisconnectedDialogOnEnterKey({ key: event.key, enabled: canEnterReconnectFromDialog, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, isComposing: event.nativeEvent.isComposing, target: event.target, })) { return; } event.preventDefault(); event.stopPropagation(); onRetry?.(); }, [canEnterReconnectFromDialog, onRetry]); const targetFirstSegmentWidth = isVerifyingHostKey || shouldCompleteProgress ? 100 : Math.min(100, progressValue * 2); const targetSecondSegmentWidth = isVerifyingHostKey ? 0 : shouldCompleteProgress ? 100 : Math.max(0, Math.min(100, (progressValue - 50) * 2)); const [secondSegmentUnlocked, setSecondSegmentUnlocked] = React.useState( () => shouldCompleteProgress || targetSecondSegmentWidth <= 0 ); const secondSegmentUnlockTimerRef = React.useRef | null>(null); React.useEffect(() => { return () => { if (secondSegmentUnlockTimerRef.current) { clearTimeout(secondSegmentUnlockTimerRef.current); } }; }, []); React.useEffect(() => { if (needsAuth || isVerifyingHostKey || targetSecondSegmentWidth <= 0 || shouldCompleteProgress) { if (secondSegmentUnlockTimerRef.current) { clearTimeout(secondSegmentUnlockTimerRef.current); secondSegmentUnlockTimerRef.current = null; } setSecondSegmentUnlocked(shouldCompleteProgress); return; } if (secondSegmentUnlocked || secondSegmentUnlockTimerRef.current) return; secondSegmentUnlockTimerRef.current = setTimeout(() => { secondSegmentUnlockTimerRef.current = null; setSecondSegmentUnlocked(true); }, 320); }, [isVerifyingHostKey, needsAuth, secondSegmentUnlocked, shouldCompleteProgress, targetSecondSegmentWidth]); const firstSegmentWidth = targetFirstSegmentWidth; const secondSegmentWidth = shouldCompleteProgress || secondSegmentUnlocked ? targetSecondSegmentWidth : 0; return (
{ // Clicking the dimmed backdrop (not a control) should park focus // on the overlay so the next Enter still reconnects. if (!canEnterReconnectFromDialog) return; const target = event.target; if (!(target instanceof HTMLElement)) return; if (target.closest("button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='menuitem'], [role='textbox']")) { return; } dialogFocusRef.current?.focus({ preventScroll: true }); }} >
{chainProgress ? ( <>
{t('terminal.connection.chainOf', { current: chainProgress.currentHop, total: chainProgress.totalHops, })} {': '} {chainProgress.currentHostLabel}
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
) : ( <>
{host.label}
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
)}
{!needsAuth && ( )} {status === 'connecting' && !needsAuth && !isVerifyingHostKey && ( )} {canDismissDisconnected && ( {t('terminal.connection.dismissDisconnectedDialog')} )}
50 && !hasError ? "bg-primary/15 text-primary" : hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground" )}>
{isConnecting ? ( ) : ( )}
{needsAuth ? ( ) : hostKeyVerification ? ( ) : ( <> {isRestoredDisconnected && (
{t('terminal.restore.placeholder.title')}
{t('terminal.restore.placeholder.desc')}
)} )}
); }; export default TerminalConnectionDialog;