diff --git a/README.md b/README.md
index fb1514d1..1c874aed 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
claude-devtools
@@ -10,14 +10,6 @@
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
-
-
-
-
-
-
-
-
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 => {