diff --git a/src/ui/settings.rs b/src/ui/settings.rs index c83e8986..d5b1dada 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -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::().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(); diff --git a/src/ui/settings/hosts.rs b/src/ui/settings/hosts.rs index d52c62f0..612b9d23 100644 --- a/src/ui/settings/hosts.rs +++ b/src/ui/settings/hosts.rs @@ -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| { + 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() diff --git a/src/ui/settings/kit.rs b/src/ui/settings/kit.rs index 55d0b2d7..57fe525d 100644 --- a/src/ui/settings/kit.rs +++ b/src/ui/settings/kit.rs @@ -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, + focused: bool, + tk: &Tk, + cx: &App, +) -> Div { + field_box(input, focused, false, true, tk, cx) +} + +fn field_box( + input: &gpui::Entity, + 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()), ), ) }