fix(ui): read a path's ~ off the machine the path is on (#580)

`abbreviate_home` measured every path against this process's own `$HOME`,
whoever the path belonged to. A remote pane sitting in `/home/deploy/app`
therefore 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. #568 took the same borrow out of the file-link
resolver; this is the display half of it.

The home is now the caller's to name, because only the caller knows which
machine the path is on, and nothing here has to be asked for: a host
reports its home in the control handshake (`ControlHelloOk::home`) and
`HostLinks` already keeps it per host, so `path_display::home_for_host`
is a map lookup and never a round trip. `TerminalView::display_home` puts
a pane's own answer behind one call, and `Tab::leaf_title_and_home` reads
a title and its home off one leaf so the two cannot disagree.

Everything that draws a shortened path is on it: the Info panel's cwd, the
tab strip's label and tooltip, the sidebar's title and cwd lines, and the
switcher's workspace and tab rows. A path on a machine with no link — or
one a pane's shell has ssh'd away to, which no host here can answer for —
is shown in full rather than measured against a home that is not its own,
the same answer #568 settled on. A WSL pane gets its distro's home instead
of `C:\Users\…` for free, since it is a host like any other.

Tests: the borrow itself (a path with no home is left alone, and this
machine's home is not offered as a stand-in) at all three seams —
`abbreviate_home`, `short_title`, `display_path`. `ui::home`'s test no
longer has to set `HOME` on a process everything else is reading.
This commit is contained in:
l0ng-ai
2026-08-13 12:01:18 +08:00
parent 6c26b35acc
commit 1e51db614e
10 changed files with 266 additions and 70 deletions
+10
View File
@@ -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
+15
View File
@@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
self.remote_context().is_none().then(|| self.cwd())?
}
+22 -1
View File
@@ -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<Entity<TerminalView>> {
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<std::path::PathBuf>) {
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>,
+32 -21
View File
@@ -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.
+64 -9
View File
@@ -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<std::ffi::OsString> {
pub(crate) fn local_home() -> Option<PathBuf> {
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<PathBuf> {
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
+7 -2
View File
@@ -464,8 +464,13 @@ impl HostLinks {
cx.default_global::<HostLinks>().hosts.get(&id).cloned()
}
pub fn home(cx: &mut App, id: HostId) -> Option<PathBuf> {
cx.default_global::<HostLinks>().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<PathBuf> {
cx.try_global::<HostLinks>()?.homes.get(&id).cloned()
}
pub fn insert(cx: &mut App, host: Arc<RemoteHost>, home: PathBuf) {
+7 -3
View File
@@ -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)]
+47 -18
View File
@@ -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<Row> = 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::<crate::terminal::git_status::GitStatusCache>()?
.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!(
+10 -5
View File
@@ -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(),
+52 -11
View File
@@ -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");