fix(settings): put the password and key passphrase back on the SSH host editor (#1130)

* fix(settings): put the password and key passphrase back on the SSH host editor

The rebuilt settings window drew the host form from scratch and left out
the credential rows. Everything behind them survived — the form still
read the keychain on open, counted a typed secret as a change, and
filed it on save — but with no box on screen there was no way to give a
host a password short of connecting and waiting to be asked.

The editor now shows a masked Password row (with a reveal toggle) after
Port, and a Key passphrase row after IdentityFile. Each appears only when
the host's auth method would actually use it, the same split the
connection path makes: Password for Auto/Password, the passphrase for
Auto/PublicKey once IdentityFile names a key that exists on this machine.

* test(settings): the SSH editor's credential rows follow the auth method
This commit is contained in:
l0ng-ai
2026-10-08 18:06:21 +08:00
committed by GitHub
parent ad7efe5889
commit 9b2c571fe1
3 changed files with 99 additions and 1 deletions
+47
View File
@@ -1466,6 +1466,20 @@ impl SshProfileForm {
self.password.read(cx).value().as_ref() != self.loaded_password
|| self.passphrase.read(cx).value().as_ref() != self.loaded_passphrase
}
/// Which credential boxes the method actually uses — the same split
/// `ssh_connect::build_spec_inner` makes when it decides what to hand the
/// daemon. A password box under "Agent" would be a secret that is stored
/// and then never offered.
fn wants_password(&self) -> bool {
matches!(self.auth, AuthMode::Auto | AuthMode::Password)
}
/// A passphrase is filed against a key's contents, so the box only shows
/// once the form names a key that is actually on this machine.
fn wants_passphrase(&self) -> bool {
matches!(self.auth, AuthMode::Auto | AuthMode::PublicKey) && self.loaded_key.is_some()
}
}
/// What saving does to the keychain for the password field: which entry to
@@ -3928,6 +3942,39 @@ mod gpui_tests {
});
}
/// The credential rows follow the auth method the same way connecting
/// does: a password only for methods that offer one, a passphrase never
/// for a method that does not try keys. (Whether Auto / PublicKey show the
/// passphrase depends on the default keys in this machine's `~/.ssh`, so
/// that half is not pinned here.)
#[gpui::test]
fn the_credential_rows_follow_the_auth_method(cx: &mut TestAppContext) {
use crate::core::ssh_profile::{AuthMode, SshProfile};
crate::core::config::pin_test_config_dir();
let (app, mut vcx) = harness(cx);
for (auth, password, keys) in [
(AuthMode::Auto, true, true),
(AuthMode::Password, true, false),
(AuthMode::PublicKey, false, true),
(AuthMode::Agent, false, false),
] {
let mut profile = SshProfile::new("creds");
profile.auth = auth;
let id = profile.id;
app.update_in(&mut vcx, |app, window, cx| {
cx.global_mut::<Config>().ssh_profiles.push(profile);
app.open_ssh_profile_in_settings(id, window, cx);
let form = app.active_settings().unwrap().ssh_form.as_ref().unwrap();
assert_eq!(form.wants_password(), password, "{auth:?}");
if !keys {
assert!(!form.wants_passphrase(), "{auth:?} never tries a key");
}
app.cancel_ssh_form(cx);
});
vcx.run_until_parked();
}
}
#[gpui::test]
fn failed_settings_writes_remain_visible_until_retry_succeeds(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
+29
View File
@@ -721,13 +721,42 @@ impl Tty7App {
.max_w(px(FORM_FIELD_W)),
)
};
// The secrets are not ssh_config keywords, so they are the only two
// labels here that are translated. Neither reaches the config file:
// `save_ssh_form_secrets` files them in the system keychain.
let secret = |label: &'static str, input: &Entity<InputState>| {
let focused = self.settings_input_focused(input, cx);
h_flex()
.items_center()
.child(
div()
.w(px(SSH_LABEL_W))
.flex_shrink_0()
.text_size(fs(12.))
.text_color(tk.k45)
.child(label),
)
.child(
kit::secret_field(input, focused, &tk, cx)
.bg(tk.page)
.text_size(fs(11.5))
.flex_1()
.max_w(px(FORM_FIELD_W)),
)
};
let fields = v_flex()
.gap(px(6.))
.child(field("Alias", &form.name, false))
.child(field("HostName", &form.host, host_error.is_some()))
.child(field("User", &form.user, false))
.child(field("Port", &form.port, errors.port.is_some()))
.when(form.wants_password(), |v| {
v.child(secret(t(L10nKey::SettingsPassword), &form.password))
})
.child(field("IdentityFile", &form.identity_files, false))
.when(form.wants_passphrase(), |v| {
v.child(secret(t(L10nKey::SettingsKeyPassphrase), &form.passphrase))
})
.child(field("ProxyJump", &form.jump, errors.jump.is_some()));
v_flex()
+23 -1
View File
@@ -488,6 +488,27 @@ pub(crate) fn text_field(
invalid: bool,
tk: &Tk,
cx: &App,
) -> Div {
field_box(input, focused, invalid, false, tk, cx)
}
/// A [`text_field`] for a masked input, with the eye that reveals it.
pub(crate) fn secret_field(
input: &gpui::Entity<InputState>,
focused: bool,
tk: &Tk,
cx: &App,
) -> Div {
field_box(input, focused, false, true, tk, cx)
}
fn field_box(
input: &gpui::Entity<InputState>,
focused: bool,
invalid: bool,
reveal: bool,
tk: &Tk,
cx: &App,
) -> Div {
let ring_color = if invalid {
tk.danger
@@ -519,7 +540,8 @@ pub(crate) fn text_field(
// The input's own `text_sm` beat the 12px set on this
// box, so the value stood a size above every dropdown
// label in the same column.
.text_size(fs(12.)),
.text_size(fs(12.))
.when(reveal, |i| i.mask_toggle()),
),
)
}