feat(ssh): keep saved hosts in servers.json and add an SSH tab title setting (#952)

Saved SSH hosts and their usage counts move out of config.json into
servers.json beside it, so config.json can be synced between machines
without carrying a server list (#911). An older config.json is split on
first load: servers.json is written first (0600), then only the two keys
are removed from config.json, leaving every other key as it was. When
both files hold hosts, servers.json wins and the stale copy falls out of
config.json at its next save. A servers.json that does not parse is kept
aside and blocks saves, as config.json does; hand edits hot-reload.

Settings -> Window & Tabs -> SSH tab title (`ssh_tab_title`) pins an SSH
tab to the profile name (saved host name, ~/.ssh/config alias, or the
address typed for a quick connect) or the hostname, on the OSC title's
rung of the existing label ladder: a renamed tab still wins, OSC titles
are still tracked, local panes are untouched (#726).
This commit is contained in:
l0ng-ai
2026-09-25 16:34:38 +08:00
committed by GitHub
parent 02d8611e33
commit de15f9b0ab
17 changed files with 852 additions and 41 deletions
+25
View File
@@ -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
+473 -16
View File
@@ -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<String, String>,
#[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<crate::core::ssh_profile::SshProfile>,
#[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<uuid::Uuid, ProfileUsage>,
#[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<crate::core::ssh_profile::SshProfile>,
ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
}
#[derive(Serialize)]
struct ServersFileRef<'a> {
ssh_profiles: &'a [crate::core::ssh_profile::SshProfile],
ssh_profile_frecency: &'a HashMap<uuid::Uuid, ProfileUsage>,
}
/// 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::de::IgnoredAny>,
#[serde(default)]
ssh_profile_frecency: Option<serde::de::IgnoredAny>,
}
fn has_legacy_server_keys(text: &str) -> bool {
serde_json::from_str::<LegacyServerKeys>(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<String>) {
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::<Config>(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::<ServersFile>(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::<ServersFile>(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<PathBuf> {
@@ -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);
}
}
}
+6 -1
View File
@@ -31,7 +31,8 @@ section something is in.
Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
Tab position and placement, sidebar grouping, and diff previews.
Tab position and placement, sidebar grouping, diff previews, and what SSH
tabs are called.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
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.
+22 -4
View File
@@ -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.
<Note>
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
+2 -1
View File
@@ -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 | `<config>/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 | `<config>/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. |
+4 -2
View File
@@ -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
+6
View File
@@ -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
+7 -4
View File
@@ -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(());
}
+30 -11
View File
@@ -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<String> {
let pinned = self.ssh_spec.as_deref().and_then(|spec| {
let cfg = cx.try_global::<Config>()?;
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);
}
+13 -1
View File
@@ -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>,
) {
self.update_config(cx, |cfg| cfg.ssh_tab_title = mode);
}
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<Config>().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)
}
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+6
View File
@@ -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,
+10
View File
@@ -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"
}
+39 -1
View File
@@ -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))
+111
View File
@@ -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<String> {
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")
);
}
}
+78
View File
@@ -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::<Config>().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::<Config>().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)]