diff --git a/crates/tty7-core/src/core/group_key.rs b/crates/tty7-core/src/core/group_key.rs index 99a8f0b4..6587d78f 100644 --- a/crates/tty7-core/src/core/group_key.rs +++ b/crates/tty7-core/src/core/group_key.rs @@ -359,6 +359,93 @@ impl EntryWatch { } } +/// What each pinned group's header reads, in list order: the name the user +/// gave it, or its folder's last component — lengthened, like a repo's, when +/// two folders end the same way. A label group always has a given name. +pub fn pinned_names(pinned: &[PinnedGroup]) -> Vec { + let unnamed: Vec = pinned + .iter() + .filter(|g| g.given_name().is_none()) + .filter_map(|g| g.folder_path().map(Path::to_path_buf)) + .collect(); + let mut from_folders = group_names(&unnamed.iter().collect::>()).into_iter(); + pinned + .iter() + .map(|g| match (g.given_name(), g.folder_path()) { + (Some(name), _) => name.to_string(), + (None, Some(_)) => from_folders + .next() + .expect("group_names answers one name per folder"), + (None, None) => String::new(), + }) + .collect() +} + +/// What each auto group's header reads, in `keys` order. +/// +/// Only repo roots go through [`group_names`]. They are paths, so two of them +/// can perfectly well end in the same component and need lengthening until +/// they differ. A host group reads its `user@host` target as it is. +pub fn auto_names(keys: &[&AutoKey]) -> Vec { + let roots: Vec<&PathBuf> = keys + .iter() + .filter_map(|k| match k { + AutoKey::Repo(p) => Some(p), + AutoKey::SshHost(_) => None, + }) + .collect(); + let mut disambiguated = group_names(&roots).into_iter(); + keys.iter() + .map(|k| match k { + AutoKey::Repo(_) => disambiguated + .next() + .expect("group_names answers one name per root"), + AutoKey::SshHost(host) => host.clone(), + }) + .collect() +} + +pub fn group_names(roots: &[&PathBuf]) -> Vec { + let comps: Vec> = roots + .iter() + .map(|r| { + r.components() + .filter(|c| matches!(c, std::path::Component::Normal(_))) + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect() + }) + .collect(); + let mut depth = vec![1usize; roots.len()]; + loop { + let names: Vec = comps + .iter() + .zip(&depth) + .enumerate() + .map(|(i, (c, &d))| { + if c.is_empty() { + roots[i].display().to_string() + } else { + c[c.len().saturating_sub(d)..].join("/") + } + }) + .collect(); + let mut grew = false; + for i in 0..names.len() { + let collides = names + .iter() + .enumerate() + .any(|(j, n)| j != i && *n == names[i]); + if collides && depth[i] < comps[i].len() { + depth[i] += 1; + grew = true; + } + } + if !grew { + return names; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -607,4 +694,48 @@ mod tests { g.name = Some(" tty ".into()); assert_eq!(g.given_name(), Some("tty")); } + + /// A label is the name the user typed. Running it through the path + /// splitter would chop one containing a `/` into components and then + /// "shorten" it to the tail, so `work/urgent` would print as `urgent`. + #[test] + fn a_label_is_never_shortened_the_way_a_path_is() { + let pinned = vec![ + PinnedGroup::label("work/urgent"), + PinnedGroup::folder(Path::new("/home/u/tty7")), + ]; + assert_eq!(pinned_names(&pinned), vec!["work/urgent", "tty7"]); + } + + /// Two folders ending in the same component grow a prefix until they + /// differ, the way two repo roots do; a named one sits that out. + #[test] + fn pinned_folders_disambiguate_like_repo_roots() { + let mut named = PinnedGroup::folder(Path::new("/home/u/other/app")); + named.name = Some("mine".into()); + let pinned = vec![ + PinnedGroup::folder(Path::new("/home/u/work/app")), + named, + PinnedGroup::folder(Path::new("/home/u/fork/app")), + ]; + assert_eq!(pinned_names(&pinned), vec!["work/app", "mine", "fork/app"]); + } + + #[test] + fn group_names_disambiguate_only_the_collisions() { + let (a, b, c) = ( + p("/home/u/work/app"), + p("/home/u/fork/app"), + p("/home/u/tty7"), + ); + let names = group_names(&[&a, &b, &c]); + assert_eq!(names, vec!["work/app", "fork/app", "tty7"]); + } + + #[test] + fn group_names_handle_suffix_roots() { + let (short, long) = (p("/app"), p("/x/app")); + let names = group_names(&[&short, &long]); + assert_eq!(names, vec!["app", "x/app"]); + } } diff --git a/crates/tty7-gateway/src/tree.rs b/crates/tty7-gateway/src/tree.rs index dd87d2b0..9fe4bf87 100644 --- a/crates/tty7-gateway/src/tree.rs +++ b/crates/tty7-gateway/src/tree.rs @@ -8,10 +8,13 @@ //! desktop's switcher. use tty7_core::core::cli_agent::{AgentSessionState, AgentStatus as CoreStatus, CLIAgent}; -use tty7_core::core::machine::{Machine, PaneRecord}; +use tty7_core::core::group_key::{AutoKey, GroupKey, auto_names, pinned_names, place}; +use tty7_core::core::machine::{Machine, PaneRecord, Workspace}; use tty7_core::core::tab_view::{TabLabel, strip_host_prefix, strip_status_mark, tab_views_of}; use tty7_core::daemon::control::PaneAgentState; -use tty7_mobile_proto::{AgentStatus, AgentView, PaneView, TabView, Tree, WorkspaceView}; +use tty7_mobile_proto::{ + AgentStatus, AgentView, GroupView, PaneView, TabView, Tree, WorkspaceView, +}; pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { let mut workspaces: Vec<_> = machine.workspaces.iter().collect(); @@ -47,6 +50,8 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { .collect(), }) .collect(), + groups: groups_of(ws), + active_tab: ws.active_tab.map(|t| t.to_string()), } }) .collect(), @@ -54,6 +59,73 @@ pub fn build(host: &str, machine: &Machine, agents: &[PaneAgentState]) -> Tree { } } +/// The workspace's sidebar groups, drawn as the desktop draws them: pinned +/// groups in their order, then a group per repository or SSH host in the +/// order their first tab comes, then the tabs in neither. +/// +/// A tab's auto group is the one the desktop last filed it under +/// (`last_auto`): the desktop works it out by probing the tab's directory, and +/// writes the answer down so other readers need not. Auto grouping is taken +/// to be on, as it is by default; a desktop that turned it off still keeps +/// these hints, so its phone shows the repo groups its sidebar does not. +fn groups_of(ws: &Workspace) -> Vec { + let keys: Vec> = ws + .tabs + .iter() + .map(|tab| place(tab.group, &ws.groups, true, tab.last_auto.clone())) + .collect(); + let members = |key: Option<&GroupKey>| -> Vec { + ws.tabs + .iter() + .zip(&keys) + .filter(|(_, k)| k.as_ref() == key) + .map(|(tab, _)| tab.id.to_string()) + .collect() + }; + let mut auto_order: Vec<&AutoKey> = Vec::new(); + for key in keys.iter().flatten().filter_map(GroupKey::auto) { + if !auto_order.contains(&key) { + auto_order.push(key); + } + } + + let mut groups: Vec = ws + .groups + .pinned + .iter() + .zip(pinned_names(&ws.groups.pinned)) + .map(|(group, name)| GroupView { + name: Some(name), + pinned: true, + collapsed: group.collapsed, + tabs: members(Some(&GroupKey::Pinned(group.id))), + }) + .collect(); + groups.extend( + auto_order + .iter() + .zip(auto_names(&auto_order)) + .map(|(key, name)| GroupView { + name: Some(name), + pinned: false, + collapsed: ws.groups.auto_collapsed.contains(key), + tabs: members(Some(&GroupKey::Auto((*key).clone()))), + }), + ); + let rest = members(None); + if !rest.is_empty() { + // Named only beside auto groups, as the desktop names it. + let named = !auto_order.is_empty(); + groups.push(GroupView { + name: named.then(|| "Ungrouped".to_string()), + pinned: false, + collapsed: named && ws.groups.ungrouped_collapsed, + tabs: rest, + }); + } + groups +} + fn tab_name(label: TabLabel<'_>) -> String { match label { TabLabel::Named(name) => name.to_string(), @@ -215,6 +287,68 @@ mod tests { assert!(tree.workspaces[0].tabs[1].panes[0].agent.is_none()); } + #[test] + fn tabs_fall_into_the_desktops_groups_in_its_order() { + use tty7_core::core::group_key::PinnedGroup; + let mut ws = workspace("w", 1, (1..=5).map(Tab::leaf).collect()); + let mut urgent = PinnedGroup::label("urgent"); + urgent.collapsed = true; + let urgent_id = urgent.id; + ws.groups.pinned.push(urgent); + // A pinned group nobody is in still has its place. + ws.groups.pinned.push(PinnedGroup::label("later")); + ws.groups + .auto_collapsed + .push(AutoKey::Repo("/w/api".into())); + ws.tabs[0].last_auto = Some(AutoKey::Repo("/w/tty7".into())); + ws.tabs[1].group = Some(urgent_id); + // A pin outranks the repo the tab is in. + ws.tabs[1].last_auto = Some(AutoKey::Repo("/w/tty7".into())); + ws.tabs[2].last_auto = Some(AutoKey::Repo("/w/api".into())); + ws.tabs[3].last_auto = Some(AutoKey::Repo("/w/tty7".into())); + ws.active_tab = Some(ws.tabs[3].id); + let ids: Vec = ws.tabs.iter().map(|t| t.id.to_string()).collect(); + let machine = Machine { + workspaces: vec![ws], + panes: Vec::new(), + }; + + let view = &build("h", &machine, &[]).workspaces[0]; + let groups: Vec<_> = view + .groups + .iter() + .map(|g| (g.name.as_deref(), g.pinned, g.collapsed, g.tabs.clone())) + .collect(); + assert_eq!( + groups, + [ + (Some("urgent"), true, true, vec![ids[1].clone()]), + (Some("later"), true, false, vec![]), + ( + Some("tty7"), + false, + false, + vec![ids[0].clone(), ids[3].clone()] + ), + (Some("api"), false, true, vec![ids[2].clone()]), + (Some("Ungrouped"), false, false, vec![ids[4].clone()]), + ] + ); + assert_eq!(view.active_tab.as_deref(), Some(ids[3].as_str())); + } + + #[test] + fn a_workspace_without_groups_is_one_headless_list() { + let machine = Machine { + workspaces: vec![workspace("w", 1, vec![Tab::leaf(1), Tab::leaf(2)])], + panes: Vec::new(), + }; + let view = &build("h", &machine, &[]).workspaces[0]; + assert_eq!(view.groups.len(), 1); + assert_eq!(view.groups[0].name, None); + assert_eq!(view.groups[0].tabs.len(), 2); + } + #[test] fn a_path_is_cut_to_its_leaf() { assert_eq!(path_leaf("/a/b/c/"), "c"); diff --git a/crates/tty7-mobile-proto/src/lib.rs b/crates/tty7-mobile-proto/src/lib.rs index cd6f1122..a44ee066 100644 --- a/crates/tty7-mobile-proto/src/lib.rs +++ b/crates/tty7-mobile-proto/src/lib.rs @@ -274,6 +274,33 @@ pub struct WorkspaceView { pub id: String, pub name: String, pub tabs: Vec, + /// The desktop sidebar's groups, in its order: pinned groups, then the + /// ones it works out per repository or SSH host, then the tabs in none. + /// Every tab is in exactly one. Empty from a gateway that predates + /// groups, and the tabs are then one list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, + /// The tab the desktop last had in front in this workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_tab: Option, +} + +/// One of the desktop sidebar's groups of tabs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupView { + /// What its header reads. `None` for the tabs in no group when there is + /// no other group to set them apart from, which the desktop draws without + /// a header. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// A group the user pinned, rather than one worked out from the tabs. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub pinned: bool, + /// Folded shut on the desktop. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, + /// Its tabs' ids, in the workspace's order. + pub tabs: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 89bffb02..e08bd2a2 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -37,6 +37,19 @@ export interface WorkspaceView { id: string; name: string; tabs: TabView[]; + /** The desktop sidebar's groups, in its order; absent from an older desktop. */ + groups?: GroupView[]; + /** The tab the desktop last had in front here. */ + active_tab?: string; +} + +/** One of the desktop sidebar's groups: pinned, per repository or SSH host, + * or the tabs in none (unnamed when it is the only group). */ +export interface GroupView { + name?: string; + pinned?: boolean; + collapsed?: boolean; + tabs: string[]; } export interface Tree { diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 279e8879..0af15b8a 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -16,6 +16,7 @@ import { MAX_UPLOAD } from "./api"; import type { AgentStatus, AgentView, + GroupView, Host, LinkInfo, PaneView, @@ -1293,7 +1294,7 @@ function renderTree( ...(tree.remotes ?? []).flatMap((r) => r.workspaces.map((ws) => [r, matching(ws, query)] as const)), ].filter(([, ws]) => ws.tabs.length > 0); return groups.length - ? groups.map(([place, ws]) => workspaceGroup(host, place, ws, place?.name)) + ? groups.map(([place, ws]) => workspaceGroup(host, place, ws, place?.name, true, true)) : [h("p", { class: "search-empty" }, `No tab matches “${query}”.`)]; } const remotes = tree.remotes ?? []; @@ -1410,8 +1411,28 @@ function remoteState(host: Host, remote: RemoteView) { /** A workspace is one card of its tabs, named as the desktop's sidebar names * them; a split tab gives each of its panes a row. `where` names the machine * when a search mixes them. */ -function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, where?: string, headed = true) { - const rows = ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane))); +function workspaceGroup( + host: Host, + place: Place, + ws: WorkspaceView, + where?: string, + headed = true, + searching = false, +) { + const rowsOf = (tabs: TabView[]) => + tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane, tab.id === ws.active_tab))); + // The desktop sidebar's groups, each with the tabs still in it (a search + // leaves some out). An older desktop sends none: one list, as before. + const byId = new Map(ws.tabs.map((tab) => [tab.id, tab])); + const sections = ws.groups?.length + ? ws.groups + .map((group) => ({ group, tabs: group.tabs.flatMap((id) => byId.get(id) ?? []) })) + .filter((s) => s.tabs.length > 0) + : [{ group: null, tabs: ws.tabs }]; + const body: Node[] = + sections.length === 1 && !sections[0].group?.name + ? [h("div", { class: "card" }, ...rowsOf(sections[0].tabs))] + : sections.map(({ group, tabs }) => tabGroup(host, place, ws, group, rowsOf(tabs), tabs, searching)); return h( "section", { class: "group" }, @@ -1422,10 +1443,58 @@ function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, where?: str h("h2", { class: "group-title" }, where ? `${where} · ${workspaceName(ws.name)}` : workspaceName(ws.name)), h("span", { class: "group-count" }, String(ws.tabs.length)), ), - rows.length ? h("div", { class: "card" }, ...rows) : h("p", { class: "group-empty" }, "No tabs open."), + ...(ws.tabs.length ? body : [h("p", { class: "group-empty" }, "No tabs open.")]), ); } +/** The most pressing state among some tabs' agents: what a folded group's + * header shows, so one waiting on you is not hidden by the fold. */ +function mostUrgent(tabs: TabView[]): AgentStatus | null { + const order: AgentStatus[] = ["waiting", "working"]; + const states = new Set(tabs.flatMap((tab) => tab.panes.map((pane) => pane.agent?.status))); + return order.find((s) => states.has(s)) ?? null; +} + +/** One sidebar group: a header that folds it, over its rows. It starts folded + * as the desktop has it; a fold here is this phone's own and is remembered, + * and a search shows every match whatever is folded. */ +function tabGroup( + host: Host, + place: Place, + ws: WorkspaceView, + group: GroupView | null, + rows: Node[], + tabs: TabView[], + searching: boolean, +) { + const card = h("div", { class: "card" }, ...rows); + if (!group?.name) return h("div", { class: "tgroup" }, card); + const key = `fold.${host.id}.${spaceKey(place, ws)}.${group.pinned ? "pin" : "auto"}.${group.name}`; + const saved = remembered(key); + let folded = !searching && (saved === null ? !!group.collapsed : saved === "1"); + const urgent = mostUrgent(tabs); + const head = h( + "button", + { class: "tgroup-head" }, + ico("chevron", "icon tgroup-chevron"), + h("span", { class: "tgroup-name" }, group.name), + urgent && h("span", { class: `status-dot ${urgent}` }), + h("span", { class: "tgroup-count" }, String(tabs.length)), + ); + const section = h("div", { class: "tgroup" }, head, card); + const show = () => { + section.classList.toggle("folded", folded); + head.setAttribute("aria-expanded", String(!folded)); + }; + head.onclick = () => { + folded = !folded; + if (!searching) remember(key, folded ? "1" : "0"); + show(); + }; + show(); + return section; +} + /** The agents a new tab can start in, and the command that starts each. */ const STARTERS = [ { kind: "claude", command: "claude" }, @@ -1664,7 +1733,7 @@ function workspaceName(name: string) { /** A pane's row: its tab's name first, as on the desktop, then what the pane * is doing — its agent's state, or where its shell is. */ -function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView) { +function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView, current = false) { const agent = pane.agent; const sub: Child[] = []; if (agent && agent.status !== "idle") @@ -1690,6 +1759,8 @@ function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView) { "span", { class: "row-title" }, tab.name, + // The tab in front on the desktop: where you were. + current && h("span", { class: "tag current" }, "Current"), tab.hibernated && h("span", { class: "tag" }, "Asleep"), ), sub.length > 0 && h("span", { class: "row-sub" }, ...sub), diff --git a/mobile/src/style.css b/mobile/src/style.css index dcf08f45..8e9e5f48 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -450,6 +450,70 @@ code { overflow: hidden; } +/* The desktop sidebar's groups within a workspace. A header is one line the + height of a tap target; folded ones stack close, and a group that is open + leaves room after its rows so the next header starts a new block. */ +.tgroup:not(.folded) + .tgroup { + margin-top: 14px; +} + +.tgroup-head { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 0 4px; + border: 0; + background: none; + font: inherit; + font-size: 15px; + font-weight: 600; + color: var(--ink); + text-align: left; +} + +.tgroup:not(.folded) .tgroup-head { + margin-bottom: 4px; +} + +.tgroup-chevron { + flex: none; + width: 14px; + height: 14px; + color: var(--ink-2); + transform: rotate(90deg); + transition: transform 0.2s var(--ease); +} + +.tgroup.folded .tgroup-chevron { + transform: none; +} + +.tgroup-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgroup-count { + font-size: 13px; + font-weight: 500; + color: var(--ink-2); + font-variant-numeric: tabular-nums; +} + +.tgroup.folded .card { + display: none; +} + +.tag.current { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} + .remote { padding-top: 10px; } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index cd50426e..63ec7597 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -15,7 +15,8 @@ use std::path::{Path, PathBuf}; use crate::core::config::Config; use crate::core::group_key::{ - AutoKey, GroupId, GroupKey, PinnedGroup, WorkspaceGroups, auto_key, place, + AutoKey, GroupId, GroupKey, PinnedGroup, WorkspaceGroups, auto_key, auto_names, pinned_names, + place, }; use crate::terminal::git_status::GitStatusCache; use crate::ui::app::{TITLE_BAR_HEIGHT, Tab, Tty7App}; @@ -3220,93 +3221,6 @@ fn reordered_pinned( .collect() } -/// What each pinned group's header reads, in list order: the name the user -/// gave it, or its folder's last component — lengthened, like a repo's, when -/// two folders end the same way. A label group always has a given name. -fn pinned_names(pinned: &[PinnedGroup]) -> Vec { - let unnamed: Vec = pinned - .iter() - .filter(|g| g.given_name().is_none()) - .filter_map(|g| g.folder_path().map(Path::to_path_buf)) - .collect(); - let mut from_folders = group_names(&unnamed.iter().collect::>()).into_iter(); - pinned - .iter() - .map(|g| match (g.given_name(), g.folder_path()) { - (Some(name), _) => name.to_string(), - (None, Some(_)) => from_folders - .next() - .expect("group_names answers one name per folder"), - (None, None) => String::new(), - }) - .collect() -} - -/// What each auto group's header reads, in `keys` order. -/// -/// Only repo roots go through [`group_names`]. They are paths, so two of them -/// can perfectly well end in the same component and need lengthening until -/// they differ. A host group reads its `user@host` target as it is. -fn auto_names(keys: &[&AutoKey]) -> Vec { - let roots: Vec<&PathBuf> = keys - .iter() - .filter_map(|k| match k { - AutoKey::Repo(p) => Some(p), - AutoKey::SshHost(_) => None, - }) - .collect(); - let mut disambiguated = group_names(&roots).into_iter(); - keys.iter() - .map(|k| match k { - AutoKey::Repo(_) => disambiguated - .next() - .expect("group_names answers one name per root"), - AutoKey::SshHost(host) => host.clone(), - }) - .collect() -} - -fn group_names(roots: &[&PathBuf]) -> Vec { - let comps: Vec> = roots - .iter() - .map(|r| { - r.components() - .filter(|c| matches!(c, std::path::Component::Normal(_))) - .map(|c| c.as_os_str().to_string_lossy().to_string()) - .collect() - }) - .collect(); - let mut depth = vec![1usize; roots.len()]; - loop { - let names: Vec = comps - .iter() - .zip(&depth) - .enumerate() - .map(|(i, (c, &d))| { - if c.is_empty() { - roots[i].display().to_string() - } else { - c[c.len().saturating_sub(d)..].join("/") - } - }) - .collect(); - let mut grew = false; - for i in 0..names.len() { - let collides = names - .iter() - .enumerate() - .any(|(j, n)| j != i && *n == names[i]); - if collides && depth[i] < comps[i].len() { - depth[i] += 1; - grew = true; - } - } - if !grew { - return names; - } - } -} - /// Where a click on a tab's diff counts opens the overlay: the focused pane's /// repo. fn git_click( @@ -4705,50 +4619,6 @@ mod tests { assert_eq!(reordered_pinned(&ids, &shown, 2, 0), Some(vec![3, 0, 1, 2])); } - /// A label is the name the user typed. Running it through the path - /// splitter would chop one containing a `/` into components and then - /// "shorten" it to the tail, so `work/urgent` would print as `urgent`. - #[test] - fn a_label_is_never_shortened_the_way_a_path_is() { - let pinned = vec![ - PinnedGroup::label("work/urgent"), - PinnedGroup::folder(Path::new("/home/u/tty7")), - ]; - assert_eq!(pinned_names(&pinned), vec!["work/urgent", "tty7"]); - } - - /// Two folders ending in the same component grow a prefix until they - /// differ, the way two repo roots do; a named one sits that out. - #[test] - fn pinned_folders_disambiguate_like_repo_roots() { - let mut named = PinnedGroup::folder(Path::new("/home/u/other/app")); - named.name = Some("mine".into()); - let pinned = vec![ - PinnedGroup::folder(Path::new("/home/u/work/app")), - named, - PinnedGroup::folder(Path::new("/home/u/fork/app")), - ]; - assert_eq!(pinned_names(&pinned), vec!["work/app", "mine", "fork/app"]); - } - - #[test] - fn group_names_disambiguate_only_the_collisions() { - let (a, b, c) = ( - p("/home/u/work/app"), - p("/home/u/fork/app"), - p("/home/u/tty7"), - ); - let names = group_names(&[&a, &b, &c]); - assert_eq!(names, vec!["work/app", "fork/app", "tty7"]); - } - - #[test] - fn group_names_handle_suffix_roots() { - let (short, long) = (p("/app"), p("/x/app")); - let names = group_names(&[&short, &long]); - assert_eq!(names, vec!["app", "x/app"]); - } - /// A header with a long branch on it used to leave the heading as `DEL…` /// while the branch kept thirty characters. The name is the group; the /// branch is what it happens to be sitting on, and it may take at most