diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 316d10cd..5632786e 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, @@ -1477,6 +1495,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 @@ -7222,6 +7250,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 dea55890..eae7e48b 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 2468a40c..9d3f087d 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -2956,47 +2956,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 eb4f5198..450ef36b 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -140,6 +140,57 @@ 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 is still worth more than a number. + let shortened = |raw: &str| match short_title(raw, home) { + shortened if !shortened.trim().is_empty() => shortened, + _ => match view.title.trim() { + "" => unnamed(), + title => title.to_string(), + }, + }; + 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(), + } +} + /// 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 +1280,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. pub(crate) fn tab_title_tooltip( &self, tab: &Tab, @@ -1236,12 +1292,18 @@ 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); + use crate::ui::machine_mirror::TabLabel; + + let (view, home) = tab.label_view(window, cx); + // 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 raw = raw.trim(); - if raw.is_empty() || raw == self.tab_label(tab, index, window, cx) { + if raw.is_empty() || raw == label_of(&view, index, home.as_deref()) { return None; } Some(SharedString::from( @@ -1249,6 +1311,9 @@ impl Tty7App { )) } + /// 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 +1321,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 @@ -2500,4 +2551,88 @@ 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())), + ); + } + + #[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')); + } }