diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 45a1d5e0..5e90cad5 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -182,11 +182,14 @@ impl Tty7App { _ => None, }; // A saved connection is editable; a one-off `user@host` is not, and - // offering to edit one would open an empty page. + // offering to edit one opened Settings on "Nothing selected" — this + // comment already said that was the thing to avoid, and the code under + // it only checked that the spec's uuid *parsed*. Every connection has + // one. Ask the same question `unsaved_ssh_session` asks. + let profiles = &cx.global::().ssh_profiles; let profile = view .ssh_spec() - .and_then(|s| s.profile_id.clone()) - .and_then(|id| uuid::Uuid::parse_str(&id).ok()); + .and_then(|spec| crate::ui::ssh_connect::saved_profile_of(&spec, profiles)); let theme = cx.theme(); @@ -251,15 +254,28 @@ impl Tty7App { cx.listener(|this, _, window, cx| this.restart_ssh_session(window, cx)), ), ) - .children(profile.map(|id| { - Button::new("ssh-edit-profile") - .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshEditProfile)) + .child(match profile { + // A saved host: the form that opens is the one this connection + // was dialled from, so a wrong hostname or password is fixed + // where it lives. + Some(id) => Button::new("ssh-edit-profile") + .label(t(L10nKey::SshEditProfile)) .ghost() .small() .on_click(cx.listener(move |this, _, window, cx| { this.open_ssh_profile_in_settings(id, window, cx) - })) - })); + })), + // A one-off: there is no host to open, but the address that + // failed is worth keeping — the form opens prefilled from the + // live spec, which is where the typo gets corrected (#438). + None => Button::new("ssh-save-as-host") + .label(t(L10nKey::SshSaveAsHost)) + .ghost() + .small() + .on_click(cx.listener(move |this, _, window, cx| { + this.save_ssh_session_as_host(window, cx) + })), + }); Some( div() .absolute() diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 3081f00b..d9ce8460 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -917,6 +917,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ForwardPanelTitle => "Forwards", L10nKey::ForwardDisconnected => "Disconnected", L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}", + L10nKey::SshSaveAsHost => "Save as Host…", L10nKey::SshEditProfile => "Edit connection…", L10nKey::ForwardTooltipAdd => "Add forward", L10nKey::ForwardTooltipTurnOff => "Switch off — keeps the rule", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index f48a41f2..f36111df 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -967,6 +967,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardPanelTitle => "ポートフォワード", L10nKey::ForwardDisconnected => "切断済み", L10nKey::ForwardDisconnectedFrom => "{host} から切断されました", + L10nKey::SshSaveAsHost => "ホストとして保存…", L10nKey::SshEditProfile => "接続を編集…", L10nKey::ForwardTooltipAdd => "フォワードを追加", L10nKey::ForwardTooltipTurnOff => "無効にする(ルールは残す)", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 7d6951e3..e7b546a9 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -678,6 +678,7 @@ l10n_keys! { ForwardDisconnected, ForwardDisconnectedFrom, SshEditProfile, + SshSaveAsHost, ForwardTooltipAdd, ForwardTooltipRemove, ForwardTooltipTurnOff, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 512b7e58..f72411ce 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -871,6 +871,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardPanelTitle => "端口转发", L10nKey::ForwardDisconnected => "已断开", L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开", + L10nKey::SshSaveAsHost => "保存为主机…", L10nKey::SshEditProfile => "编辑连接…", L10nKey::ForwardTooltipAdd => "添加转发", L10nKey::ForwardTooltipTurnOff => "停用(保留规则)", diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 6e7c5dcf..7ac11084 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -155,15 +155,7 @@ impl Tty7App { ) -> Option> { let spec = self.focused_pane_view(window, cx)?.read(cx).ssh_spec()?; let profiles = &cx.global::().ssh_profiles; - // A transient profile is handed a fresh uuid on its way to the daemon, - // so an id alone does not mean a host was saved — only one that still - // resolves does. - let saved = spec - .profile_id - .as_deref() - .and_then(|s| uuid::Uuid::parse_str(s).ok()) - .is_some_and(|id| profiles.iter().any(|p| p.id == id)); - (!saved).then_some(spec) + saved_profile_of(&spec, profiles).is_none().then_some(spec) } /// Keep the connection in front of you: the form opens on everything the @@ -244,6 +236,21 @@ impl Tty7App { } } +/// The saved host a live connection came from, if it still exists. +/// +/// A transient profile is handed a fresh uuid on its way to the daemon, so +/// *every* spec carries a `profile_id` and an id alone does not mean anything +/// was saved. Only one that still resolves does — and the two places that ask +/// (whether to offer Save Connection as Host, and whether the failure strip's +/// button edits a host or offers to keep one) have to agree, or an ad-hoc +/// connection gets an Edit button that opens Settings on nothing. +pub(crate) fn saved_profile_of(spec: &NativeSshSpec, profiles: &[SshProfile]) -> Option { + spec.profile_id + .as_deref() + .and_then(|s| Uuid::parse_str(s).ok()) + .filter(|id| profiles.iter().any(|p| p.id == *id)) +} + /// Saved hosts, most likely first: whatever has been connected to often and /// recently, then alphabetically for everything nobody has used yet. Shared by /// the palette and the new-tab menu so the same host leads both lists. @@ -659,6 +666,34 @@ mod tests { assert_eq!(spec.password, None); } + /// Both the palette (Save Connection as Host) and the failure strip (Edit + /// connection… vs Save as Host…) branch on this. They used to answer it + /// separately, and the strip's copy of the test only checked that the uuid + /// parsed — which every connection's does — so an ad-hoc `user@host` wore + /// an Edit button that opened Settings on "Nothing selected". + #[test] + fn only_a_uuid_that_still_names_a_host_counts_as_saved() { + let store = InMemoryCredentialStore::new(); + let saved = profile("prod-web", "10.0.0.5", "deploy"); + let profiles = vec![saved.clone()]; + + let spec = build_native_ssh_spec(&saved, &profiles, &store, true); + assert_eq!(saved_profile_of(&spec, &profiles), Some(saved.id)); + + // What `quick_connect` and the palette's address box build: a profile + // that never reached the config, carrying a uuid of its own. + let adhoc = profile("", "nosuchhost.invalid", "root"); + let spec = build_native_ssh_spec(&adhoc, &profiles, &store, true); + assert!( + spec.profile_id.is_some(), + "the spec does carry an id — that is exactly why the id alone is not the test" + ); + assert_eq!(saved_profile_of(&spec, &profiles), None); + + // And a host that was saved and has since been deleted. + assert_eq!(saved_profile_of(&spec, &[]), None); + } + /// The pane wears this until the remote shell titles itself, so a host /// nobody bothered to name still says where it went (#438). Every host /// imported from `~/.ssh/config` used to arrive nameless, and every one diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index b30c3ef7..4a7b5b11 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2313,8 +2313,18 @@ impl Tty7App { .child(host_label), ) .when(!when_path.is_empty(), |line| { + // `flex_1`, not a bare `min_w_0`: with an auto + // basis this box takes its content width as its + // starting size and then absorbs every pixel + // the row is over — which collapsed it to its + // zero minimum and left a lone "…" where the + // timestamp goes. Rows with *less* to say lost + // more of it: `java-box · …` beside + // `local · ~/repo/025/tty7 ·…`. A zero basis + // that grows into what is left ellipsizes only + // what genuinely does not fit. line.child(div().flex_shrink_0().child("·")) - .child(div().min_w_0().truncate().child(when_path)) + .child(div().flex_1().min_w_0().truncate().child(when_path)) }), ), )