diff --git a/CHANGELOG.md b/CHANGELOG.md index af5fb409..a0135de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 83, so it stays a loud error that names both spellings (`send %83 --enter`, `send %PANE 83 --enter`) rather than quietly retargeting the keystroke (#581). +- **A `~` in a path now belongs to the machine that path is on.** The Info + panel's cwd, the tab strip and sidebar titles, and the switcher's workspace + and tab rows all shortened against *this* machine's `$HOME` whoever the path + belonged to, so a server sitting in `/home/deploy/app` read as `~/app` on a + laptop that happens to log in as `deploy` and stayed spelled out on one that + does not — the `~` naming the wrong machine either way. Each of those rows + now measures a path against the home its own host reported when the link + came up, and a path on a machine nothing here has a link to — or one a pane's + shell has `ssh`'d away to — is shown in full rather than against a home that + is not its own. (#580) ## [26.8.3] - 2026-08-12 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 2cec8b86..b12be3f0 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1308,6 +1308,21 @@ impl TerminalView { self.remote_context().is_none() && self.host_id.is_local() } + /// The directory a `~` in this pane's paths stands for, for anything that + /// draws one of them shortened. + /// + /// The pane's own host answers, never this machine on its behalf (#580). + /// A shell that has ssh'd somewhere gets no answer at all: the paths it + /// reports are on a third machine tty7 has no link to, and neither this + /// laptop's home nor the pane host's describes it — the same reason #568 + /// stopped resolving file links in those panes. + pub fn display_home(&self, cx: &gpui::App) -> Option { + match self.remote_context().is_some() { + true => None, + false => crate::ui::path_display::home_for_host(cx, self.host_id), + } + } + pub fn spawnable_cwd(&self) -> Option { self.remote_context().is_none().then(|| self.cwd())? } diff --git a/src/ui/app.rs b/src/ui/app.rs index d32ad6b7..599ede5e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -313,7 +313,9 @@ impl Tab { .and_then(|slot| slot.terminal().cloned()) } - pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String { + /// The leaf a tab names itself after — its title and, with it, the home + /// that title's path is measured against. + fn title_leaf(&self, window: Option<&Window>, cx: &App) -> Option> { let leaf = match window { Some(window) => self .pane @@ -322,10 +324,29 @@ impl Tab { None => self.focus_target(), }; leaf.and_then(|l| l.terminal().cloned()) + } + + pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String { + self.title_leaf(window, cx) .map(|l| l.read(cx).title.clone()) .unwrap_or_default() } + /// [`Self::leaf_title`] together with what a `~` in it would mean — one + /// leaf lookup, so the title and the home shortening it can never come + /// from different panes (#580). + pub(crate) fn leaf_title_and_home( + &self, + window: Option<&Window>, + cx: &App, + ) -> (String, Option) { + let Some(leaf) = self.title_leaf(window, cx) else { + return (String::new(), None); + }; + let leaf = leaf.read(cx); + (leaf.title.clone(), leaf.display_home(cx)) + } + pub(crate) fn git_status( &self, window: Option<&Window>, diff --git a/src/ui/home.rs b/src/ui/home.rs index 9df9427b..65a8a0c6 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -102,11 +102,14 @@ pub(crate) fn relative_time(now: u64, then: u64) -> String { } } -pub(crate) fn display_path(path: &std::path::Path) -> String { +/// `home` belongs to the machine the workspace is on — every row here can +/// name a directory on another one, and this machine's home says nothing +/// about those (#580). `None` shows the path in full. +pub(crate) fn display_path(path: &std::path::Path, home: Option<&std::path::Path>) -> String { let text = path.to_string_lossy(); - // Same home-abbreviation the Info panel and tab strip use: HOME with a - // USERPROFILE fallback, separators normalized, case folded (#544). - let shortened = crate::ui::path_display::abbreviate_home(&text).into_owned(); + // Same home-abbreviation the Info panel and tab strip use: separators + // normalized, case folded (#544). + let shortened = crate::ui::path_display::abbreviate_home(&text, home).into_owned(); if shortened.chars().count() <= PICKER_PATH_MAX { return shortened; } @@ -434,18 +437,16 @@ mod tests { #[test] fn display_path_collapses_home_and_elides_from_the_front() { - let saved = std::env::var("HOME").ok(); - unsafe { std::env::set_var("HOME", "/Users/tester") }; + // The home is handed in rather than set in the environment: the row + // being drawn may belong to a workspace on another machine, so the + // caller names the home and nothing here reads `$HOME` (#580). + let home = Some(std::path::Path::new("/Users/tester")); + let shown = |p: &str| display_path(std::path::Path::new(p), home); - assert_eq!( - display_path(std::path::Path::new("/Users/tester/repo/tty7")), - "~/repo/tty7" - ); - assert_eq!(display_path(std::path::Path::new("/opt/work")), "/opt/work"); + assert_eq!(shown("/Users/tester/repo/tty7"), "~/repo/tty7"); + assert_eq!(shown("/opt/work"), "/opt/work"); - let long = display_path(std::path::Path::new( - "/Users/tester/very/deeply/nested/projects/area/thing", - )); + let long = shown("/Users/tester/very/deeply/nested/projects/area/thing"); assert!(long.starts_with('…'), "{long} should be front-elided"); assert!(long.ends_with("thing"), "{long} must keep the tail"); // Snapping to a separator can only shorten what the char budget kept. @@ -453,15 +454,25 @@ mod tests { // A cut that lands mid-name drops the fragment rather than passing it // off as a directory. - let midname = display_path(std::path::Path::new( - "/Users/tester/verylongish/deeply/nested/projects/area/thing", - )); + let midname = shown("/Users/tester/verylongish/deeply/nested/projects/area/thing"); assert_eq!(midname, "…/deeply/nested/projects/area/thing"); + } - match saved { - Some(home) => unsafe { std::env::set_var("HOME", home) }, - None => unsafe { std::env::remove_var("HOME") }, - } + /// A workspace on another machine is elided the same way, but only its + /// own host's home may put a `~` on it (#580). + #[test] + fn display_path_does_not_measure_another_machine_by_this_one() { + let remote = std::path::Path::new("/home/deploy/app"); + assert_eq!( + display_path(remote, Some(std::path::Path::new("/home/deploy"))), + "~/app" + ); + assert_eq!( + display_path(remote, Some(std::path::Path::new("/Users/tester"))), + "/home/deploy/app" + ); + // No link to that host yet, so nothing here knows what `~` is there. + assert_eq!(display_path(remote, None), "/home/deploy/app"); } /// The fragment only goes when the rest of the path can stand without it. diff --git a/src/ui/path_display.rs b/src/ui/path_display.rs index c9f96718..6581822e 100644 --- a/src/ui/path_display.rs +++ b/src/ui/path_display.rs @@ -14,18 +14,47 @@ //! are spelled everywhere, with `/`. Nothing feeds it back to an API: the //! Info panel's Copy Path and Reveal both carry the untouched `PathBuf`, and //! the tab strip and picker only ever draw it. +//! +//! *Which* home a path is measured against is the caller's to say, because +//! only the caller knows which machine the path is on. A pane, a workspace +//! row or a tab title can name a directory on another host, and this +//! machine's `$HOME` answers for nothing over there: `/home/deploy/app` on a +//! server shortened to `~/app` on a laptop that happens to log in as +//! `deploy`, and stayed long on one that does not, so the `~` meant the +//! wrong machine either way (#580). [`home_for_host`] is where that question +//! is answered — the same borrow #568 took out of the file-link resolver. +use crate::ui::host_ops::HostId; +use gpui::App; use std::borrow::Cow; +use std::path::{Path, PathBuf}; -/// The directory `~` stands for, or `None` when this machine won't say. +/// The directory `~` stands for on the machine tty7 is running on, or `None` +/// when it won't say. /// /// `USERPROFILE` is the fallback rather than the only source on Windows so /// the MSYS/Git-Bash environments that do export `HOME` keep working, and /// the two agree in every case that matters. -fn home_dir() -> Option { +pub(crate) fn local_home() -> Option { std::env::var_os("HOME") .filter(|h| !h.is_empty()) .or_else(|| std::env::var_os("USERPROFILE").filter(|h| !h.is_empty())) + .map(PathBuf::from) +} + +/// The directory `~` stands for in a path that lives on `host`. +/// +/// A remote host reports its home during the control handshake and it is +/// kept per host in [`HostLinks`](crate::ui::remote_connect::HostLinks), so +/// this costs a map lookup and never a round trip. `None` means nothing here +/// can say — no link to that host yet — and a path measured against nothing +/// is shown whole, which is the honest answer and the one #568 settled on +/// for the same question about file links. +pub(crate) fn home_for_host(cx: &App, host: HostId) -> Option { + match host.is_local() { + true => local_home(), + false => crate::ui::remote_connect::HostLinks::home(cx, host), + } } /// `/`-spelled, case-folded, trailing separators dropped — the form two @@ -40,24 +69,29 @@ fn normalized(s: &str) -> String { .to_ascii_lowercase() } -/// Shortens `path` to start from `~` when it is (inside) the home directory. +/// Shortens `path` to start from `~` when it is (inside) `home` — the home +/// directory of the machine `path` is on, not of this one. +/// +/// A `None` home is a path this process cannot place: a pane on a host with +/// no link, or one whose shell has ssh'd somewhere tty7 never spoke to. It +/// comes back untouched rather than measured against a home that belongs to +/// somebody else (#580). /// /// The `~` replaces the home prefix and the remainder is re-spelled with /// `/` separators (a `~\work` hybrid reads as a root the path never had), /// but its case and component spelling are the path's own. A path that is /// exactly home shortens to `~`, and one whose next character is not a /// separator (`/home/xavier` under `/home/xa`) does not match at all. -pub(crate) fn abbreviate_home(path: &str) -> Cow<'_, str> { - let Some(home) = home_dir() else { +pub(crate) fn abbreviate_home<'a>(path: &'a str, home: Option<&Path>) -> Cow<'a, str> { + let Some(home) = home else { return Cow::Borrowed(path); }; abbreviate_under(path, &home.to_string_lossy()) } -/// `abbreviate_home` with the home handed in rather than read from the -/// environment, so the tests below pin one without touching a process-global -/// the rest of the binary is also reading (`ui::home`'s own test sets `HOME` -/// and expects to see it, and everything runs in one process). +/// `abbreviate_home` with the home as a plain string, so the tests below can +/// pin one — including a Windows-spelled home on a Unix build, which no +/// `Path` on that platform round-trips. fn abbreviate_under<'a>(path: &'a str, home: &str) -> Cow<'a, str> { let home_norm = normalized(home); if home_norm.is_empty() { @@ -118,6 +152,27 @@ mod tests { assert_eq!(abbreviate_under("C:/Users/xa/Mix\\ed", home), "~/Mix/ed"); } + /// The #580 borrow: a path whose machine is unknown keeps its full + /// spelling instead of being read against this one's home. + #[test] + fn a_path_with_no_home_to_measure_against_is_left_alone() { + let deploy = "/home/deploy/app"; + assert_eq!(abbreviate_home(deploy, None), deploy); + // The same path *does* shorten once the host that owns it has said + // what its home is. + assert_eq!( + abbreviate_home(deploy, Some(Path::new("/home/deploy"))), + "~/app" + ); + // And this machine's home is not offered as a stand-in: a laptop + // that logs in as `deploy` used to shorten a server's path by + // accident, purely because the two names matched. + assert_eq!( + abbreviate_home(deploy, Some(Path::new("/Users/thomas"))), + deploy + ); + } + #[test] fn a_non_ascii_component_is_sliced_on_a_character_boundary() { // The cut is taken from the normalized string; `replace` and the diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index 62e30e96..0478b124 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -464,8 +464,13 @@ impl HostLinks { cx.default_global::().hosts.get(&id).cloned() } - pub fn home(cx: &mut App, id: HostId) -> Option { - cx.default_global::().homes.get(&id).cloned() + /// The home directory the host reported when the link came up — the only + /// thing that can say what a `~` in one of its paths means (#580). Read + /// through `try_global` rather than `default_global` so a caller holding + /// nothing but `&App` (every renderer that draws a path) can ask; an + /// absent table and an empty one are the same answer here. + pub fn home(cx: &App, id: HostId) -> Option { + cx.try_global::()?.homes.get(&id).cloned() } pub fn insert(cx: &mut App, host: Arc, home: PathBuf) { diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 16d33301..f1f3913e 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -642,9 +642,10 @@ impl Tty7App { .map(|cwd| (view.host_id(), cwd.to_path_buf())), ); if let Some(cwd) = view.effective_cwd() { + let home = view.display_home(cx); rows.push(InfoRow { label: t(L10nKey::PanelCwd), - value: InfoValue::Path(compact_path(&cwd)), + value: InfoValue::Path(compact_path(&cwd, home.as_deref())), // The compacted `~/…` spelling is for reading; what // goes on the clipboard is the path a shell can use. copy: Some(cwd.display().to_string()), @@ -1455,8 +1456,11 @@ fn split_path_leaf(s: &str) -> (String, String) { } } -fn compact_path(path: &std::path::Path) -> String { - crate::ui::path_display::abbreviate_home(&path.to_string_lossy()).into_owned() +/// `home` is the home directory of the machine `path` lives on. A remote +/// pane's cwd is measured against *its* host's home, never this machine's +/// (#580) — and against nothing at all while the host has not said. +fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> String { + crate::ui::path_display::abbreviate_home(&path.to_string_lossy(), home).into_owned() } #[cfg(test)] diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 96b4e93b..6ef8876f 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -483,12 +483,17 @@ impl Tty7App { }); groups.len() - 1 }); + // A row's path is on the workspace's own machine, so that is + // the machine whose home may shorten it (#580). + let home = crate::ui::path_display::home_for_host(app, w.host_id()); groups[slot].rows.push(Row { id: w.id, name: crate::ui::machine_mirror::display_name(app, w) .unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string()), path: crate::ui::machine_mirror::subject_path(app, w) - .map(|p| crate::ui::home::display_path(std::path::Path::new(&p))) + .map(|p| { + crate::ui::home::display_path(std::path::Path::new(&p), home.as_deref()) + }) .unwrap_or_default(), when: crate::ui::home::relative_time(now, w.last_active), live: crate::terminal::pane_liveness::liveness_of(app, w), @@ -585,6 +590,9 @@ impl Tty7App { .flat_map(|g| g.rows.iter().map(|r| r.id)) .collect(); let app: &App = cx; + // Unclaimed *local* workspaces: their paths are on this machine, + // so this machine's home is the right one to measure them by. + let local_home = crate::ui::path_display::local_home(); let rows: Vec = crate::ui::machine_mirror::unclaimed_local_workspaces(app) .into_iter() .filter(|ws| !listed.contains(&ws.id)) @@ -593,7 +601,12 @@ impl Tty7App { name: ws.name, path: ws .path - .map(|p| crate::ui::home::display_path(std::path::Path::new(&p))) + .map(|p| { + crate::ui::home::display_path( + std::path::Path::new(&p), + local_home.as_deref(), + ) + }) .unwrap_or_default(), when: crate::ui::home::relative_time(now, ws.last_active), live: match ws.live { @@ -709,8 +722,11 @@ impl Tty7App { .pane .terminals() .first() - .and_then(|leaf| leaf.read(cx).cwd()) - .map(|p| crate::ui::home::display_path(&p)) + .and_then(|leaf| { + let leaf = leaf.read(cx); + Some((leaf.cwd()?, leaf.display_home(cx))) + }) + .map(|(p, home)| crate::ui::home::display_path(&p, home.as_deref())) .unwrap_or_default(), agent: tab.agent(cx), status: tab.agent_status(cx), @@ -738,11 +754,14 @@ impl Tty7App { cx.try_global::()? .status_for(host, std::path::Path::new(cwd)) }; + // These rows describe a workspace on `host`, and the cwds they carry + // are that machine's. Only its home may shorten them (#580). + let home = host.and_then(|host| crate::ui::path_display::home_for_host(cx, host)); views .into_iter() .enumerate() .map(|(i, v)| TabRow { - label: tab_view_label(&v, i), + label: tab_view_label(&v, i, home.as_deref()), // The label only stands in for the path when it came *from* the // path; a name or an agent leaves the location still worth // printing. @@ -750,7 +769,9 @@ impl Tty7App { path: v .cwd .as_deref() - .map(|p| crate::ui::home::display_path(std::path::Path::new(p))) + .map(|p| { + crate::ui::home::display_path(std::path::Path::new(p), home.as_deref()) + }) .unwrap_or_default(), agent: v.agent, status: v.status, @@ -2471,7 +2492,11 @@ impl TabRow { /// carries a copy of that title (`PaneRecord::osc_title`), which is what makes /// the two columns agree; `PaneRecord::title` is the *foreground process name* /// ("zsh") and only stands in when there is no title at all. -fn tab_view_label(view: &crate::ui::machine_mirror::TabView, index: usize) -> String { +fn tab_view_label( + view: &crate::ui::machine_mirror::TabView, + index: usize, + home: Option<&std::path::Path>, +) -> String { let unnamed = || { t_fmt( L10nKey::TabUnnamedShell, @@ -2480,7 +2505,7 @@ fn tab_view_label(view: &crate::ui::machine_mirror::TabView, index: usize) -> St }; // A path can shorten away to nothing (a bare "user@host:"), and the process // name is still worth more than a number. - let shortened = |raw: &str| match crate::ui::tab_strip::short_title(raw) { + let shortened = |raw: &str| match crate::ui::tab_strip::short_title(raw, home) { shortened if !shortened.trim().is_empty() => shortened, _ => match view.title.trim() { "" => unnamed(), @@ -3011,48 +3036,52 @@ mod tests { live: true, panes: 1, }; - assert_eq!(tab_view_label(&view, 0), "build", "a given name wins"); + assert_eq!(tab_view_label(&view, 0, None), "build", "a given name wins"); view.name = None; assert_eq!( - tab_view_label(&view, 0), + tab_view_label(&view, 0, None), "✳ 修复 workspace switcher", "then the title the local strip would be showing, verbatim" ); view.osc_title = Some("user@host:~/repo/025/tty7".to_string()); assert_eq!( - tab_view_label(&view, 0), - crate::ui::tab_strip::short_title("user@host:~/repo/025/tty7"), + tab_view_label(&view, 0, None), + crate::ui::tab_strip::short_title("user@host:~/repo/025/tty7", None), "a shell's title goes through the shortener the strip uses" ); view.osc_title = Some("user@host:".to_string()); assert_eq!( - tab_view_label(&view, 0), + tab_view_label(&view, 0, None), "zsh", "a title that shortens away to nothing falls through" ); view.osc_title = None; assert_eq!( - tab_view_label(&view, 0), + tab_view_label(&view, 0, None), "Claude Code", "an agent names a tab that has told us nothing else" ); view.agent = None; assert_eq!( - tab_view_label(&view, 0), - crate::ui::tab_strip::short_title("/Users/x/repo/tty7"), + tab_view_label(&view, 0, None), + crate::ui::tab_strip::short_title("/Users/x/repo/tty7", None), "otherwise the directory, put through the same shortener as the strip" ); view.cwd = None; - assert_eq!(tab_view_label(&view, 0), "zsh", "process name is last"); + assert_eq!( + tab_view_label(&view, 0, None), + "zsh", + "process name is last" + ); view.title = String::new(); - assert!(tab_view_label(&view, 2).contains('3')); + assert!(tab_view_label(&view, 2, None).contains('3')); } fn refusal(peer: u32, ours: u32) -> String { format!( diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 0429e106..46235d65 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -289,8 +289,9 @@ impl Tty7App { ); (shown, Some(full)) } else { - let raw_title = tab.leaf_title(Some(window), cx); - let raw = abbreviate_home(strip_host_prefix(raw_title.trim())); + let (raw_title, home) = tab.leaf_title_and_home(Some(window), cx); + let title = strip_host_prefix(raw_title.trim()); + let raw = abbreviate_home(title, home.as_deref()); if raw.trim().is_empty() { // Nothing to expand: the row is naming an unnamed // shell, not hiding a title behind an ellipsis. @@ -425,10 +426,14 @@ impl Tty7App { cwd_shown = tab .pane .focused_or_first(window, cx) - .and_then(|leaf| leaf.read(cx).effective_cwd()) - .map(|cwd| { + .and_then(|leaf| { + let leaf = leaf.read(cx); + Some((leaf.effective_cwd()?, leaf.display_home(cx))) + }) + .map(|(cwd, home)| { + let text = cwd.display().to_string(); let full = SharedString::from( - abbreviate_home(&cwd.display().to_string()).into_owned(), + abbreviate_home(&text, home.as_deref()).into_owned(), ); let shown = elide_path_keep_tail( &window.text_system(), diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 7d927694..a77102ad 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -52,15 +52,21 @@ fn shell_spec(shell: &DetectedShell) -> ShellSpec { /// same titles. pub(crate) use tty7_core::core::tab_view::strip_host_prefix; -pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> { +/// `home` is the home directory of the machine `path` is on, from +/// [`Tab::leaf_title_and_home`](crate::ui::app::Tab::leaf_title_and_home) or +/// the workspace's host; `None` leaves the path spelled out (#580). +pub(crate) fn abbreviate_home<'a>( + path: &'a str, + home: Option<&std::path::Path>, +) -> std::borrow::Cow<'a, str> { use std::borrow::Cow; if path.starts_with('~') { return Cow::Borrowed(path); } - // The shared comparison: HOME with a USERPROFILE fallback, separators - // normalized, case folded — a Windows pane whose cwd spells itself - // `C:/Users/…` shortens under a `C:\Users\…` home too (#544). - crate::ui::path_display::abbreviate_home(path) + // The shared comparison: separators normalized, case folded — a Windows + // pane whose cwd spells itself `C:/Users/…` shortens under a + // `C:\Users\…` home too (#544). + crate::ui::path_display::abbreviate_home(path, home) } /// The separator a path spells itself with. A path carrying a single `\` is @@ -75,7 +81,7 @@ fn join_segments(segments: &[&str], sep: char) -> String { segments.join(sep.encode_utf8(&mut [0u8; 4]) as &str) } -pub(crate) fn short_title(raw: &str) -> String { +pub(crate) fn short_title(raw: &str, home: Option<&std::path::Path>) -> String { let raw = raw.trim(); if raw.is_empty() { return String::new(); @@ -85,7 +91,7 @@ pub(crate) fn short_title(raw: &str) -> String { if after_host.is_empty() { return String::new(); } - let abbreviated = abbreviate_home(after_host); + let abbreviated = abbreviate_home(after_host, home); let path: &str = abbreviated.as_ref(); enum Kind { @@ -957,12 +963,14 @@ impl Tty7App { if tab.name.as_ref().is_some_and(|n| !n.trim().is_empty()) { return None; } - let raw = tab.leaf_title(window, cx); + let (raw, home) = tab.leaf_title_and_home(window, cx); let raw = raw.trim(); if raw.is_empty() || raw == self.tab_label(tab, index, window, cx) { return None; } - Some(SharedString::from(abbreviate_home(raw).into_owned())) + Some(SharedString::from( + abbreviate_home(raw, home.as_deref()).into_owned(), + )) } pub(crate) fn tab_label( @@ -978,8 +986,8 @@ impl Tty7App { return trimmed.to_string(); } } - let raw = tab.leaf_title(window, cx); - let label = short_title(&raw); + let (raw, home) = tab.leaf_title_and_home(window, cx); + let label = short_title(&raw, home.as_deref()); if label.trim().is_empty() { t_fmt( L10nKey::TabUnnamedShell, @@ -1617,8 +1625,18 @@ impl Tty7App { mod tests { use super::*; use gpui::TestAppContext; + use std::path::Path; use unicode_segmentation::UnicodeSegmentation; + /// Most of these tests are about where a title is *cut*, not about what + /// `~` means: the paths they pass either already start with `~` or are + /// nowhere near anybody's home. Naming no home keeps the assertions off + /// the process environment — and is what a title of unknown provenance + /// gets in the app too (#580). + fn short_title(raw: &str) -> String { + super::short_title(raw, None) + } + #[test] fn every_visible_agent_state_has_words_for_it() { use crate::core::cli_agent::AgentStatus; @@ -1669,6 +1687,29 @@ mod tests { assert_eq!(short_title("plain"), "plain"); } + /// A title shortens under the home of the machine it came from, and + /// under no other (#580). + #[test] + fn short_title_shortens_under_the_home_it_was_given() { + let server = Path::new("/home/deploy"); + assert_eq!( + super::short_title("/home/deploy/app", Some(server)), + "~/app" + ); + // This machine's home is not a stand-in for the server's: the same + // path stays whole when the home naming it is somewhere else. + assert_eq!( + super::short_title("/home/deploy/app", Some(Path::new("/Users/thomas"))), + "/home/deploy/app" + ); + // And a pane nothing here can place — no link to its host, or a + // shell that has ssh'd on — shortens against nothing. + assert_eq!( + super::short_title("/home/deploy/app", None), + "/home/deploy/app" + ); + } + #[test] fn short_title_truncates_deep_paths_to_trailing_segments() { assert_eq!(short_title("user@host:~/repo/025/tty7"), "…/repo/025/tty7");