fix(ssh): give a failed one-off connection a way forward, and stop a switcher row eliding to a lone ellipsis

Edit connection… on an ad-hoc `user@host` opened Settings on "Nothing
selected". The comment beside it already said a one-off is not editable and
that offering it would open an empty page; the code under that comment only
checked that the spec's uuid *parsed*, and every connection carries one — a
transient profile is handed a fresh uuid on its way to the daemon. Both places
that ask this question now ask it the same way, through `saved_profile_of`.

A one-off that failed gets Save as Host… instead: the form opens prefilled from
the address that failed, which is where a mistyped hostname actually gets
corrected (#438). A saved host keeps Edit connection… and still opens its own
profile.

The switcher's second line put the workspace's timestamp in a box with an auto
flex basis, so it started at its content width and then absorbed every pixel
the row was over — collapsing to its zero minimum and leaving a bare "…" where
the time goes. Rows with *less* to say lost more of it: `java-box · …` sat
beside `local · ~/repo/025/tty7 ·…`. A zero basis that grows into what is left
ellipsizes only what genuinely does not fit.
This commit is contained in:
l0ng-ai
2026-08-15 01:15:53 +08:00
parent bb1b78add9
commit 24cee113cf
7 changed files with 83 additions and 18 deletions
+24 -8
View File
@@ -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::<crate::core::config::Config>().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()
+1
View File
@@ -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",
+1
View File
@@ -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 => "無効にする(ルールは残す)",
+1
View File
@@ -678,6 +678,7 @@ l10n_keys! {
ForwardDisconnected,
ForwardDisconnectedFrom,
SshEditProfile,
SshSaveAsHost,
ForwardTooltipAdd,
ForwardTooltipRemove,
ForwardTooltipTurnOff,
+1
View File
@@ -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 => "停用(保留规则)",
+44 -9
View File
@@ -155,15 +155,7 @@ impl Tty7App {
) -> Option<Box<NativeSshSpec>> {
let spec = self.focused_pane_view(window, cx)?.read(cx).ssh_spec()?;
let profiles = &cx.global::<Config>().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<Uuid> {
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
+11 -1
View File
@@ -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))
}),
),
)