diff --git a/CHANGELOG.md b/CHANGELOG.md index d870f6f5..2086e6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **SSH tabs can be named after the host instead of whatever the remote shell + titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is + *Dynamic* (the default, and what tty7 always did), *Profile name* — the saved + host's name, the alias for a `~/.ssh/config` host, the address typed for a + quick connect — or *Hostname*, the address dialled. Only the tab's name is + pinned: OSC 0/2 titles are still tracked and come back the moment it is + *Dynamic* again, a tab you renamed keeps its name, a split tab follows the + pane in front as before, and an ended session still says so. The key is + `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`). + +### Changed + +- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so + `config.json` can be synced between machines for its colours and keys + without carrying a list of servers. `ssh_profiles` and + `ssh_profile_frecency` move across by themselves the first time a new build + reads an older `config.json`: `servers.json` is written first (mode `0600`), + and only once it has landed are the two keys taken out of `config.json` — + nothing else in that file is touched, including keys this build does not + know. When both files hold hosts, `servers.json` wins and the stale copy in + `config.json` is dropped at its next save. A `servers.json` that cannot be + parsed is kept aside as `servers.json.corrupt` and saving is refused until it + is repaired, the same rule `config.json` has. Hand edits to `servers.json` + hot-reload. Passwords and passphrases stay in the OS keychain. + - **`tty7 exec` runs a command in a pane that already exists and hands back its result** (#839). `run` makes a new pane and `send` does not wait, so every script that wanted a command's exit code from a shell it already had ended up diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 07bc28b0..456c7700 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -213,6 +213,11 @@ pub struct Config { pub new_tab_position: NewTabPosition, #[serde(default, deserialize_with = "de_lenient")] pub tab_bar_position: TabBarPosition, + /// What an SSH pane's tab is called (#726). Only the name the tab shows: + /// the titles the remote side sets are still read and kept, and win again + /// the moment this is back on [`SshTabTitle::Dynamic`]. + #[serde(default, deserialize_with = "de_lenient")] + pub ssh_tab_title: SshTabTitle, #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, #[serde(default)] @@ -383,13 +388,21 @@ pub struct Config { #[serde(default)] pub env: HashMap, - #[serde(default)] + /// The saved SSH hosts. They live in [`SERVERS_FILE`], not in + /// `config.json` (#911): people sync `config.json` between machines for its + /// colours and keys, and a list of servers is not something every machine + /// should carry. Still read from `config.json` so [`Config::load`] can + /// move an older file's hosts across, never written back to it. + #[serde(default, skip_serializing)] pub ssh_profiles: Vec, #[serde(default = "default_true")] pub verify_host_keys: bool, #[serde(default)] pub ssh_warn_on_close: bool, - #[serde(default)] + /// How often each saved host is used, keyed by its id — which means + /// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`] + /// beside them. + #[serde(default, skip_serializing)] pub ssh_profile_frecency: HashMap, #[serde(default)] @@ -418,6 +431,45 @@ pub struct Config { /// permanent data loss (#537). Cleared only by a load that parses. #[serde(skip)] pub quarantined: bool, + /// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts + /// in hand are not the ones in it, so [`Config::save`] refuses to run + /// rather than write them over it — the same rule, for the same reason, + /// as [`Self::quarantined`]. + #[serde(skip)] + pub servers_unreadable: bool, +} + +/// Where the saved SSH hosts live, beside `config.json` (#911). +pub const SERVERS_FILE: &str = "servers.json"; + +/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to +/// carry, so a hand-moved block pastes straight across. +#[derive(Debug, Default, PartialEq, Deserialize)] +#[serde(default)] +struct ServersFile { + ssh_profiles: Vec, + ssh_profile_frecency: HashMap, +} + +#[derive(Serialize)] +struct ServersFileRef<'a> { + ssh_profiles: &'a [crate::core::ssh_profile::SshProfile], + ssh_profile_frecency: &'a HashMap, +} + +/// Whether a `config.json` still carries the keys that moved to +/// [`SERVERS_FILE`] — the cue for the one-time move. +#[derive(Deserialize)] +struct LegacyServerKeys { + #[serde(default)] + ssh_profiles: Option, + #[serde(default)] + ssh_profile_frecency: Option, +} + +fn has_legacy_server_keys(text: &str) -> bool { + serde_json::from_str::(strip_bom(text)) + .is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some()) } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -495,6 +547,21 @@ pub enum TabBarPosition { Left, } +/// Where an SSH pane's tab takes its name from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SshTabTitle { + /// Whatever the remote shell or program titles itself, falling back to + /// the host's name until it says anything — what tty7 always did. + #[default] + Dynamic, + /// The saved host's name, the alias for a `~/.ssh/config` host, the + /// address typed for a quick connect. + ProfileName, + /// The address the connection dialled. + Hostname, +} + /// Native window backdrop material for the Windows GUI. Other platforms retain /// the value for config synchronization but do not use it for rendering. /// `Auto` keeps the legacy behavior where theme blur decides between blurred @@ -673,6 +740,7 @@ impl Default for Config { scrollback_limit: 10_000, new_tab_position: NewTabPosition::AfterCurrent, tab_bar_position: TabBarPosition::Left, + ssh_tab_title: SshTabTitle::Dynamic, sidebar_width: default_sidebar_width(), sidebar_collapsed: false, right_panel_visible: false, @@ -727,6 +795,7 @@ impl Default for Config { restore_agent_sessions: true, per_pane_history: false, quarantined: false, + servers_unreadable: false, } } } @@ -773,13 +842,42 @@ impl Config { /// app onto the result needs the outcome to keep a broken file from /// evicting the settings the app is running on. pub fn load_with_outcome() -> (Self, LoadOutcome) { - let Some(path) = Self::path() else { - return (Config::default(), LoadOutcome::Absent); - }; - let text = match std::fs::read_to_string(&path) { + match Self::path() { + Some(path) => Self::load_from(&path), + None => (Config::default(), LoadOutcome::Absent), + } + } + + /// [`Config::load_with_outcome`] for the `config.json` at `path`, with the + /// saved hosts read from the [`SERVERS_FILE`] beside it. + /// + /// A `config.json` from before the split still holds the hosts itself. + /// When there is no [`SERVERS_FILE`] yet they are moved into one, once: + /// the new file is written first and `config.json` rewritten without them + /// only after that has landed, so a failure anywhere leaves them where + /// they were. When both files are there the new one wins and the copy in + /// `config.json` is not read — it is what an older tty7 wrote after the + /// move, or a synced file brought in, and it falls out of `config.json` + /// at its next save. + pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) { + let (mut cfg, outcome, text) = Self::load_config_file(path); + let servers = path.with_file_name(SERVERS_FILE); + let found = cfg.load_servers(&servers); + if !found + && let Some(text) = text + && has_legacy_server_keys(&text) + { + cfg.move_servers_out(&text, path, &servers); + } + (cfg, outcome) + } + + /// `config.json` alone, with the text it parsed from. + fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option) { + let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return (Config::default(), LoadOutcome::Absent); + return (Config::default(), LoadOutcome::Absent, None); } Err(e) => { // Unreadable is not unparseable, but the rule is the same: @@ -792,13 +890,13 @@ impl Config { ); let mut cfg = Config::default(); cfg.quarantined = true; - return (cfg, LoadOutcome::Unreadable); + return (cfg, LoadOutcome::Unreadable, None); } }; match serde_json::from_str::(strip_bom(&text)) { Ok(mut cfg) => { cfg.sanitize(); - (cfg, LoadOutcome::Parsed) + (cfg, LoadOutcome::Parsed, Some(text)) } Err(e) => { // The next `save` overwrites this file wholesale, so handing @@ -810,14 +908,83 @@ impl Config { "failed to parse config at {}: {e}; keeping it aside and using defaults", path.display() ); - quarantine(&path); + quarantine(path); let mut cfg = Config::default(); cfg.quarantined = true; - (cfg, LoadOutcome::Quarantined) + (cfg, LoadOutcome::Quarantined, None) } } } + /// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one. + /// Returns whether there was — a file that is there but broken counts, so + /// that nothing is moved over the top of it. + fn load_servers(&mut self, path: &Path) -> bool { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false, + Err(e) => { + log::warn!( + "failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed", + path.display() + ); + self.servers_unreadable = true; + return true; + } + }; + match serde_json::from_str::(strip_bom(&text)) { + Ok(servers) => { + self.ssh_profiles = servers.ssh_profiles; + self.ssh_profile_frecency = servers.ssh_profile_frecency; + } + Err(e) => { + log::warn!( + "failed to parse {}: {e}; keeping it aside, writes suppressed", + path.display() + ); + quarantine(path); + self.servers_unreadable = true; + } + } + true + } + + /// The one-time move of the hosts out of `config.json` (#911). New file + /// first: until it has landed, `config.json` is the only copy. + /// + /// `config.json` is then rewritten from its own `text` with just those + /// two keys taken out, not from `self` the way a save writes it. Nobody + /// asked for this write, so it should change nothing else: a hand-kept + /// file stays the handful of keys it was rather than growing every + /// default, and a key this build does not know — one a newer tty7 on a + /// synced machine wrote — survives. + fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) { + if let Err(e) = self.write_servers(servers) { + log::warn!( + "could not move SSH hosts to {}: {e}; leaving them in config.json", + servers.display() + ); + return; + } + let rewrite = || -> std::io::Result<()> { + let mut value: serde_json::Value = + serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?; + if let Some(object) = value.as_object_mut() { + object.remove("ssh_profiles"); + object.remove("ssh_profile_frecency"); + } + let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?; + write_atomic(config, text.as_bytes()) + }; + match rewrite() { + Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()), + Err(e) => log::warn!( + "moved SSH hosts to {}, but could not rewrite config.json: {e}", + servers.display() + ), + } + } + /// What a clicked file link should open in, with the pre-setting default /// filled in for a `Config` that never went through [`Self::sanitize`]. pub fn file_open_mode(&self) -> LinkFileOpen { @@ -905,18 +1072,62 @@ impl Config { /// Persist without hiding a failure from an interactive settings editor. pub fn try_save(&self) -> std::io::Result<()> { + let path = Self::path() + .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + self.try_save_to(&path) + } + + /// [`Config::try_save`] for the `config.json` at `path`, and the + /// [`SERVERS_FILE`] beside it. + pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> { if self.quarantined { return Err(std::io::Error::other( "the existing configuration could not be read; repair it before saving", )); } - let path = Self::path() - .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + if self.servers_unreadable { + return Err(std::io::Error::other(format!( + "{SERVERS_FILE} could not be read; repair it before saving" + ))); + } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + // Hosts first, for the same reason the move out of `config.json` + // writes them first: a `config.json` that lands without them while + // their own file failed to would have dropped them. + self.write_servers(&path.with_file_name(SERVERS_FILE))?; + self.write_config(path) + } + + fn write_config(&self, path: &Path) -> std::io::Result<()> { let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - write_atomic(&path, text.as_bytes()) + write_atomic(path, text.as_bytes()) + } + + /// Writes the hosts to their own file — unless it already says exactly + /// this, which is nearly every save: a dragged divider has nothing to do + /// with the hosts, and rewriting the file anyway would wake the watcher a + /// second time. No hosts and no file stays no file. + fn write_servers(&self, path: &Path) -> std::io::Result<()> { + let on_disk = match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str::(strip_bom(&text)).ok(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()), + Err(_) => None, + }; + if on_disk.as_ref().is_some_and(|held| { + held.ssh_profiles == self.ssh_profiles + && held.ssh_profile_frecency == self.ssh_profile_frecency + }) { + return Ok(()); + } + let text = serde_json::to_string_pretty(&ServersFileRef { + ssh_profiles: &self.ssh_profiles, + ssh_profile_frecency: &self.ssh_profile_frecency, + }) + .map_err(std::io::Error::other)?; + // Host names, users and key paths: nobody else's business. + write_atomic_private(path, text.as_bytes()) } fn path() -> Option { @@ -1436,8 +1647,12 @@ mod tests { last_used: 42, }, ); - let json = serde_json::to_string(&cfg).unwrap(); - let back: Config = serde_json::from_str(&json).unwrap(); + // The count travels in the servers file now (#911), so the round + // trip is through disk rather than through `config.json`'s serde. + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + cfg.try_save_to(&config).unwrap(); + let (back, _) = Config::load_from(&config); assert!(back.ssh_warn_on_close); assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } @@ -2559,4 +2774,246 @@ mod tests { std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap(); assert_eq!(portable_data_dir(exe_dir.path()), None); } + + fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile { + let mut profile = crate::core::ssh_profile::SshProfile::new(name); + profile.host = address.to_string(); + profile.user = "deploy".to_string(); + profile + } + + /// A `config.json` from before #911, holding hosts, a usage count, a key + /// this build has never heard of, and the handful of settings the user + /// actually wrote. + fn legacy_config(profile: &crate::core::ssh_profile::SshProfile) -> String { + let usage = serde_json::json!({ profile.id.to_string(): { "count": 3, "last_used": 5 } }); + serde_json::to_string_pretty(&serde_json::json!({ + "font_size": 18.0, + "a_key_from_a_newer_build": { "x": 1 }, + "ssh_profiles": [profile], + "ssh_profile_frecency": usage, + "keybindings": { "SplitRight": "cmd-d" }, + })) + .unwrap() + } + + #[test] + fn the_hosts_move_out_of_config_json_once_and_nothing_else_moves() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed); + assert_eq!(cfg.ssh_profiles, vec![profile.clone()]); + assert_eq!(cfg.ssh_profile_frecency[&profile.id].count, 3); + assert_eq!(cfg.font_size, 18.0); + + let moved: ServersFile = + serde_json::from_str(&std::fs::read_to_string(&servers).unwrap()).unwrap(); + assert_eq!(moved.ssh_profiles, vec![profile.clone()]); + assert_eq!(moved.ssh_profile_frecency[&profile.id].count, 3); + + let left: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + let left = left.as_object().unwrap(); + assert!(!left.contains_key("ssh_profiles")); + assert!(!left.contains_key("ssh_profile_frecency")); + // The rest of the file is exactly what it was: nothing the user did + // not write appears, and nothing this build cannot read disappears. + assert_eq!(left["font_size"], serde_json::json!(18.0)); + assert_eq!( + left["a_key_from_a_newer_build"], + serde_json::json!({ "x": 1 }) + ); + assert_eq!( + left["keybindings"], + serde_json::json!({ "SplitRight": "cmd-d" }) + ); + assert_eq!(left.len(), 3, "the move must not expand the file: {left:?}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&servers).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // Once is once: a second load reads the new file and writes nothing. + let config_after = std::fs::read(&config).unwrap(); + let servers_after = std::fs::read(&servers).unwrap(); + let (again, _) = Config::load_from(&config); + assert_eq!(again.ssh_profiles, vec![profile]); + assert_eq!(std::fs::read(&config).unwrap(), config_after); + assert_eq!(std::fs::read(&servers).unwrap(), servers_after); + } + + #[test] + fn with_both_files_present_the_servers_file_wins() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let current = host("current", "10.0.0.1"); + let stale = host("stale", "10.0.0.2"); + let cfg = Config { + ssh_profiles: vec![current.clone()], + ..Config::default() + }; + cfg.try_save_to(&config).unwrap(); + // An older tty7, or a synced file, puts hosts back in config.json. + std::fs::write(&config, legacy_config(&stale)).unwrap(); + let servers_before = std::fs::read(&servers).unwrap(); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![current.clone()]); + assert!(loaded.ssh_profile_frecency.is_empty()); + assert_eq!(std::fs::read(&servers).unwrap(), servers_before); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&stale), + "loading is not the moment to rewrite config.json" + ); + + // The stale copy falls out at the next ordinary save. + loaded.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert_eq!(Config::load_from(&config).0.ssh_profiles, vec![current]); + } + + #[test] + fn a_config_json_without_hosts_is_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18}").unwrap(); + + let (cfg, _) = Config::load_from(&config); + assert!(cfg.ssh_profiles.is_empty()); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "{\"font_size\": 18}" + ); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn an_empty_hosts_key_is_dropped_without_making_a_servers_file() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18, \"ssh_profiles\": []}").unwrap(); + + Config::load_from(&config); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert!(text.contains("font_size"), "{text}"); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn saving_writes_the_hosts_to_their_own_file_and_nowhere_else() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + + // No hosts, no file. + Config::default().try_save_to(&config).unwrap(); + assert!(!servers.exists()); + + let profile = host("prod-web", "10.0.0.5"); + let mut cfg = Config { + ssh_profiles: vec![profile.clone()], + ..Config::default() + }; + cfg.ssh_profile_frecency.insert( + profile.id, + ProfileUsage { + count: 2, + last_used: 9, + }, + ); + cfg.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profile"), "{text}"); + assert!(!text.contains("10.0.0.5"), "{text}"); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![profile]); + assert_eq!(loaded.ssh_profile_frecency, cfg.ssh_profile_frecency); + + // Deleting the last host empties the file rather than leaving it be. + let mut emptied = loaded.clone(); + emptied.ssh_profiles.clear(); + emptied.ssh_profile_frecency.clear(); + emptied.try_save_to(&config).unwrap(); + assert!(Config::load_from(&config).0.ssh_profiles.is_empty()); + } + + #[test] + fn a_broken_servers_file_is_kept_aside_and_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + std::fs::write(&servers, "{ nope").unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed, "config.json itself is fine"); + assert!(cfg.servers_unreadable); + assert_eq!( + std::fs::read_to_string(servers.with_extension("json.corrupt")).unwrap(), + "{ nope" + ); + // Nothing moved on top of it, and nothing saves over either file. + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + assert!(cfg.try_save_to(&config).is_err()); + assert_eq!(std::fs::read_to_string(&servers).unwrap(), "{ nope"); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + } + + #[test] + fn the_hosts_never_serialize_into_config_json_but_an_old_file_still_reads() { + let mut cfg: Config = serde_json::from_str( + r#"{"ssh_profiles":[{"name":"a","host":"h"}],"verify_host_keys":false}"#, + ) + .unwrap(); + assert_eq!(cfg.ssh_profiles.len(), 1); + assert!(!cfg.verify_host_keys); + cfg.ssh_profile_frecency + .insert(cfg.ssh_profiles[0].id, ProfileUsage::default()); + let value = serde_json::to_value(&cfg).unwrap(); + assert!(value.get("ssh_profiles").is_none()); + assert!(value.get("ssh_profile_frecency").is_none()); + // The SSH preferences are settings like any other and stay put. + assert_eq!(value["verify_host_keys"], serde_json::json!(false)); + } + + #[test] + fn ssh_tab_title_defaults_to_dynamic_and_round_trips_leniently() { + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert_eq!(cfg.ssh_tab_title, SshTabTitle::Dynamic); + for (raw, mode) in [ + ("\"dynamic\"", SshTabTitle::Dynamic), + ("\"profile-name\"", SshTabTitle::ProfileName), + ("\"hostname\"", SshTabTitle::Hostname), + ("\"sideways\"", SshTabTitle::Dynamic), + ("7", SshTabTitle::Dynamic), + ] { + let cfg: Config = + serde_json::from_str(&format!("{{\"ssh_tab_title\": {raw}, \"font_size\": 20}}")) + .unwrap(); + assert_eq!(cfg.ssh_tab_title, mode, "{raw}"); + assert_eq!(cfg.font_size, 20.0, "a bad value must not cost the file"); + let back: Config = serde_json::from_value(serde_json::to_value(&cfg).unwrap()).unwrap(); + assert_eq!(back.ssh_tab_title, mode); + } + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 05f96cf9..eb06a642 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -31,7 +31,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar grouping, and diff previews. + Tab position and placement, sidebar grouping, diff previews, and what SSH + tabs are called. Hosts, connection defaults, security, authentication, proxies, and forwarding. @@ -69,6 +70,10 @@ Everything the Settings window writes goes to one file: | macOS / Linux | `~/.config/tty7/config.json` | | Windows | `%APPDATA%\tty7\config.json` | +The one exception is your saved SSH hosts, which live beside it in +`servers.json` — so `config.json` can be synced between machines without +carrying your servers. [servers.json →](/reference/configuration#ssh) + Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state — somewhere else. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index aae869b6..33429065 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -92,6 +92,7 @@ their id from the file name. [More about themes →](/customization/themes) |---|---|---|---| | `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | | `new_tab_position` | enum | `"after-current"` | Or `end`. | +| `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. | | `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. | | `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | | `sidebar_width` | number | `220` | Pixels (100–2000). | @@ -186,10 +187,28 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| -| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | | `verify_host_keys` | bool | `true` | | | `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. | +### servers.json + +The saved hosts are not in `config.json`: they live in `servers.json` in the +same directory, so `config.json` can be synced between machines without taking +your list of servers along. The file is written with mode `0600` and read as +forgivingly as `config.json`; if it cannot be parsed, tty7 keeps a copy at +`servers.json.corrupt` and saves nothing until it is repaired. + +| Key | Type | Default | | +|---|---|---|---| +| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | +| `ssh_profile_frecency` | object | `{}` | How often and how recently each host is used, for ranking. Written by the app. | + +An older `config.json` that still holds `ssh_profiles` is split the first time +a newer tty7 reads it: `servers.json` is written first, and only then are the +two keys removed from `config.json`, leaving the rest of it as it was. If both +files hold hosts, `servers.json` wins, and the copy in `config.json` is dropped +the next time settings are saved. + Each object in `ssh_profiles` can also set: | Key | Type | Default | | @@ -209,9 +228,8 @@ Each object in `ssh_profiles` can also set: ## Keys tty7 manages itself -`ssh_profile_frecency` and `command_frecency` record how often and how recently -you use a profile or command, so the pickers can rank them. They are written by -the app; there is no reason to edit them. +`command_frecency` records how often and how recently you run a command, so the +palette can rank it. It is written by the app; there is no reason to edit it. If the file cannot be parsed, tty7 starts on defaults, keeps your original at diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx index 9b405502..c1de9221 100644 --- a/docs/reference/privacy.mdx +++ b/docs/reference/privacy.mdx @@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies) | | | |---|---| | Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) | -| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk | +| Saved SSH hosts | `/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. | +| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk | | Pane scrollback tails | `/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. | | Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. | diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index 138d5b96..681e8bb2 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -75,8 +75,10 @@ on the tab it was opened on, not on whichever pane is focused. Saving one opens on the whole live connection — its proxy, keys and forwards as well as its address — so the host that lands is the one you were already on. -Passwords and key passphrases go in the **OS keychain**, never in -`config.json` and never on disk in plain text. **Forget Password** in a +Saved hosts are kept in `servers.json`, beside `config.json` rather than in +it, so a `config.json` you sync between machines does not carry them. +Passwords and key passphrases go in the **OS keychain**, never in either file +and never on disk in plain text. **Forget Password** in a profile's menu removes the stored one. Deleting a profile drops its keychain credentials and forgets the remote diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index dea2382a..7a6ef61f 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the list is **Settings → Window & Tabs → New tab position** — *After current* by default, or *At end*. +An SSH tab is named by whatever the remote shell titles itself, like any other. +To keep it on the host instead, set **Settings → Window & Tabs → SSH tab +title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or +the address you typed — or *Hostname*. A tab you renamed keeps its name either +way, and a split tab still follows the pane in front. + Right-click a tab for the rest: rename, split right or down, a new worktree tab, copy the working directory, copy the session id, close · close others · close to the right, and — when a coding agent is running there — mark unread and fork its diff --git a/src/main.rs b/src/main.rs index 76c9559d..7c9f73a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) { if !tty7_core::host::is_content_change(&event.kind) { return; } - let hit = event - .paths - .iter() - .any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p)); + // The saved SSH hosts live beside config.json in a file of their own + // (#911), and a hand edit there has to land the same way. + let hit = event.paths.iter().any(|p| { + p.file_name() == watched_file.file_name() + || p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE)) + || is_theme_file(p) + }); if hit { let _ = tx.try_send(()); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 4d00bd1f..5c810f08 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1766,6 +1766,35 @@ impl TerminalView { stated_title(&self.title) } + /// The title this pane gives its tab: [`Self::stated_title`], unless this + /// is an SSH pane and Settings pins its tab to the host's name instead + /// (#726). The pane's own title is untouched either way — OSC 0/2 keep + /// landing in it, and it is back on the tab the moment the setting is. + /// + /// A pane that has ended still says so: the pinned name takes the same + /// suffix the pane's own title would have. + pub(crate) fn tab_title(&self, cx: &App) -> Option { + let pinned = self.ssh_spec.as_deref().and_then(|spec| { + let cfg = cx.try_global::()?; + crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles) + }); + match pinned { + Some(name) if self.terminal.exited => Some(self.ended_title(&name)), + Some(name) => Some(name), + None => self.stated_title().map(str::to_string), + } + } + + /// `name` with the suffix that says how this pane ended. + fn ended_title(&self, name: &str) -> String { + let key = if self.workspace().is_some() && !self.terminal.child_exited() { + L10nKey::PaneTitleDisconnected + } else { + L10nKey::PaneTitleProcessExited + }; + t_fmt(key, &[("title", name)]) + } + /// Sets how opaque the pane wants this terminal painted; the pane leaf /// calls this every frame while rendering, and the terminal element /// blends its colours toward the window background during paint (see @@ -2249,17 +2278,7 @@ impl TerminalView { self.pending_title = None; // The pane keeps answering to its own name (an SSH pane's // host, #438) — only the state suffix is localized (#602). - self.title = if self.workspace().is_some() && !self.terminal.child_exited() { - t_fmt( - L10nKey::PaneTitleDisconnected, - &[("title", &self.default_title)], - ) - } else { - t_fmt( - L10nKey::PaneTitleProcessExited, - &[("title", &self.default_title)], - ) - }; + self.title = self.ended_title(&self.default_title); if self.terminal.child_exited() { cx.emit(ChildExited); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 51e5b132..791a0162 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -612,7 +612,10 @@ impl Tab { // name tty7 chose for it deliberately, and `stated_title` hands // those up as the title the pane is showing. title: crate::terminal::view::DEFAULT_TITLE.to_string(), - osc_title: leaf.stated_title().map(str::to_string), + // On the title's rung, not a new one above it: a name given to the + // tab still beats an SSH host pinned by Settings (#726), and the + // pinned host beats everything the pane could say about itself. + osc_title: leaf.tab_title(cx), cwd: leaf.cwd().map(|p| p.display().to_string()), agent: leaf.agent(), status: leaf.agent_session().map(|s| s.status), @@ -3461,6 +3464,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on); } + pub(crate) fn set_ssh_tab_title( + &mut self, + mode: crate::core::config::SshTabTitle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.ssh_tab_title = mode); + } + pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context) { let next = match cx.global::().tab_bar_position { TabBarPosition::Top => TabBarPosition::Left, @@ -5819,6 +5830,7 @@ impl Tty7App { L10nKey::SettingsDiffPreviewFromCounts => { self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx) } + L10nKey::SettingsSshTabTitle => self.set_ssh_tab_title(defaults.ssh_tab_title, cx), L10nKey::SettingsNotifyOnCommandFinish => { self.set_notify_mode(defaults.notify_on_command_finish, cx) } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6013f4e6..63778e02 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -620,6 +620,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDiffPreviewFromCountsDesc => { "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } + L10nKey::SettingsSshTabTitle => "SSH tab title", + L10nKey::SettingsSshTabTitleDesc => { + "What an SSH tab is called. Dynamic follows the title the remote side sets; the other two pin the tab to the host. A tab you renamed keeps its name." + } + L10nKey::SettingsSshTabTitleDynamic => "Dynamic", + L10nKey::SettingsSshTabTitleProfileName => "Profile name", + L10nKey::SettingsSshTabTitleHostname => "Hostname", L10nKey::DocumentDock => "Dock beside terminal", L10nKey::DocumentFill => "Fill window", L10nKey::SettingsNotifications => "Notifications", @@ -943,6 +950,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchSidebarGroupingKeywords => { "tabs group repo repository git scratch header sidebar flat folder directory cwd" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "ssh tab title name host hostname profile alias pin fixed osc remote" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "double click word url path select semantic bracket email" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 028e45a5..e49d05a6 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -628,6 +628,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } + L10nKey::SettingsSshTabTitle => "SSH タブのタイトル", + L10nKey::SettingsSshTabTitleDesc => { + "SSH タブに表示する名前。「動的」はリモート側が設定したタイトルに従い、ほかの二つはタブをホストに固定します。名前を変更したタブはその名前のまま" + } + L10nKey::SettingsSshTabTitleDynamic => "動的", + L10nKey::SettingsSshTabTitleProfileName => "プロファイル名", + L10nKey::SettingsSshTabTitleHostname => "ホスト名", L10nKey::DocumentDock => "ターミナルの隣にドック", L10nKey::DocumentFill => "ウィンドウ全体", L10nKey::SettingsNotifications => "通知", @@ -1005,6 +1012,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index b8b60db2..45903235 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -498,6 +498,11 @@ l10n_keys! { SettingsSidebarGroupingDesc, SettingsDiffPreviewFromCounts, SettingsDiffPreviewFromCountsDesc, + SettingsSshTabTitle, + SettingsSshTabTitleDesc, + SettingsSshTabTitleDynamic, + SettingsSshTabTitleProfileName, + SettingsSshTabTitleHostname, DocumentDock, DocumentFill, SettingsNotifications, @@ -685,6 +690,7 @@ l10n_keys! { SettingsSearchScrollbackKeywords, SettingsSearchShowTrayIconKeywords, SettingsSearchSidebarGroupingKeywords, + SettingsSearchSshTabTitleKeywords, SettingsSearchSmartSelectionKeywords, SettingsSearchStartInKeywords, SettingsSearchSyncWithSystemKeywords, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index f9c59eb0..d27a7f7f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -549,6 +549,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } + L10nKey::SettingsSshTabTitle => "SSH 标签页标题", + L10nKey::SettingsSshTabTitleDesc => { + "SSH 标签页显示的名字。“动态”跟随远端设置的标题;另外两项把标签页固定为主机。手动重命名过的标签页保留其名字。" + } + L10nKey::SettingsSshTabTitleDynamic => "动态", + L10nKey::SettingsSshTabTitleProfileName => "配置名称", + L10nKey::SettingsSshTabTitleHostname => "主机名", L10nKey::DocumentDock => "停靠在终端旁", L10nKey::DocumentFill => "铺满窗口", L10nKey::SettingsNotifications => "通知", @@ -896,6 +903,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 6e66e41e..d1361975 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -839,6 +839,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsDiffPreviewFromCounts, keywords: SettingsSearchDiffPreviewFromCountsKeywords, }, + SearchEntry { + section: WindowTabs, + title: SettingsSshTabTitle, + keywords: SettingsSearchSshTabTitleKeywords, + }, SearchEntry { section: General, title: SettingsNotifyOnCommandFinish, @@ -898,6 +903,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => "tab_bar_position", L10nKey::SettingsSidebarGrouping => "sidebar_grouping", L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview", + L10nKey::SettingsSshTabTitle => "ssh_tab_title", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", L10nKey::SettingsNotifyThreshold => "notify_threshold_secs", L10nKey::SettingsTerminalBell => "bell", @@ -1007,6 +1013,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc), L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc), L10nKey::SettingsDiffPreviewFromCounts => t(L10nKey::SettingsDiffPreviewFromCountsDesc), + L10nKey::SettingsSshTabTitle => t(L10nKey::SettingsSshTabTitleDesc), L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc), L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc), L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc), @@ -1050,6 +1057,7 @@ impl SearchEntry { L10nKey::SettingsDiffPreviewFromCounts => { cfg.sidebar_diff_preview != defaults.sidebar_diff_preview } + L10nKey::SettingsSshTabTitle => cfg.ssh_tab_title != defaults.ssh_tab_title, L10nKey::SettingsNotifyOnCommandFinish => { cfg.notify_on_command_finish != defaults.notify_on_command_finish } @@ -7659,6 +7667,11 @@ impl Tty7App { TabBarPosition::Left => 1, }; let sidebar_diff_preview = cfg.sidebar_diff_preview; + let ssh_tab_title_idx = match cfg.ssh_tab_title { + SshTabTitle::Dynamic => 0, + SshTabTitle::ProfileName => 1, + SshTabTitle::Hostname => 2, + }; let sidebar_grouping_idx = match cfg.sidebar_grouping { crate::core::config::SidebarGrouping::Repo => 0, crate::core::config::SidebarGrouping::RepoOrDirectory => 1, @@ -7790,6 +7803,25 @@ impl Tty7App { }, ); + let ssh_tab_title_radio = self.segmented( + "wt-ssh-tab-title", + &[ + t(L10nKey::SettingsSshTabTitleDynamic), + t(L10nKey::SettingsSshTabTitleProfileName), + t(L10nKey::SettingsSshTabTitleHostname), + ], + ssh_tab_title_idx, + cx, + |this, ix, _w, cx| { + let mode = match ix { + 0 => SshTabTitle::Dynamic, + 1 => SshTabTitle::ProfileName, + _ => SshTabTitle::Hostname, + }; + this.set_ssh_tab_title(mode, cx); + }, + ); + v_flex() .when(general, |v| { v.child(self.section_header(t(L10nKey::SettingsWindow), cx)) @@ -7845,6 +7877,12 @@ impl Tty7App { sidebar_diff_switch, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsSshTabTitle), + t(L10nKey::SettingsSshTabTitleDesc), + ssh_tab_title_radio, + cx, + )) }) .when(general, |v| { v.child(self.section_rule(cx)) diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index c780c7bd..533ba60f 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -461,6 +461,38 @@ fn build_spec_inner( } } +/// The name an SSH pane's tab is pinned to under `mode` (#726), or `None` for +/// [`SshTabTitle::Dynamic`], which leaves the tab to the title the pane is +/// showing. +/// +/// Built on the same evidence as the name the pane wears before the remote +/// side speaks — [`NativeSshSpec::display_name`], set above — so the pinned +/// name and that one only ever differ where the saved host has been renamed +/// since the pane dialled: the live name wins there, looked up by +/// `profile_id`. An alias from `~/.ssh/config` and a quick connect are not +/// saved hosts, and their `display_name` is the alias and the address typed. +pub(crate) fn pinned_ssh_title( + mode: crate::core::config::SshTabTitle, + spec: &NativeSshSpec, + profiles: &[SshProfile], +) -> Option { + use crate::core::config::SshTabTitle; + + let non_blank = |s: &str| Some(s.trim().to_string()).filter(|s| !s.is_empty()); + match mode { + SshTabTitle::Dynamic => None, + SshTabTitle::ProfileName => spec + .profile_id + .as_deref() + .and_then(|id| Uuid::parse_str(id).ok()) + .and_then(|id| profiles.iter().find(|p| p.id == id)) + .and_then(|p| non_blank(&p.name)) + .or_else(|| spec.display_name.as_deref().and_then(non_blank)) + .or_else(|| non_blank(&spec.host)), + SshTabTitle::Hostname => non_blank(&spec.host), + } +} + /// What a tab's host row opens when it is taken. /// /// The two halves are not the same form. A saved host is already a record, so @@ -1061,4 +1093,83 @@ mod tests { SshProxy::Http { .. } )); } + + /// #726's three sources under each mode: the saved host's own name, the + /// alias for a `~/.ssh/config` host, and the address for a quick connect; + /// `Hostname` always the address dialled, and `Dynamic` nothing at all. + #[test] + fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() { + use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName}; + let store = InMemoryCredentialStore::new(); + + let mut saved = profile("prod-web", "10.0.0.5", "deploy"); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + let profiles = vec![saved.clone()]; + assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("prod-web") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.5") + ); + // Renamed after it dialled: the tab follows the saved host. + saved.name = "prod-api".into(); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(), + Some("prod-api") + ); + // A saved host nobody named reads as its address. + saved.name = " ".into(); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(), + Some("deploy@10.0.0.5") + ); + + let root = tempfile::tempdir().unwrap(); + let ssh = root.path().join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Host bastion\n HostName 192.168.1.9\n User ops\n", + ) + .unwrap(); + let alias = crate::core::ssh_config::resolve_alias_to_profile_from( + ssh.join("config"), + root.path(), + "bastion", + ) + .unwrap(); + let spec = native_spec_from_transient_profile( + &alias.profile, + alias.proxy_jump, + &store, + true, + &|_| None, + ); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("bastion") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("192.168.1.9") + ); + + // What `quick_connect` builds for an address that is no alias. + let mut typed = SshProfile::new("10.0.0.7"); + typed.host = "10.0.0.7".into(); + typed.user = "root".into(); + let spec = build_native_ssh_spec(&typed, &profiles, &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + } } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index a7d38484..769ea1cc 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -2434,6 +2434,84 @@ mod ssh_host_row_tests { assert_eq!(spec.port, 2222, "a non-default port is part of the address"); }); } + + /// #726 end to end: the setting reaches the name the strip draws, on the + /// title's rung — below a name the user gave the tab, above whatever the + /// remote shell titled itself — and only for an SSH pane. + #[gpui::test] + fn the_ssh_tab_title_setting_pins_only_ssh_tabs_and_only_their_label(cx: &mut TestAppContext) { + use crate::core::config::SshTabTitle; + + set_locale("en"); + let (app, mut vcx) = harness(cx); + let saved = uuid::Uuid::new_v4(); + let set_mode = |cx: &mut gpui::App, mode: SshTabTitle| { + let mut cfg = cx.global::().clone(); + cfg.ssh_tab_title = mode; + cx.set_global(cfg); + }; + + let (_ends, ssh) = app.update_in(&mut vcx, |app, window, cx| { + let mut cfg = cx.global::().clone(); + let mut profile = SshProfile::new("prod-web"); + profile.id = saved; + profile.user = "me".to_string(); + profile.host = "build-box".to_string(); + cfg.ssh_profiles = vec![profile]; + cx.set_global(cfg); + + let (local, a) = quiet_test_pane(1, window, cx); + let (ssh, b) = quiet_test_ssh_pane_of(2, Some(saved), window, cx); + // Both have titled themselves over OSC 0/2 the way a shell does. + local.update(cx, |v, _| v.title = "me@laptop:/work/here".into()); + ssh.update(cx, |v, _| v.title = "me@build-box:/srv/app".into()); + for view in [local, ssh.clone()] { + app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(view)))); + } + app.active = 0; + ((a, b), ssh) + }); + vcx.background_executor.run_until_parked(); + + app.update_in(&mut vcx, |app, window, cx| { + let label = + |app: &crate::ui::app::Tty7App, i: usize, window: &gpui::Window, cx: &gpui::App| { + app.tab_label(&app.tabs[i], i, Some(window), cx) + }; + let dynamic_local = label(app, 0, window, cx); + let dynamic_ssh = label(app, 1, window, cx); + assert_eq!(dynamic_ssh, "/srv/app", "Dynamic is what tty7 always did"); + + set_mode(cx, SshTabTitle::ProfileName); + assert_eq!(label(app, 1, window, cx), "prod-web"); + assert_eq!( + label(app, 0, window, cx), + dynamic_local, + "a local tab is untouched" + ); + assert_eq!( + ssh.read(cx).title, + "me@build-box:/srv/app", + "the remote title is still tracked underneath" + ); + + set_mode(cx, SshTabTitle::Hostname); + assert_eq!(label(app, 1, window, cx), "build-box"); + + // A name the user gave the tab outranks the setting. + app.tabs[1].name = Some("mine".into()); + assert_eq!(label(app, 1, window, cx), "mine"); + app.tabs[1].name = None; + + // An ended session keeps saying so under the pinned name. + ssh.update(cx, |v, _| v.terminal.exited = true); + assert_eq!(label(app, 1, window, cx), "build-box — process exited"); + ssh.update(cx, |v, _| v.terminal.exited = false); + + set_mode(cx, SshTabTitle::Dynamic); + assert_eq!(label(app, 1, window, cx), dynamic_ssh); + }); + } } #[cfg(test)]