diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 66f1f775..5f73eaab 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -873,6 +873,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "You already know this host by a {previous_algorithm} key. This is a new \ {algorithm} key, not a replacement for that one." } + L10nKey::SshPromptTypeYesToOverride => "Type \"yes\" to enable Override.", L10nKey::EditorCantOpen => "Could not open {path}: {e}", L10nKey::EditorCantRead => "Could not read {path}: {e}", L10nKey::EditorNotUtf8 => "\"{path}\" is not valid UTF-8", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a4316705..1e7147fa 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -914,6 +914,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SshPromptHostKeyNewAlgorithm => { "このホストはすでに {previous_algorithm} キーで登録されています。これはそれを置き換えるものではなく、新しい {algorithm} キーです" } + L10nKey::SshPromptTypeYesToOverride => "「yes」を入力すると「上書き」が有効になります", L10nKey::EditorCantOpen => "{path} を開けません: {e}", L10nKey::EditorCantRead => "{path} を読み取れません: {e}", L10nKey::EditorNotUtf8 => "「{path}」は有効な UTF-8 ではありません", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index d97932f6..eef84be1 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -670,6 +670,7 @@ l10n_keys! { SshPromptNewKey, SshPromptOldKey, SshPromptHostKeyNewAlgorithm, + SshPromptTypeYesToOverride, EditorCantOpen, EditorCantRead, EditorNotUtf8, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 0bbd80c1..a08c6b80 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -835,6 +835,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SshPromptHostKeyNewAlgorithm => { "你已经通过一把 {previous_algorithm} 密钥认识这台主机。这是一把新的 {algorithm} 密钥,并不是用来替换那一把的。" } + L10nKey::SshPromptTypeYesToOverride => "输入 yes 才能启用“覆盖”。", L10nKey::EditorCantOpen => "无法打开 {path}:{e}", L10nKey::EditorCantRead => "无法读取 {path}:{e}", L10nKey::EditorNotUtf8 => "“{path}”不是有效的 UTF-8", diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs index 26c9b50d..ab3a7acf 100644 --- a/src/ui/ssh_prompt.rs +++ b/src/ui/ssh_prompt.rs @@ -5,7 +5,7 @@ use gpui::{ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::checkbox::Checkbox; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; +use gpui_component::{ActiveTheme as _, Disableable as _, Sizable as _, h_flex, v_flex}; use crate::core::keychain::{CredentialStore as _, OsCredentialStore}; use crate::daemon::protocol::{AuthPromptKind, AuthResponse, SshPhase}; @@ -317,10 +317,14 @@ impl Tty7App { subs.push(cx.subscribe_in( input, window, - |this, _input, ev: &InputEvent, window, cx| { - if matches!(ev, InputEvent::PressEnter { .. }) { - this.submit_ssh_prompt(window, cx); - } + |this, _input, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { .. } => this.submit_ssh_prompt(window, cx), + // The changed-host sheet enables Override off the + // typed text, so the flag has to be recomputed + // between keystrokes rather than at whatever + // repaint happened to come along. + InputEvent::Change => cx.notify(), + _ => {} }, )); } @@ -363,10 +367,12 @@ impl Tty7App { subs.push(cx.subscribe_in( input, window, - |this, _input, ev: &InputEvent, window, cx| { - if matches!(ev, InputEvent::PressEnter { .. }) { - this.submit_ssh_prompt(window, cx); - } + |this, _input, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { .. } => this.submit_ssh_prompt(window, cx), + // Same reason as the pane-routed path above: Override's + // enabled state is read off the input on every repaint. + InputEvent::Change => cx.notify(), + _ => {} }, )); } @@ -398,6 +404,18 @@ impl Tty7App { .map(|i| i.read(cx).value().to_string()) .collect(); + // A changed host key is the one prompt where submitting the wrong thing + // is indistinguishable from aborting: the decision it sends for + // anything but "yes" is byte-for-byte what Abort sends, and the sheet + // closed either way. So Enter on a half-typed answer looked like the + // app had swallowed the connection. Leave the sheet up instead; the + // rejection stays available on Abort, where the user meant it. + if let PromptModel::HostKeyChanged { .. } = &model { + if !changed_confirmed(values.first().map(String::as_str).unwrap_or_default()) { + return; + } + } + let (response, write) = match &model { PromptModel::Password { user, @@ -787,8 +805,20 @@ impl Tty7App { old_fingerprint, port, host, - } => card - .child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( + } => { + // Override without "yes" typed used to send the *rejection* — + // the same bytes Abort sends — and close the sheet, so the + // button read as a way through and behaved as a way out. It is + // dead until the word is there, which is what the line above + // the field has been claiming all along. + let typed = self + .ssh_prompt + .inputs + .first() + .map(|i| i.read(cx).value().to_string()) + .unwrap_or_default(); + let can_override = changed_confirmed(&typed); + card.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t( crate::ui::i18n::L10nKey::SshPromptHostKeyChangedBody, ))) .child(div().text_xs().child(format!("{host}:{port} {algorithm}"))) @@ -815,6 +845,18 @@ impl Tty7App { crate::ui::i18n::L10nKey::HostKeyOverrideMessage, ))) .child(self.render_ssh_input(0)) + // Only once they have typed something: an empty field is not a + // mistake to be corrected, it is where everyone starts. + .when(!typed.trim().is_empty() && !can_override, |c| { + c.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(crate::ui::i18n::t( + crate::ui::i18n::L10nKey::SshPromptTypeYesToOverride, + )), + ) + }) .child( h_flex() .justify_end() @@ -826,6 +868,7 @@ impl Tty7App { Button::new("ssh-hkc-override") .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override)) .small() + .disabled(!can_override) .on_click(cx.listener(|this, _, window, cx| { this.submit_ssh_prompt(window, cx) })), @@ -839,7 +882,8 @@ impl Tty7App { this.cancel_ssh_prompt(window, cx) })), ), - ), + ) + } }; card.into_any_element() @@ -1046,6 +1090,29 @@ mod tests { ); } + /// `changed_confirmed` is also what enables the Override button, so the + /// button and the decision can never disagree about what "yes" means — the + /// bug was a button that offered a way through and sent the rejection. + #[test] + fn override_is_enabled_by_exactly_what_accepts() { + for typed in ["", " ", "y", "no", "yesss"] { + assert!( + !changed_confirmed(typed), + "{typed:?} must leave Override disabled" + ); + assert_eq!( + host_key_changed_decision(typed), + AuthResponse::HostKeyDecision { + accept: false, + remember: false + } + ); + } + for typed in ["yes", "YES", " yes "] { + assert!(changed_confirmed(typed), "{typed:?} must enable Override"); + } + } + /// The host is known, just not by this algorithm — the mild confirmation, /// carrying the algorithm it *is* known by, and never the danger sheet. #[test]