/** * ConnectionSection - Settings section for SSH connection management. * * Provides UI for: * - Toggling between local and SSH modes * - Configuring SSH connection (host, port, username, auth) * - SSH config host alias combobox with auto-fill * - Testing and connecting to remote hosts * - Workspace profiles (via embedded WorkspaceSection) */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SettingRow } from '../components/SettingRow'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; import { SettingsSelect } from '../components/SettingsSelect'; import { WorkspaceSection } from './WorkspaceSection'; import type { ClaudeRootInfo, SshAuthMethod, SshConfigHostEntry, SshConnectionConfig, SshConnectionProfile, } from '@shared/types'; const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ { value: 'auto', label: 'Auto (from SSH Config)' }, { value: 'agent', label: 'SSH Agent' }, { value: 'privateKey', label: 'Private Key' }, { value: 'password', label: 'Password' }, ]; export const ConnectionSection = (): React.JSX.Element => { const { connectionState, connectedHost, connectionError, connectSsh, disconnectSsh, testConnection, sshConfigHosts, fetchSshConfigHosts, lastSshConfig, loadLastConnection, } = useStore( useShallow((s) => ({ connectionState: s.connectionState, connectedHost: s.connectedHost, connectionError: s.connectionError, connectSsh: s.connectSsh, disconnectSsh: s.disconnectSsh, testConnection: s.testConnection, sshConfigHosts: s.sshConfigHosts, fetchSshConfigHosts: s.fetchSshConfigHosts, lastSshConfig: s.lastSshConfig, loadLastConnection: s.loadLastConnection, })) ); // Form state const [host, setHost] = useState(''); const [port, setPort] = useState('22'); const [username, setUsername] = useState(''); const [authMethod, setAuthMethod] = useState('auto'); const [password, setPassword] = useState(''); const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa'); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); // Combobox state const [showDropdown, setShowDropdown] = useState(false); const hostInputRef = useRef(null); const dropdownRef = useRef(null); // Saved profiles const [savedProfiles, setSavedProfiles] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(null); const [claudeRootInfo, setClaudeRootInfo] = useState(null); const loadProfiles = useCallback(async () => { try { const config = await api.config.get(); const loaded = config.ssh; setSavedProfiles(loaded?.profiles ?? []); } catch { // ignore } }, []); const loadClaudeRootInfo = useCallback(async () => { try { const info = await api.config.getClaudeRootInfo(); setClaudeRootInfo(info); } catch { // ignore } }, []); // Fetch SSH config hosts, saved profiles, and load last connection on mount useEffect(() => { void fetchSshConfigHosts(); void loadLastConnection(); void loadProfiles(); void loadClaudeRootInfo(); }, [fetchSshConfigHosts, loadLastConnection, loadProfiles, loadClaudeRootInfo]); // Pre-fill form from saved connection config when it arrives (one-time on mount). // setState in effect is intentional: lastSshConfig loads async from IPC, so we can't // use it as useState initializers. const prefilled = useRef(false); useEffect(() => { if (lastSshConfig && connectionState !== 'connected' && !prefilled.current) { prefilled.current = true; setHost(lastSshConfig.host); setPort(String(lastSshConfig.port)); setUsername(lastSshConfig.username); setAuthMethod(lastSshConfig.authMethod); if (lastSshConfig.privateKeyPath) { setPrivateKeyPath(lastSshConfig.privateKeyPath); } } // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time prefill when async data arrives }, [lastSshConfig]); // Close dropdown on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent): void => { if ( dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && hostInputRef.current && !hostInputRef.current.contains(e.target as Node) ) { setShowDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Filter config hosts based on input const filteredHosts = useMemo(() => { if (!host.trim()) return sshConfigHosts; const lower = host.toLowerCase(); return sshConfigHosts.filter( (entry) => entry.alias.toLowerCase().includes(lower) || entry.hostName?.toLowerCase().includes(lower) ); }, [host, sshConfigHosts]); const clearProfileSelection = (): void => setSelectedProfileId(null); const handleSelectConfigHost = (entry: SshConfigHostEntry): void => { setHost(entry.alias); if (entry.port) setPort(String(entry.port)); if (entry.user) setUsername(entry.user); setAuthMethod('auto'); setShowDropdown(false); setTestResult(null); clearProfileSelection(); }; const handleSelectProfile = (profile: SshConnectionProfile): void => { setHost(profile.host); setPort(String(profile.port)); setUsername(profile.username); setAuthMethod(profile.authMethod); if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath); setPassword(''); setTestResult(null); setSelectedProfileId(profile.id); }; const buildConfig = (): SshConnectionConfig => ({ host, port: parseInt(port, 10) || 22, username, authMethod, password: authMethod === 'password' ? password : undefined, privateKeyPath: authMethod === 'privateKey' ? privateKeyPath : undefined, }); const handleTest = async (): Promise => { setTesting(true); setTestResult(null); const result = await testConnection(buildConfig()); setTestResult(result); setTesting(false); }; const handleConnect = async (): Promise => { await connectSsh(buildConfig()); }; const handleDisconnect = async (): Promise => { await disconnectSsh(); }; const isConnecting = connectionState === 'connecting'; const isConnected = connectionState === 'connected'; const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude'; const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1'; const inputStyle = { backgroundColor: 'var(--color-surface-raised)', borderColor: 'var(--color-border)', color: 'var(--color-text)', }; return (

Connect to a remote machine to view Claude Code sessions running there

{/* Connection Status */} {isConnected && (

Connected to {connectedHost}

Viewing remote sessions via SSH

)} {connectionError && (

{connectionError}

)} {/* Mode indicator */} {!isConnected && (
Local ({resolvedClaudeRootPath})
)} {/* Saved Profiles */} {!isConnected && savedProfiles.length > 0 && (

Saved Profiles

{savedProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; return ( ); })}
)} {/* SSH Connection Form */} {!isConnected && (

SSH Connection

{/* Host input with combobox */}
{ setHost(e.target.value); setShowDropdown(true); setTestResult(null); clearProfileSelection(); }} onFocus={() => setShowDropdown(true)} placeholder="hostname or ssh config alias" className={inputClass} style={inputStyle} /> {showDropdown && filteredHosts.length > 0 && (
{filteredHosts.map((entry) => ( ))}
)}
setPort(e.target.value)} placeholder="22" className={inputClass} style={inputStyle} />
{ setUsername(e.target.value); clearProfileSelection(); }} placeholder="user" className={inputClass} style={inputStyle} />
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- SettingsSelect is a custom dropdown without a native control */}
{authMethod === 'privateKey' && (
setPrivateKeyPath(e.target.value)} placeholder="~/.ssh/id_rsa" className={inputClass} style={inputStyle} />
)} {authMethod === 'password' && (
setPassword(e.target.value)} className={inputClass} style={inputStyle} />
)} {/* Test result */} {testResult && (
{testResult.success ? 'Connection successful' : `Connection failed: ${testResult.error ?? 'Unknown error'}`}
)} {/* Action buttons */}
)} {/* Workspace Profiles */}
); };