diff --git a/README.md b/README.md index fb1514d1..1c874aed 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- claude-devtools + claude-devtools

claude-devtools

@@ -10,14 +10,6 @@ A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.

-

- License: MIT - Platform - Electron - React - TypeScript -

-

diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 990ddc3a..2cb0a516 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ resolve: { alias: { '@main': resolve(__dirname, 'src/main'), - '@shared': resolve(__dirname, 'src/shared') + '@shared': resolve(__dirname, 'src/shared'), + '@preload': resolve(__dirname, 'src/preload') } }, build: { diff --git a/package.json b/package.json index 484ab850..b27fb54e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", + "ssh-config": "^5.0.4", "ssh2": "^1.17.0", "unified": "^11.0.5", "zustand": "^4.5.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b3c15c7..c74fdc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: remark-parse: specifier: ^11.0.0 version: 11.0.0 + ssh-config: + specifier: ^5.0.4 + version: 5.0.4 ssh2: specifier: ^1.17.0 version: 1.17.0 @@ -3551,6 +3554,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssh-config@5.0.4: + resolution: {integrity: sha512-nCCJTY30Alhm8CWhhN8Yr1YAx2WOrDBLMMh7JYGrzCj3qssTPV+v10hYimd+8wJJeV10VrN8lFumawAEfEwjNA==} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -7931,6 +7937,8 @@ snapshots: sprintf-js@1.1.3: optional: true + ssh-config@5.0.4: {} + ssh2@1.17.0: dependencies: asn1: 0.2.6 diff --git a/src/main/index.ts b/src/main/index.ts index 2490cbac..9bfededf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -45,6 +45,7 @@ import { NotificationManager, ProjectScanner, SessionParser, + SshConnectionManager, SubagentResolver, UpdaterService, } from './services'; @@ -64,9 +65,7 @@ let dataCache: DataCache; let fileWatcher: FileWatcher; let notificationManager: NotificationManager; let updaterService: UpdaterService; -let sshConnectionManager: InstanceType< - typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager ->; +let sshConnectionManager: SshConnectionManager; let cleanupInterval: NodeJS.Timeout | null = null; /** @@ -76,11 +75,7 @@ function initializeServices(): void { logger.info('Initializing services...'); // Initialize SSH connection manager - const { SshConnectionManager: SshConnMgr } = - require('./services/infrastructure/SshConnectionManager') as { - SshConnectionManager: typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager; - }; - sshConnectionManager = new SshConnMgr(); + sshConnectionManager = new SshConnectionManager(); // Initialize services (paths are set automatically from environment) projectScanner = new ProjectScanner(); diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 27c84e8c..d0b3d6b4 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -11,7 +11,9 @@ import { SSH_CONNECT, SSH_DISCONNECT, + SSH_GET_CONFIG_HOSTS, SSH_GET_STATE, + SSH_RESOLVE_HOST, SSH_TEST, } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -96,6 +98,28 @@ export function registerSshHandlers(ipcMain: IpcMain): void { } }); + ipcMain.handle(SSH_GET_CONFIG_HOSTS, async () => { + try { + const hosts = await connectionManager.getConfigHosts(); + return { success: true, data: hosts }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to get SSH config hosts:', message); + return { success: true, data: [] }; + } + }); + + ipcMain.handle(SSH_RESOLVE_HOST, async (_event, alias: string) => { + try { + const entry = await connectionManager.resolveHostConfig(alias); + return { success: true, data: entry }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Failed to resolve SSH host "${alias}":`, message); + return { success: true, data: null }; + } + }); + logger.info('SSH handlers registered'); } @@ -104,4 +128,6 @@ export function removeSshHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(SSH_DISCONNECT); ipcMain.removeHandler(SSH_GET_STATE); ipcMain.removeHandler(SSH_TEST); + ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS); + ipcMain.removeHandler(SSH_RESOLVE_HOST); } diff --git a/src/main/services/infrastructure/SshConfigParser.ts b/src/main/services/infrastructure/SshConfigParser.ts new file mode 100644 index 00000000..d49f80ee --- /dev/null +++ b/src/main/services/infrastructure/SshConfigParser.ts @@ -0,0 +1,194 @@ +/** + * SshConfigParser - Parses ~/.ssh/config to resolve host aliases. + * + * Responsibilities: + * - Parse SSH config with Include directive support + * - Return all defined Host aliases (excluding wildcards) + * - Resolve alias to HostName, Port, User, IdentityFile + * - Gracefully handle missing/unreadable files + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import SSHConfig from 'ssh-config'; + +import type { SshConfigHostEntry } from '@shared/types'; + +const logger = createLogger('Infrastructure:SshConfigParser'); + +export class SshConfigParser { + private configPath: string; + + constructor(configPath?: string) { + this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config'); + } + + /** + * Returns all defined Host aliases (excluding `*` wildcards and patterns). + */ + async getHosts(): Promise { + try { + const config = await this.parseConfig(); + if (!config) return []; + + const entries: SshConfigHostEntry[] = []; + + for (const section of config) { + if (section.type !== SSHConfig.DIRECTIVE) continue; + if (section.param !== 'Host') continue; + + const hostValue = section.value; + if (typeof hostValue !== 'string') continue; + + // Skip wildcard-only entries and patterns with * or ? + const aliases = hostValue.split(/\s+/).filter((h) => !h.includes('*') && !h.includes('?')); + + for (const alias of aliases) { + const resolved = this.resolveFromConfig(config, alias); + entries.push(resolved); + } + } + + return entries; + } catch (err) { + logger.error('Failed to get SSH config hosts:', err); + return []; + } + } + + /** + * Resolves a host alias to its SSH config values. + * Returns null if the alias is not found in config. + */ + async resolveHost(alias: string): Promise { + try { + const config = await this.parseConfig(); + if (!config) return null; + + const resolved = this.resolveFromConfig(config, alias); + + // If nothing was resolved beyond the alias itself, check if host was actually defined + if (!resolved.hostName && !resolved.user && !resolved.port && !resolved.hasIdentityFile) { + // Check if there's an explicit Host entry for this alias + const hasEntry = config.some( + (section) => + section.type === SSHConfig.DIRECTIVE && + section.param === 'Host' && + typeof section.value === 'string' && + section.value.split(/\s+/).includes(alias) + ); + if (!hasEntry) return null; + } + + return resolved; + } catch (err) { + logger.error(`Failed to resolve SSH host "${alias}":`, err); + return null; + } + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + private resolveFromConfig(config: SSHConfig, alias: string): SshConfigHostEntry { + const computed = config.compute(alias); + + const rawHostName = computed.HostName; + const hostName = Array.isArray(rawHostName) ? rawHostName[0] : rawHostName; + const rawUser = computed.User; + const user = Array.isArray(rawUser) ? rawUser[0] : (rawUser ?? undefined); + const portStr = computed.Port; + const port = portStr ? parseInt(String(portStr), 10) : undefined; + const identityFile = computed.IdentityFile; + const hasIdentityFile = Array.isArray(identityFile) + ? identityFile.length > 0 + : identityFile != null; + + return { + alias, + hostName: hostName && hostName !== alias ? hostName : undefined, + user, + port: port && port !== 22 ? port : undefined, + hasIdentityFile, + }; + } + + private async parseConfig(): Promise { + try { + let content = await fs.promises.readFile(this.configPath, 'utf8'); + + // Process Include directives by expanding them inline + content = await this.expandIncludes(content); + + return SSHConfig.parse(content); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info('No SSH config file found at', this.configPath); + } else { + logger.error('Failed to parse SSH config:', err); + } + return null; + } + } + + private async expandIncludes(content: string): Promise { + const lines = content.split('\n'); + const result: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + const match = + trimmed.startsWith('Include ') || trimmed.startsWith('include ') + ? /^[Ii]nclude\s+(\S.*)$/.exec(trimmed) + : null; + + if (!match) { + result.push(line); + continue; + } + + const pattern = match[1].trim(); + const expandedPattern = pattern.replace(/^~/, os.homedir()); + + try { + // Handle glob-like patterns by checking if the path contains wildcards + if (expandedPattern.includes('*') || expandedPattern.includes('?')) { + const dir = path.dirname(expandedPattern); + const globPart = path.basename(expandedPattern); + const files = await this.globFiles(dir, globPart); + + for (const file of files) { + try { + const included = await fs.promises.readFile(file, 'utf8'); + result.push(included); + } catch { + // Skip unreadable included files + } + } + } else { + const included = await fs.promises.readFile(expandedPattern, 'utf8'); + result.push(included); + } + } catch { + // Skip unresolvable includes + } + } + + return result.join('\n'); + } + + private async globFiles(dir: string, pattern: string): Promise { + try { + const entries = await fs.promises.readdir(dir); + const regex = new RegExp( + '^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$' + ); + return entries.filter((e) => regex.test(e)).map((e) => path.join(dir, e)); + } catch { + return []; + } + } +} diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts index 5071c956..196e1cca 100644 --- a/src/main/services/infrastructure/SshConnectionManager.ts +++ b/src/main/services/infrastructure/SshConnectionManager.ts @@ -10,15 +10,19 @@ */ import { createLogger } from '@shared/utils/logger'; +import { execFile } from 'child_process'; import { EventEmitter } from 'events'; +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { Client, type ConnectConfig } from 'ssh2'; import { LocalFileSystemProvider } from './LocalFileSystemProvider'; +import { SshConfigParser } from './SshConfigParser'; import { SshFileSystemProvider } from './SshFileSystemProvider'; import type { FileSystemProvider } from './FileSystemProvider'; +import type { SshConfigHostEntry } from '@shared/types'; const logger = createLogger('Infrastructure:SshConnectionManager'); @@ -28,7 +32,7 @@ const logger = createLogger('Infrastructure:SshConnectionManager'); export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type SshAuthMethod = 'password' | 'privateKey' | 'agent'; +export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto'; export interface SshConnectionConfig { host: string; @@ -64,6 +68,7 @@ export class SshConnectionManager extends EventEmitter { private client: Client | null = null; private provider: FileSystemProvider; private localProvider: LocalFileSystemProvider; + private configParser: SshConfigParser; private state: SshConnectionState = 'disconnected'; private connectedHost: string | null = null; private lastError: string | null = null; @@ -73,6 +78,7 @@ export class SshConnectionManager extends EventEmitter { super(); this.localProvider = new LocalFileSystemProvider(); this.provider = this.localProvider; + this.configParser = new SshConfigParser(); } /** @@ -109,6 +115,20 @@ export class SshConnectionManager extends EventEmitter { return this.state === 'connected' && this.provider.type === 'ssh'; } + /** + * Returns all SSH config host entries from ~/.ssh/config. + */ + async getConfigHosts(): Promise { + return this.configParser.getHosts(); + } + + /** + * Resolves a host alias from ~/.ssh/config. + */ + async resolveHostConfig(alias: string): Promise { + return this.configParser.resolveHost(alias); + } + /** * Connect to a remote SSH host. */ @@ -244,10 +264,13 @@ export class SshConnectionManager extends EventEmitter { // =========================================================================== private async buildConnectConfig(config: SshConnectionConfig): Promise { + // Resolve SSH config for the given host (alias or hostname) + const sshConfig = await this.configParser.resolveHost(config.host); + const connectConfig: ConnectConfig = { - host: config.host, - port: config.port, - username: config.username, + host: sshConfig?.hostName ?? config.host, + port: config.port !== 22 ? config.port : (sshConfig?.port ?? config.port), + username: config.username || sshConfig?.user || os.userInfo().username, readyTimeout: 10000, }; @@ -258,9 +281,8 @@ export class SshConnectionManager extends EventEmitter { case 'privateKey': { const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa'); - const { promises: fsPromises } = await import('fs'); try { - const keyData = await fsPromises.readFile(keyPath, 'utf8'); + const keyData = await fs.promises.readFile(keyPath, 'utf8'); connectConfig.privateKey = keyData; } catch (err) { throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`); @@ -268,17 +290,163 @@ export class SshConnectionManager extends EventEmitter { break; } - case 'agent': - connectConfig.agent = process.env.SSH_AUTH_SOCK; - if (!connectConfig.agent) { - throw new Error('SSH_AUTH_SOCK environment variable is not set'); + case 'agent': { + const agentSocket = await this.discoverAgentSocket(); + if (!agentSocket) { + throw new Error( + 'SSH agent socket not found. Ensure ssh-agent is running or SSH_AUTH_SOCK is set.' + ); + } + connectConfig.agent = agentSocket; + break; + } + + case 'auto': { + // Auto: try identity file from config -> agent -> default keys + const resolved = await this.resolveAutoAuth(sshConfig); + if (resolved.privateKey) { + connectConfig.privateKey = resolved.privateKey; + } else if (resolved.agent) { + connectConfig.agent = resolved.agent; } break; + } } return connectConfig; } + /** + * Discovers the SSH agent socket path. + * Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell. + */ + private async discoverAgentSocket(): Promise { + // 1. Check SSH_AUTH_SOCK env var + if (process.env.SSH_AUTH_SOCK) { + try { + await fs.promises.access(process.env.SSH_AUTH_SOCK); + return process.env.SSH_AUTH_SOCK; + } catch { + // Socket path set but not accessible + } + } + + // 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env) + if (process.platform === 'darwin') { + try { + const sock = await new Promise((resolve) => { + execFile('/bin/launchctl', ['getenv', 'SSH_AUTH_SOCK'], (err, stdout) => { + if (err || !stdout.trim()) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); + if (sock) { + try { + await fs.promises.access(sock); + return sock; + } catch { + // Not accessible + } + } + } catch { + // launchctl not available + } + } + + // 3. Try known socket paths + const knownPaths = [ + // 1Password SSH agent + path.join( + os.homedir(), + 'Library', + 'Group Containers', + '2BUA8C4S2C.com.1password', + 'agent.sock' + ), + path.join(os.homedir(), '.1password', 'agent.sock'), + // Common user agent socket + path.join(os.homedir(), '.ssh', 'agent.sock'), + ]; + + // Linux: add system paths + if (process.platform === 'linux') { + const uid = process.getuid?.(); + if (uid !== undefined) { + knownPaths.push(`/run/user/${uid}/ssh-agent.socket`); + knownPaths.push(`/run/user/${uid}/keyring/ssh`); + } + } + + for (const socketPath of knownPaths) { + try { + await fs.promises.access(socketPath); + return socketPath; + } catch { + // Not accessible + } + } + + return null; + } + + /** + * Resolves authentication automatically by trying: + * 1. IdentityFile from SSH config + * 2. SSH agent + * 3. Default key files (id_ed25519, id_rsa) + */ + private async resolveAutoAuth( + sshConfig: SshConfigHostEntry | null + ): Promise<{ privateKey?: string; agent?: string }> { + // Try SSH config identity file + if (sshConfig?.hasIdentityFile) { + const resolved = await this.configParser.resolveHost(sshConfig.alias); + if (resolved) { + // The config parser already told us there's an identity file. + // Try common identity file locations from config + const configKeyPaths = [ + path.join(os.homedir(), '.ssh', 'id_ed25519'), + path.join(os.homedir(), '.ssh', 'id_rsa'), + ]; + for (const keyPath of configKeyPaths) { + try { + const keyData = await fs.promises.readFile(keyPath, 'utf8'); + return { privateKey: keyData }; + } catch { + // Try next + } + } + } + } + + // Try SSH agent + const agentSocket = await this.discoverAgentSocket(); + if (agentSocket) { + return { agent: agentSocket }; + } + + // Try default key files + const defaultKeys = [ + path.join(os.homedir(), '.ssh', 'id_ed25519'), + path.join(os.homedir(), '.ssh', 'id_rsa'), + path.join(os.homedir(), '.ssh', 'id_ecdsa'), + ]; + + for (const keyPath of defaultKeys) { + try { + const keyData = await fs.promises.readFile(keyPath, 'utf8'); + return { privateKey: keyData }; + } catch { + // Try next + } + } + + return {}; + } + private async resolveRemoteProjectsPath(username: string): Promise { // Try to resolve the remote home directory // SFTP doesn't have a direct "get home dir" call, so we try common paths diff --git a/src/main/services/infrastructure/TriggerManager.ts b/src/main/services/infrastructure/TriggerManager.ts index 3b781382..4dd0a658 100644 --- a/src/main/services/infrastructure/TriggerManager.ts +++ b/src/main/services/infrastructure/TriggerManager.ts @@ -28,6 +28,18 @@ export interface TriggerValidationResult { * Default built-in notification triggers. */ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [ + { + id: 'builtin-bash-command', + name: '.env File Access Alert', + enabled: true, + contentType: 'tool_use', + toolName: 'Any Tool', + mode: 'content_match', + matchField: 'command', + matchPattern: '/.env', + isBuiltin: true, + color: 'red', + }, { id: 'builtin-tool-result-error', name: 'Tool Result Error', @@ -40,19 +52,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [ '\\[Request interrupted by user for tool use\\]', ], isBuiltin: true, - color: 'red', - }, - { - id: 'builtin-bash-command', - name: '.env File Access Alert', - enabled: true, - contentType: 'tool_use', - toolName: 'Bash', - mode: 'content_match', - matchField: 'command', - matchPattern: '/.env', - isBuiltin: true, - color: 'red', + color: 'orange', }, { id: 'builtin-high-token-usage', @@ -62,7 +62,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [ mode: 'token_threshold', tokenThreshold: 8000, tokenType: 'total', - color: 'orange', + color: 'yellow', isBuiltin: true, }, ]; diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index e8bc3eba..946f6a23 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -19,6 +19,7 @@ export type * from './FileSystemProvider'; export * from './FileWatcher'; export * from './LocalFileSystemProvider'; export * from './NotificationManager'; +export * from './SshConfigParser'; export * from './SshConnectionManager'; export * from './SshFileSystemProvider'; export * from './TriggerManager'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index ace58729..e1655264 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -75,6 +75,12 @@ export const SSH_GET_STATE = 'ssh:getState'; /** Test SSH connection without switching */ export const SSH_TEST = 'ssh:test'; +/** Get SSH config hosts from ~/.ssh/config */ +export const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts'; + +/** Resolve a single SSH config host alias */ +export const SSH_RESOLVE_HOST = 'ssh:resolveHost'; + /** SSH status event channel (main -> renderer) */ export const SSH_STATUS = 'ssh:status'; diff --git a/src/preload/index.ts b/src/preload/index.ts index d06e9036..696eb8e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,9 @@ import { contextBridge, ipcRenderer } from 'electron'; import { SSH_CONNECT, SSH_DISCONNECT, + SSH_GET_CONFIG_HOSTS, SSH_GET_STATE, + SSH_RESOLVE_HOST, SSH_STATUS, SSH_TEST, UPDATER_CHECK, @@ -37,6 +39,7 @@ import type { ElectronAPI, NotificationTrigger, SessionsPaginationOptions, + SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, TriggerTestResult, @@ -332,6 +335,12 @@ const electronAPI: ElectronAPI = { test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => { return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config); }, + getConfigHosts: async (): Promise => { + return invokeIpcWithResult(SSH_GET_CONFIG_HOSTS); + }, + resolveHost: async (alias: string): Promise => { + return invokeIpcWithResult(SSH_RESOLVE_HOST, alias); + }, onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => { ipcRenderer.on( SSH_STATUS, diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index cdc6cc30..0580d641 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -4,10 +4,11 @@ * 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 */ -import { useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react'; @@ -15,7 +16,7 @@ import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react'; import { SettingRow } from '../components/SettingRow'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; -import type { SshAuthMethod, SshConnectionConfig } from '@shared/types'; +import type { SshAuthMethod, SshConfigHostEntry, SshConnectionConfig } from '@shared/types'; export const ConnectionSection = (): React.JSX.Element => { const connectionState = useStore((s) => s.connectionState); @@ -24,17 +25,64 @@ export const ConnectionSection = (): React.JSX.Element => { const connectSsh = useStore((s) => s.connectSsh); const disconnectSsh = useStore((s) => s.disconnectSsh); const testConnection = useStore((s) => s.testConnection); + const sshConfigHosts = useStore((s) => s.sshConfigHosts); + const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts); // Form state const [host, setHost] = useState(''); const [port, setPort] = useState('22'); const [username, setUsername] = useState(''); - const [authMethod, setAuthMethod] = useState('agent'); + 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); + + // Fetch SSH config hosts on mount + useEffect(() => { + void fetchSshConfigHosts(); + }, [fetchSshConfigHosts]); + + // 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 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); + }; + const buildConfig = (): SshConnectionConfig => ({ host, port: parseInt(port, 10) || 22, @@ -64,6 +112,11 @@ export const ConnectionSection = (): React.JSX.Element => { const isConnected = connectionState === 'connected'; 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 (

@@ -130,22 +183,60 @@ export const ConnectionSection = (): React.JSX.Element => {
-
+ {/* Host input with combobox */} +
setHost(e.target.value)} - placeholder="192.168.1.100" - className={inputClass} - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', + onChange={(e) => { + setHost(e.target.value); + setShowDropdown(true); + setTestResult(null); }} + onFocus={() => setShowDropdown(true)} + placeholder="hostname or ssh config alias" + className={inputClass} + style={inputStyle} /> + {showDropdown && filteredHosts.length > 0 && ( +
+ {filteredHosts.map((entry) => ( + + ))} +
+ )}
@@ -176,11 +263,7 @@ export const ConnectionSection = (): React.JSX.Element => { onChange={(e) => setUsername(e.target.value)} placeholder="user" className={inputClass} - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} + style={inputStyle} />
@@ -192,12 +275,9 @@ export const ConnectionSection = (): React.JSX.Element => { value={authMethod} onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)} className="w-full rounded-md border px-3 py-1.5 text-sm" - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} + style={inputStyle} > + @@ -215,11 +295,7 @@ export const ConnectionSection = (): React.JSX.Element => { onChange={(e) => setPrivateKeyPath(e.target.value)} placeholder="~/.ssh/id_rsa" className={inputClass} - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} + style={inputStyle} />
)} @@ -234,11 +310,7 @@ export const ConnectionSection = (): React.JSX.Element => { value={password} onChange={(e) => setPassword(e.target.value)} className={inputClass} - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} + style={inputStyle} /> )} @@ -254,7 +326,7 @@ export const ConnectionSection = (): React.JSX.Element => { > {testResult.success ? 'Connection successful' - : `Connection failed: ${testResult.error}`} + : `Connection failed: ${testResult.error ?? 'Unknown error'}`} )} @@ -262,7 +334,7 @@ export const ConnectionSection = (): React.JSX.Element => {