Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -112,16 +128,14 @@ fn parse_ssh_config_manually(content: &str) -> Vec<SSHConfigEntry> {
} 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());
}
}
}
Expand Down
25 changes: 22 additions & 3 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,36 +33,55 @@
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 {
display: flex;
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);
cursor: pointer;
position: relative;

&:hover {
border-color: var(--border-accent);
background: var(--card-bg-hover);
}

Expand Down
79 changes: 60 additions & 19 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +40,8 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
const [localError, setLocalError] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [credentialsPrompt, setCredentialsPrompt] = useState<SavedConnection | null>(null);
const [savedSearch, setSavedSearch] = useState('');
const [configSearch, setConfigSearch] = useState('');

const error = localError || connectionError;

Expand Down Expand Up @@ -80,6 +82,8 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
if (open) {
clearError();
setLocalError(null);
setSavedSearch('');
setConfigSearch('');
void loadSavedConnections();
void loadSSHConfigHosts();
}
Expand Down Expand Up @@ -336,6 +340,33 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
{ label: t('ssh.remote.privateKey') || 'Private Key', value: 'privateKey', icon: <Key size={14} /> },
];

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();
Expand Down Expand Up @@ -372,11 +403,21 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
{/* Saved connections section */}
{savedConnections.length > 0 && (
<div className="ssh-connection-dialog__section">
<h3 className="ssh-connection-dialog__section-title">
{t('ssh.remote.savedConnections')}
</h3>
<div className="ssh-connection-dialog__section-header">
<h3 className="ssh-connection-dialog__section-title">
{t('ssh.remote.savedConnections')}
</h3>
<Input
className="ssh-connection-dialog__search"
value={savedSearch}
onChange={(e) => setSavedSearch(e.target.value)}
placeholder={t('actions.search')}
prefix={<Search size={14} />}
size="small"
/>
</div>
<div className="ssh-connection-dialog__saved-list">
{savedConnections.map((conn) => (
{filteredSavedConnections.map((conn) => (
<div
key={conn.id}
className="ssh-connection-dialog__saved-item"
Expand Down Expand Up @@ -435,21 +476,21 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
{/* SSH Config hosts section */}
{sshConfigHosts.length > 0 && (
<div className="ssh-connection-dialog__section">
<h3 className="ssh-connection-dialog__section-title">
{t('ssh.remote.sshConfigHosts') || 'SSH Config'}
</h3>
<div className="ssh-connection-dialog__section-header">
<h3 className="ssh-connection-dialog__section-title">
{t('ssh.remote.sshConfigHosts') || 'SSH Config'}
</h3>
<Input
className="ssh-connection-dialog__search"
value={configSearch}
onChange={(e) => setConfigSearch(e.target.value)}
placeholder={t('actions.search')}
prefix={<Search size={14} />}
size="small"
/>
</div>
<div className="ssh-connection-dialog__saved-list">
{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) => (
<div
key={configHost.host}
className="ssh-connection-dialog__saved-item ssh-connection-dialog__saved-item--config"
Expand Down
Loading