Enhance SSH functionality and configuration management

- Added SSH config host alias support, allowing users to fetch and resolve host entries from the SSH config file.
- Introduced SshConfigParser to handle parsing of ~/.ssh/config and retrieving host aliases.
- Updated ConnectionSection to include a combobox for selecting SSH config hosts with auto-fill capabilities.
- Enhanced SshConnectionManager to utilize the new SshConfigParser for resolving host configurations.
- Added IPC channels for fetching SSH config hosts and resolving host aliases.
- Updated relevant types and state management to accommodate new SSH config features.
This commit is contained in:
matt 2026-02-12 00:14:41 +09:00
parent 4b56186f7c
commit 921420b946
15 changed files with 591 additions and 83 deletions

View file

@ -1,5 +1,5 @@
<p align="center">
<img src="resources/icons/png/512x512.png" alt="claude-devtools" width="120" />
<img src="resources/icons/png/1024x1024.png" alt="claude-devtools" width="120" />
</p>
<h1 align="center">claude-devtools</h1>
@ -10,14 +10,6 @@
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
</p>
<p align="center">
<a href="https://github.com/matt1398/claude-devtools/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
<a href="#"><img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg" alt="Platform" /></a>
<a href="#"><img src="https://img.shields.io/badge/electron-40-47848F.svg?logo=electron" alt="Electron" /></a>
<a href="#"><img src="https://img.shields.io/badge/react-18-61DAFB.svg?logo=react" alt="React" /></a>
<a href="#"><img src="https://img.shields.io/badge/typescript-5-3178C6.svg?logo=typescript" alt="TypeScript" /></a>
</p>
<br />
<p align="center">

View file

@ -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: {

View file

@ -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"

View file

@ -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

View file

@ -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();

View file

@ -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);
}

View file

@ -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<SshConfigHostEntry[]> {
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<SshConfigHostEntry | null> {
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<SSHConfig | null> {
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<string> {
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<string[]> {
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 [];
}
}
}

View file

@ -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<SshConfigHostEntry[]> {
return this.configParser.getHosts();
}
/**
* Resolves a host alias from ~/.ssh/config.
*/
async resolveHostConfig(alias: string): Promise<SshConfigHostEntry | null> {
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<ConnectConfig> {
// 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<string | null> {
// 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<string | null>((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<string> {
// Try to resolve the remote home directory
// SFTP doesn't have a direct "get home dir" call, so we try common paths

View file

@ -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,
},
];

View file

@ -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';

View file

@ -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';

View file

@ -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<SshConfigHostEntry[]> => {
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
},
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
},
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
ipcRenderer.on(
SSH_STATUS,

View file

@ -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<SshAuthMethod>('agent');
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('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<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="space-y-6">
@ -130,22 +183,60 @@ export const ConnectionSection = (): React.JSX.Element => {
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
{/* Host input with combobox */}
<div className="relative">
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Host
</label>
<input
ref={hostInputRef}
type="text"
value={host}
onChange={(e) => 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 && (
<div
ref={dropdownRef}
className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border shadow-lg"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
>
{filteredHosts.map((entry) => (
<button
key={entry.alias}
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-raised"
style={{
color: 'var(--color-text)',
}}
onClick={() => handleSelectConfigHost(entry)}
>
<span className="font-medium">{entry.alias}</span>
{entry.hostName && (
<span style={{ color: 'var(--color-text-muted)' }}>{entry.hostName}</span>
)}
{entry.user && (
<span
className="ml-auto text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
{entry.user}
</span>
)}
</button>
))}
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
@ -157,11 +248,7 @@ export const ConnectionSection = (): React.JSX.Element => {
onChange={(e) => setPort(e.target.value)}
placeholder="22"
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
style={inputStyle}
/>
</div>
</div>
@ -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}
/>
</div>
@ -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}
>
<option value="auto">Auto (from SSH Config)</option>
<option value="agent">SSH Agent</option>
<option value="privateKey">Private Key</option>
<option value="password">Password</option>
@ -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}
/>
</div>
)}
@ -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}
/>
</div>
)}
@ -254,7 +326,7 @@ export const ConnectionSection = (): React.JSX.Element => {
>
{testResult.success
? 'Connection successful'
: `Connection failed: ${testResult.error}`}
: `Connection failed: ${testResult.error ?? 'Unknown error'}`}
</div>
)}
@ -262,7 +334,7 @@ export const ConnectionSection = (): React.JSX.Element => {
<div className="flex items-center gap-3">
<button
onClick={() => void handleTest()}
disabled={!host || !username || testing || isConnecting}
disabled={!host || testing || isConnecting}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
@ -281,7 +353,7 @@ export const ConnectionSection = (): React.JSX.Element => {
<button
onClick={() => void handleConnect()}
disabled={!host || !username || isConnecting}
disabled={!host || isConnecting}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',

View file

@ -6,7 +6,7 @@
*/
import type { AppState } from '../types';
import type { SshConnectionConfig, SshConnectionState } from '@shared/types';
import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionState } from '@shared/types';
import type { StateCreator } from 'zustand';
// =============================================================================
@ -19,6 +19,7 @@ export interface ConnectionSlice {
connectionState: SshConnectionState;
connectedHost: string | null;
connectionError: string | null;
sshConfigHosts: SshConfigHostEntry[];
// Actions
connectSsh: (config: SshConnectionConfig) => Promise<void>;
@ -29,6 +30,8 @@ export interface ConnectionSlice {
host: string | null,
error: string | null
) => void;
fetchSshConfigHosts: () => Promise<void>;
resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>;
}
// =============================================================================
@ -44,6 +47,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
connectionState: 'disconnected',
connectedHost: null,
connectionError: null,
sshConfigHosts: [],
// Actions
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
@ -118,4 +122,22 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
connectionError: error,
});
},
fetchSshConfigHosts: async (): Promise<void> => {
try {
const hosts = await window.electronAPI.ssh.getConfigHosts();
set({ sshConfigHosts: hosts });
} catch {
// Gracefully ignore - SSH config may not exist
set({ sshConfigHosts: [] });
}
},
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
try {
return await window.electronAPI.ssh.resolveHost(alias);
} catch {
return null;
}
},
});

View file

@ -156,7 +156,18 @@ export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | '
/**
* SSH authentication method.
*/
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto';
/**
* SSH config host entry resolved from ~/.ssh/config.
*/
export interface SshConfigHostEntry {
alias: string;
hostName?: string;
user?: string;
port?: number;
hasIdentityFile: boolean;
}
/**
* SSH connection configuration sent from renderer.
@ -201,6 +212,8 @@ export interface SshAPI {
disconnect: () => Promise<SshConnectionStatus>;
getState: () => Promise<SshConnectionStatus>;
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
getConfigHosts: () => Promise<SshConfigHostEntry[]>;
resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>;
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
}