diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index d7cf82ce..3cc42e90 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -718,7 +718,7 @@ impl RemoteTerminal { /// here wins: it describes where the shell actually landed, which is not /// always where we asked (a missing directory sends the daemon home, an /// rc file may `cd` on its own). - fn seed_cwd(&self, cwd: Option) { + pub(crate) fn seed_cwd(&self, cwd: Option) { let Some(cwd) = cwd else { return }; if let Ok(mut guard) = self.cwd.lock() { guard.get_or_insert(cwd); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index f1755005..800f1713 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -163,6 +163,24 @@ pub struct NativeSshParts { /// What a pane is called when nothing running in it has said otherwise. pub(crate) const DEFAULT_TITLE: &str = "tty7"; +/// What a pane is *saying* about itself, if anything — the reading behind +/// [`TerminalView::stated_title`], split out so it can be pinned without a +/// live pane. +/// +/// Anything but the placeholder counts. That is wider than "arrived over OSC +/// 0/2" on purpose: an SSH pane answers to the host it dialled and a workspace +/// pane to its workspace's name, and those are names tty7 gave the pane +/// deliberately (#438) rather than the absence of one. The literal string +/// `tty7` is the only title that says nothing, because it is the app's own +/// name standing in for a pane that has never introduced itself. +pub(crate) fn stated_title(title: &str) -> Option<&str> { + match title.trim() { + "" => None, + t if t == DEFAULT_TITLE => None, + t => Some(t), + } +} + pub struct ShellParts { terminal: RemoteTerminal, pub(crate) pane_id: u64, @@ -1563,6 +1581,16 @@ impl TerminalView { self.terminal.foreground_cwd() } + /// The title this pane is showing, or `None` while it is still answering + /// to the app's own name — see [`stated_title`]. The label ladder reads + /// this where the machine tree reads + /// [`PaneRecord::osc_title`](tty7_core::core::machine::PaneRecord::osc_title), + /// which is what lets the tab strip and the switcher name a tab the same + /// way. + pub(crate) fn stated_title(&self) -> Option<&str> { + stated_title(&self.title) + } + /// 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 @@ -7343,6 +7371,31 @@ fn drag_scroll_step(overshoot: f32) -> i32 { #[cfg(test)] mod tests { + /// What the label ladder asks a pane: are you showing a name of your own, + /// or still standing under the app's? (#740) + #[test] + fn a_pane_states_a_title_whenever_it_is_not_the_placeholder() { + use super::stated_title; + + // Nothing has spoken — this is the pane a directory stands in for. + assert_eq!(stated_title("tty7"), None); + assert_eq!(stated_title(" tty7 "), None); + assert_eq!(stated_title(" "), None); + + // A title from the program running in it. + assert_eq!(stated_title("vim — main.rs"), Some("vim — main.rs")); + assert_eq!(stated_title(" user@host:~/repo "), Some("user@host:~/repo")); + // A default tty7 chose for the pane itself is a name, not the absence + // of one: an SSH pane answers to its host (#438) and a workspace pane + // to its workspace, and neither gives way to a directory. + assert_eq!(stated_title("prod-web"), Some("prod-web")); + // So does the state a finished pane is left showing. + assert_eq!( + stated_title("tty7 — process exited"), + Some("tty7 — process exited") + ); + } + #[test] fn an_unfocused_input_caret_is_always_a_steady_outline() { use super::{InputCaretPaint, input_caret_paint}; diff --git a/src/ui/app.rs b/src/ui/app.rs index e3b74ca3..2a1c7ca8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -536,6 +536,67 @@ impl Tab { (leaf.title.clone(), leaf.display_home(cx)) } + /// This tab as the shared label ladder reads it, together with what a `~` + /// in whatever it ends up named would mean. + /// + /// [`TabView`](tty7_core::core::tab_view::TabView) is how a tab looks to + /// someone who is *not* the window showing it — the switcher listing + /// another window's workspace, `tty7 tab ls` on the far side of a socket. + /// Building one here from the live pane is what stops this window having a + /// second opinion: both sides then rank a given name, a title, an agent and + /// a directory through + /// [`TabView::label`](tty7_core::core::tab_view::TabView::label), so the + /// strip's answer to "which repo is this?" is the switcher's answer too. + /// + /// Everything comes off the one leaf the tab names itself after, so the + /// title and the directory standing in for it can never describe different + /// panes (#580). + pub(crate) fn label_view( + &self, + window: Option<&Window>, + cx: &App, + ) -> ( + tty7_core::core::tab_view::TabView, + Option, + ) { + let name = self.name.clone(); + let Some(leaf) = self.title_leaf(window, cx) else { + return ( + tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + title: String::new(), + osc_title: None, + cwd: None, + agent: None, + status: None, + live: false, + panes: 0, + }, + None, + ); + }; + let leaf = leaf.read(cx); + let view = tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + // The tree's `title` is the foreground process name — what it falls + // back on once a pane has said nothing about itself. A live pane's + // equivalent is the placeholder it answers to unprompted: any + // *other* default it was given (an SSH host, a workspace name) is a + // 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), + cwd: leaf.cwd().map(|p| p.display().to_string()), + agent: leaf.agent(), + status: leaf.agent_session().map(|s| s.status), + live: !leaf.terminal.exited, + panes: self.pane.terminals().len(), + }; + (view, leaf.display_home(cx)) + } + pub(crate) fn git_status( &self, window: Option<&Window>, diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index a379e595..e94f451d 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2965,47 +2965,20 @@ impl TabRow { } } -/// Names a tab of a workspace this window does not own, matching what -/// `Tty7App::tab_label` shows for local ones. +/// Names a tab of a workspace this window does not own. /// -/// The two read different sources and have to be talked into agreeing. A local -/// tab is named by its live terminal's OSC title, which shells set to the -/// working directory and agents overwrite with what they are doing. The tree -/// 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. +/// The two surfaces used to read different sources and had to be talked into +/// agreeing: a local tab was named by its live terminal's title, this one by +/// the tree's copy of it (`PaneRecord::osc_title`). They now go through the one +/// renderer, [`crate::ui::tab_strip::label_of`] — a local tab is turned into +/// the same [`TabView`](crate::ui::machine_mirror::TabView) this one already +/// is, so neither column can rank the evidence its own way. fn tab_view_label( view: &crate::ui::machine_mirror::TabView, index: usize, home: Option<&std::path::Path>, ) -> String { - let unnamed = || { - t_fmt( - L10nKey::TabUnnamedShell, - &[("n", &((index + 1).to_string()))], - ) - }; - // 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, home) { - shortened if !shortened.trim().is_empty() => shortened, - _ => match view.title.trim() { - "" => unnamed(), - title => title.to_string(), - }, - }; - match view.label() { - crate::ui::machine_mirror::TabLabel::Named(name) => name.to_string(), - // Through `short_title` because the local strip puts its own titles - // through it too: the shell integration writes `user@host:~/dir`, and a - // tab that spelled that out in full where the strip says "…/dir" would - // be the same disagreement in a new place. - crate::ui::machine_mirror::TabLabel::Osc(title) => shortened(title), - crate::ui::machine_mirror::TabLabel::Agent(agent) => agent.display_name().to_string(), - crate::ui::machine_mirror::TabLabel::Cwd(cwd) => shortened(cwd), - crate::ui::machine_mirror::TabLabel::Process(title) => title.to_string(), - crate::ui::machine_mirror::TabLabel::Unknown => unnamed(), - } + crate::ui::tab_strip::label_of(view, index, home) } impl Group { diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 6324133e..cb093257 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -326,9 +326,24 @@ impl Tty7App { ); (shown, Some(full)) } else { - 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()); + // The ladder the strip and the switcher climb, read + // here for the name and not for the shortening: this + // column measures in pixels and lets a card expand the + // row back to the whole string, so it wants what + // `label_of` would have cut down rather than the cut. + use crate::ui::machine_mirror::TabLabel; + let (view, home) = tab.label_view(Some(window), cx); + let raw = match view.label() { + TabLabel::Osc(title) | TabLabel::Cwd(title) => { + abbreviate_home(strip_host_prefix(title.trim()), home.as_deref()) + .into_owned() + } + TabLabel::Agent(agent) => agent.display_name().to_string(), + // A tab holding a name got one above. + TabLabel::Named(name) => name.to_string(), + TabLabel::Process(title) => title.to_string(), + TabLabel::Unknown => String::new(), + }; if raw.trim().is_empty() { // Nothing to expand: the row is naming an unnamed // shell, not hiding a title behind an ellipsis. @@ -338,7 +353,7 @@ impl Tty7App { )); (placeholder, None) } else { - let full = SharedString::from(raw.as_ref()); + let full = SharedString::from(raw); let shown = elide_label( &window.text_system(), title_font, diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 33982dd7..3ba13fcb 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -140,6 +140,94 @@ pub(crate) fn short_title(raw: &str, home: Option<&std::path::Path>) -> String { label } +/// The one place a tab gets its displayed name, whichever surface is asking. +/// +/// `label()` ranks the evidence — a given name, then the title the pane is +/// showing, then an agent, then the working directory, then the process it is +/// running — and this renders whatever came back. Both callers arrive with a +/// [`TabView`](crate::ui::machine_mirror::TabView): the switcher reads one out +/// of the machine tree for a window it does not own, and the strip builds one +/// from its own live panes in +/// [`Tab::label_view`](crate::ui::app::Tab::label_view). +/// +/// They used to rank their own evidence, and disagreed where it mattered most: +/// a pane with a working directory and no title — every non-PowerShell shell +/// tty7 ships integration for reports OSC 7 and no OSC 0 — was listed by the +/// switcher as `~/repo/tty7` and by the strip that owned it as "tty7", the +/// app's own name (#740). +pub(crate) fn label_of( + view: &crate::ui::machine_mirror::TabView, + index: usize, + home: Option<&std::path::Path>, +) -> String { + use crate::ui::machine_mirror::TabLabel; + + let unnamed = || { + t_fmt( + L10nKey::TabUnnamedShell, + &[("n", &((index + 1).to_string()))], + ) + }; + // A path can shorten away to nothing (a bare "user@host:"), and the process + // name the tree carries ("zsh") is still worth more than a number. + // + // Through `stated_title` because a tab of *this* window has no process name + // to offer: `Tab::label_view` fills that slot with the placeholder a pane + // answers to before anything has spoken, and printing the app's own name + // here is the one thing #740 exists to stop. Nothing to say falls to the + // number, which is what the strip showed before it shared this renderer. + let shortened = |raw: &str| match short_title(raw, home) { + shortened if !shortened.trim().is_empty() => shortened, + _ => match crate::terminal::view::stated_title(&view.title) { + Some(title) => title.to_string(), + None => unnamed(), + }, + }; + match view.label() { + TabLabel::Named(name) => name.to_string(), + // Through `short_title` because a title is so often a path: the shell + // integration writes `user@host:~/dir`, and a tab spelling that out in + // full where the one beside it says "…/dir" would be the same + // disagreement in a new place. + TabLabel::Osc(title) => shortened(title), + TabLabel::Agent(agent) => agent.display_name().to_string(), + TabLabel::Cwd(cwd) => shortened(cwd), + TabLabel::Process(title) => title.to_string(), + TabLabel::Unknown => unnamed(), + } +} + +/// What a row can add on hover: the name behind the one [`label_of`] cut down, +/// or `None` when it cut nothing and the tooltip would only repeat the row. +/// +/// The comparison has to happen on the *same* spelling, which is the whole +/// trick here. `label_of` abbreviates a path under the home before it elides +/// it, and this returns the abbreviated form too, so a raw `/Users/x/repo` +/// measured against a label of `~/repo` looks like a difference that isn't +/// one — and every tab named after a directory inside the home would hang a +/// tooltip saying exactly what it already says. Abbreviate first, compare +/// after. +fn tooltip_of( + view: &crate::ui::machine_mirror::TabView, + index: usize, + home: Option<&std::path::Path>, +) -> Option { + use crate::ui::machine_mirror::TabLabel; + + // The other rungs are never shortened: a given name and a process name are + // printed whole, and an agent's is a word. + let raw = match view.label() { + TabLabel::Osc(title) => title, + TabLabel::Cwd(cwd) => cwd, + _ => return None, + }; + let full = abbreviate_home(raw.trim(), home); + if full.trim().is_empty() || full.as_ref() == label_of(view, index, home).as_str() { + return None; + } + Some(SharedString::from(full.into_owned())) +} + /// Width of `text` shaped in `font` at `size`, in pixels. /// /// The window's text system caches shaped runs, so measuring the same labels @@ -1229,6 +1317,11 @@ impl Tty7App { /// read `…/a/b/c` with no way to find out which `a` that was. `None` when /// nothing was dropped, so tabs that already show their whole name stay /// quiet under the pointer. + /// + /// It has to unshorten whatever the label was *made of*, which is why it + /// reads the same [`TabView`](crate::ui::machine_mirror::TabView) the label + /// did: a tab named after its directory wants that directory spelled out, + /// not the title it never had. See [`tooltip_of`]. pub(crate) fn tab_title_tooltip( &self, tab: &Tab, @@ -1236,19 +1329,13 @@ impl Tty7App { window: Option<&Window>, cx: &App, ) -> Option { - if tab.name.as_ref().is_some_and(|n| !n.trim().is_empty()) { - return None; - } - 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, home.as_deref()).into_owned(), - )) + let (view, home) = tab.label_view(window, cx); + tooltip_of(&view, index, home.as_deref()) } + /// What this window puts on a tab of its own — the same ladder, through the + /// same renderer, as the switcher uses for a tab of somebody else's window. + /// See [`label_of`]. pub(crate) fn tab_label( &self, tab: &Tab, @@ -1256,22 +1343,8 @@ impl Tty7App { window: Option<&Window>, cx: &App, ) -> String { - if let Some(name) = tab.name.as_ref() { - let trimmed = name.trim(); - if !trimmed.is_empty() { - return trimmed.to_string(); - } - } - 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, - &[("n", &((index + 1).to_string()))], - ) - } else { - label - } + let (view, home) = tab.label_view(window, cx); + label_of(&view, index, home.as_deref()) } /// The New Tab control: one `+` that drops the list of everything it could @@ -2688,4 +2761,264 @@ mod tests { assert_eq!(spec.args, ["--login"]); assert!(!spec.args_are_tty7_defaults); } + + /// A tab of this window as the strip reads it: `tab_label` is nothing but + /// [`label_of`] over the [`TabView`](crate::ui::machine_mirror::TabView) + /// that [`Tab::label_view`](crate::ui::app::Tab::label_view) builds from + /// the live leaf, so naming one here climbs the same ladder a real tab + /// climbs. `title` is the placeholder `label_view` fills that slot with — + /// the machine tree puts a process name there, a live pane has only the + /// name it answers to before anything has spoken. + fn strip_tab() -> crate::ui::machine_mirror::TabView { + crate::ui::machine_mirror::TabView { + id: tty7_core::core::machine::TabId::new(), + name: None, + title: crate::terminal::view::DEFAULT_TITLE.to_string(), + osc_title: None, + cwd: None, + agent: None, + status: None, + live: true, + panes: 1, + } + } + + /// The home the paths below are measured against — named rather than read + /// off this machine, so the assertions do not depend on who is running + /// them (#580). + fn home() -> &'static Path { + Path::new("/Users/x") + } + + #[test] + fn a_renamed_tab_keeps_its_name_over_every_other_answer() { + let mut tab = strip_tab(); + tab.name = Some(" build ".into()); + tab.osc_title = Some("vim — main.rs".into()); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "build"); + } + + #[test] + fn a_pane_showing_a_title_is_named_by_it_and_not_by_its_directory() { + let mut tab = strip_tab(); + tab.osc_title = Some("vim — main.rs".into()); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "vim — main.rs"); + + // Including the title an SSH pane answers to before the far shell has + // said anything (#438): `label_view` hands that up here, so a window + // full of them still reads as hosts rather than as directories. + tab.osc_title = Some("prod-web".into()); + assert_eq!(label_of(&tab, 0, Some(home())), "prod-web"); + } + + /// #740: every shell tty7 ships integration for except PowerShell reports + /// its directory over OSC 7 and never sets a title, which left the tab + /// reading "tty7" — the app's own name — while the switcher listing the + /// very same tab showed the directory. + #[test] + fn a_pane_that_has_only_said_where_it_is_is_named_after_that() { + let mut tab = strip_tab(); + tab.cwd = Some("/Users/x/repo/tty7".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "~/repo/tty7"); + // Through the same shortener as a title, so a deep directory is cut + // where a deep path in a title would be. + tab.cwd = Some("/Users/x/repo/tty7/crates/tty7-core/src".into()); + assert_eq!( + label_of(&tab, 0, Some(home())), + super::short_title("/Users/x/repo/tty7/crates/tty7-core/src", Some(home())), + ); + } + + /// A tooltip exists to say what the row had to leave out. One that repeats + /// the row is worse than none, and the label and the raw string it came + /// from are not comparable until both have been abbreviated: `~/repo` and + /// `/Users/x/repo` are the same name spelled two ways, and reading them as + /// a difference hung a tooltip on every tab named after a directory under + /// the home — which, after this change, is most of them. + #[test] + fn a_tab_named_after_a_directory_says_nothing_more_on_hover_unless_it_was_cut() { + let mut tab = strip_tab(); + tab.cwd = Some("/Users/x/repo".into()); + + assert_eq!(label_of(&tab, 0, Some(home())), "~/repo"); + assert_eq!( + tooltip_of(&tab, 0, Some(home())), + None, + "the row is already showing the whole directory" + ); + + // Cut down to its last three segments, so the head is worth having. + tab.cwd = Some("/Users/x/repo/crates/tty7-core/src".into()); + assert_eq!(label_of(&tab, 0, Some(home())), "…/crates/tty7-core/src"); + assert_eq!( + tooltip_of(&tab, 0, Some(home())).as_deref(), + Some("~/repo/crates/tty7-core/src") + ); + + // The same holds for a title that happens to be a path — the rung this + // guard was already getting wrong before a directory could reach it. + let mut titled = strip_tab(); + titled.osc_title = Some("/Users/x/repo".into()); + assert_eq!(tooltip_of(&titled, 0, Some(home())), None); + + // A shell integration's `user@host:` head is not in the label, so it + // is still worth spelling out. + titled.osc_title = Some("me@box:/Users/x/repo".into()); + assert_eq!( + tooltip_of(&titled, 0, Some(home())).as_deref(), + Some("me@box:/Users/x/repo") + ); + } + + /// The one test that fails if any of the wiring is put back: a real tab, + /// built the way the window builds one, named through `tab_label` — and + /// checked against what the switcher renders from the machine tree's view + /// of that very same pane. Before this change the strip said "tty7" and + /// the switcher said the directory (#740). + #[gpui::test] + fn the_strip_names_a_titleless_pane_exactly_as_the_switcher_does(cx: &mut TestAppContext) { + use crate::ui::pane::{Pane, PaneSlot}; + + let (app, mut vcx) = crate::ui::app::test_window::harness(cx); + let _stream = app.update_in(&mut vcx, |app, window, cx| { + let (view, stream) = crate::terminal::view::quiet_test_pane(1, window, cx); + // A pane that has reported where it is over OSC 7 and has never + // titled itself — every shell tty7 ships integration for except + // PowerShell. + view.read(cx) + .terminal + .seed_cwd(Some(std::path::PathBuf::from("/work/repo"))); + app.tabs + .push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Ready(view)))); + app.active = app.tabs.len() - 1; + stream + }); + vcx.background_executor.run_until_parked(); + + app.update_in(&mut vcx, |app, window, cx| { + let index = app.active; + let tab = &app.tabs[index]; + let (view, home) = tab.label_view(Some(window), cx); + assert_eq!(view.osc_title, None, "the pane never titled itself"); + assert_eq!(view.cwd.as_deref(), Some("/work/repo")); + + let strip = app.tab_label(tab, index, Some(window), cx); + assert_eq!(strip, "/work/repo"); + assert_ne!( + strip, + crate::terminal::view::DEFAULT_TITLE, + "and is not named after the app any more" + ); + + // The machine tree's reading of the same pane, which is all the + // switcher ever has: no title was seen, the cwd is the one above, + // and `title` is the foreground process name. + let from_tree = crate::ui::machine_mirror::TabView { + id: tab.tree_id.get(), + name: None, + title: "zsh".into(), + osc_title: None, + cwd: Some("/work/repo".into()), + agent: None, + status: None, + live: true, + panes: 1, + }; + assert_eq!( + strip, + label_of(&from_tree, index, home.as_deref()), + "the two columns name the same tab the same way" + ); + + assert_eq!( + app.tab_title_tooltip(tab, index, Some(window), cx), + None, + "and the row is showing the whole path, so it stays quiet" + ); + }); + } + + #[test] + fn a_pane_with_nothing_to_say_falls_back_the_way_it_always_did() { + // No title and no directory: the placeholder, exactly as before. + let tab = strip_tab(); + assert_eq!(label_of(&tab, 0, Some(home())), "tty7"); + + // And a tab holding no live pane at all is still numbered. + let mut empty = strip_tab(); + empty.title = String::new(); + assert!(label_of(&empty, 2, Some(home())).contains('3')); + } + + /// The rung under the shortener, which the two surfaces reach holding + /// different things. A shell that has said who and where it is but not + /// *where* — `user@host:` with nothing after the colon — leaves nothing to + /// show, and whatever stands in has to be something the tab does not + /// already say: the switcher has the foreground process name, and a tab of + /// this window has only the placeholder, which is the answer #740 removed. + #[test] + fn a_title_that_shortens_away_never_puts_the_app_name_back_on_the_tab() { + let mut strip = strip_tab(); + strip.osc_title = Some("user@host:".into()); + assert_ne!( + label_of(&strip, 0, Some(home())), + crate::terminal::view::DEFAULT_TITLE + ); + assert!( + label_of(&strip, 0, Some(home())).contains('1'), + "the numbered placeholder, which is what the strip showed here \ + before it shared this renderer" + ); + + // The switcher arrives with a real process name in that slot, and it + // is still worth more than a number. + let from_tree = crate::ui::machine_mirror::TabView { + title: "zsh".into(), + osc_title: Some("user@host:".into()), + ..strip_tab() + }; + assert_eq!(label_of(&from_tree, 0, Some(home())), "zsh"); + } + + /// A path is spelled the way the machine it is on spells it, and which + /// machine that is has nothing to do with which one tty7 is running on: a + /// remote pane reports POSIX to a Windows client, and a Windows pane + /// reports backslashes to a client that has never seen one (#580). + #[test] + fn a_cwd_is_cut_in_its_own_spelling_whichever_client_is_reading_it() { + let windows_home = Path::new(r"C:\Users\x"); + + // A Windows pane: shortened under its own home, and a path too deep to + // fit is rejoined with its own separator rather than with `/`. + let mut win = strip_tab(); + win.cwd = Some(r"C:\Users\x\repo".into()); + assert_eq!(label_of(&win, 0, Some(windows_home)), "~/repo"); + win.cwd = Some(r"D:\work\a\b\proj".into()); + assert_eq!(label_of(&win, 0, Some(windows_home)), r"…\a\b\proj"); + + // A remote pane's cwd is POSIX even when the client reading it is the + // Windows one: no drive to hang it off, no `~` borrowed from this + // machine's home, and no backslash anywhere in the answer. + let mut remote = strip_tab(); + remote.cwd = Some("/srv/app".into()); + assert_eq!(label_of(&remote, 0, Some(windows_home)), "/srv/app"); + remote.cwd = Some("/home/deploy/app".into()); + assert_eq!( + label_of(&remote, 0, Some(Path::new("/home/deploy"))), + "~/app", + "measured against the home of the host it is on, not of this one" + ); + + // The root of a filesystem is a directory like any other: a tab + // sitting in it says so, and says nothing more on hover. + let mut root = strip_tab(); + root.cwd = Some("/".into()); + assert_eq!(label_of(&root, 0, Some(home())), "/"); + assert_eq!(tooltip_of(&root, 0, Some(home())), None); + } }