diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 01110007c5..dd3652d640 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -59,6 +59,22 @@ fn ssh_cfg_has(settings: &std::collections::HashMap<&str, &str>, canonical_key: .any(|k| k.eq_ignore_ascii_case(canonical_key)) } +/// Extract the first value from an SSH config directive line, handling double-quoted strings. +/// +/// SSH config values may be enclosed in double quotes when they contain whitespace +/// (e.g. `IdentityFile "~/.ssh/my key"`). This function correctly extracts the full +/// quoted value or the first whitespace-delimited token for unquoted values. +fn parse_ssh_config_value(value: &str) -> Option<&str> { + let value = value.trim(); + if value.starts_with('"') { + if let Some(end) = value[1..].find('"') { + let inner = &value[1..1 + end]; + return if inner.is_empty() { None } else { Some(inner) }; + } + } + value.split_whitespace().next() +} + /// Manually parse `~/.ssh/config` content into Host blocks with their direct settings. /// /// This is a fallback for when `SSHConfig::parse_str` fails — which happens when the @@ -112,16 +128,14 @@ fn parse_ssh_config_manually(content: &str) -> Vec { } else if current_host.is_some() { // Track details within the current Host block if keyword.eq_ignore_ascii_case("HostName") { - block_hostname = value.split_whitespace().next().map(|s| s.to_string()); + block_hostname = parse_ssh_config_value(value).map(|s| s.to_string()); } else if keyword.eq_ignore_ascii_case("Port") { - block_port = value.split_whitespace().next().and_then(|s| s.parse().ok()); + block_port = parse_ssh_config_value(value).and_then(|s| s.parse().ok()); } else if keyword.eq_ignore_ascii_case("User") { - block_user = value.split_whitespace().next().map(|s| s.to_string()); + block_user = parse_ssh_config_value(value).map(|s| s.to_string()); } else if keyword.eq_ignore_ascii_case("IdentityFile") { - block_identity_file = value - .split_whitespace() - .next() - .map(|s| shellexpand::tilde(s).to_string()); + block_identity_file = + parse_ssh_config_value(value).map(|s| shellexpand::tilde(s).to_string()); } } } diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss index da47a85292..4d89318c46 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss @@ -33,20 +33,41 @@ padding: 0 16px; } + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + &__section-title { font-size: 11px; font-weight: 600; font-family: inherit; color: var(--color-text-muted); - margin: 0 0 6px 0; + margin: 0; text-transform: uppercase; letter-spacing: 0.5px; + white-space: nowrap; + } + + &__search { + max-width: 160px; + flex-shrink: 0; } &__saved-list { display: flex; flex-direction: column; gap: 4px; + max-height: 132px; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } &__saved-item { @@ -54,7 +75,6 @@ align-items: center; gap: 8px; padding: 5px 10px; - border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--card-bg-default); transition: all var(--motion-fast) var(--easing-standard); @@ -62,7 +82,6 @@ position: relative; &:hover { - border-color: var(--border-accent); background: var(--card-bg-hover); } diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index df70bc8756..73fda391ad 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -13,7 +13,7 @@ import { Input } from '@/component-library'; import { Select } from '@/component-library'; import { Alert } from '@/component-library'; import { IconButton } from '@/component-library'; -import { FolderOpen, Loader2, Server, User, Key, Lock, Trash2, Plus, Pencil, Play, ArrowDownToLine } from 'lucide-react'; +import { FolderOpen, Loader2, Server, User, Key, Lock, Trash2, Plus, Pencil, Play, ArrowDownToLine, Search } from 'lucide-react'; import type { SSHConnectionConfig, SSHAuthMethod, @@ -40,6 +40,8 @@ export const SSHConnectionDialog: React.FC = ({ const [localError, setLocalError] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [credentialsPrompt, setCredentialsPrompt] = useState(null); + const [savedSearch, setSavedSearch] = useState(''); + const [configSearch, setConfigSearch] = useState(''); const error = localError || connectionError; @@ -80,6 +82,8 @@ export const SSHConnectionDialog: React.FC = ({ if (open) { clearError(); setLocalError(null); + setSavedSearch(''); + setConfigSearch(''); void loadSavedConnections(); void loadSSHConfigHosts(); } @@ -336,6 +340,33 @@ export const SSHConnectionDialog: React.FC = ({ { label: t('ssh.remote.privateKey') || 'Private Key', value: 'privateKey', icon: }, ]; + const filteredSavedConnections = savedConnections.filter((conn) => { + if (!savedSearch.trim()) return true; + const q = savedSearch.toLowerCase(); + return ( + conn.name.toLowerCase().includes(q) || + conn.host.toLowerCase().includes(q) || + conn.username.toLowerCase().includes(q) + ); + }); + + const filteredSSHConfigHosts = sshConfigHosts.filter((configHost) => { + // Hide SSH config hosts that already have a saved connection + const hostname = configHost.hostname || configHost.host; + const port = configHost.port || 22; + const user = configHost.user || ''; + if (savedConnections.some((c) => c.host === hostname && c.port === port && c.username === user)) { + return false; + } + if (!configSearch.trim()) return true; + const q = configSearch.toLowerCase(); + return ( + configHost.host.toLowerCase().includes(q) || + hostname.toLowerCase().includes(q) || + (configHost.user || '').toLowerCase().includes(q) + ); + }); + const dismissError = () => { setLocalError(null); clearError(); @@ -372,11 +403,21 @@ export const SSHConnectionDialog: React.FC = ({ {/* Saved connections section */} {savedConnections.length > 0 && (
-

- {t('ssh.remote.savedConnections')} -

+
+

+ {t('ssh.remote.savedConnections')} +

+ setSavedSearch(e.target.value)} + placeholder={t('actions.search')} + prefix={} + size="small" + /> +
- {savedConnections.map((conn) => ( + {filteredSavedConnections.map((conn) => (
= ({ {/* SSH Config hosts section */} {sshConfigHosts.length > 0 && (
-

- {t('ssh.remote.sshConfigHosts') || 'SSH Config'} -

+
+

+ {t('ssh.remote.sshConfigHosts') || 'SSH Config'} +

+ setConfigSearch(e.target.value)} + placeholder={t('actions.search')} + prefix={} + size="small" + /> +
- {sshConfigHosts - .filter((configHost) => { - // Hide SSH config hosts that already have a saved connection - const hostname = configHost.hostname || configHost.host; - const port = configHost.port || 22; - const user = configHost.user || ''; - return !savedConnections.some( - (c) => c.host === hostname && c.port === port && c.username === user - ); - }) - .map((configHost) => ( + {filteredSSHConfigHosts.map((configHost) => (