From 99b9327fabc670a4e8e5e695eaad7dcc2c35a54c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:27:02 +0800 Subject: [PATCH] feat(mobile): the desktop sidebar's groups, and the tab it has in front (#1073) * feat(mobile): the desktop sidebar's groups, and the tab it has in front A machine's tabs on the phone were one flat list per workspace. They now come in the desktop's groups, in its order: pinned groups, then one per repository or SSH host, then Ungrouped, each folding as on the desktop. The tab the desktop has in front reads Current. The gateway places each tab as the sidebar does, from what the machine tree already keeps: a tab's pinned group, or else the repo the desktop last filed it under. The naming rules moved from the sidebar into tty7-core so both name groups alike. An older gateway sends no groups and the phone shows one list, as before. * fix(mobile): group headers lead, and folded ones stack close A group's header read the same as the workspace label above it, and a folded one kept the gap meant for rows under it, so folded groups sat far apart. Headers are now the darker, larger line at a tap target's height; only an open group keeps room after its rows. --- crates/tty7-core/src/core/group_key.rs | 131 +++++++++++++++++++++++ crates/tty7-gateway/src/tree.rs | 138 ++++++++++++++++++++++++- crates/tty7-mobile-proto/src/lib.rs | 27 +++++ mobile/src/api.ts | 13 +++ mobile/src/main.ts | 81 ++++++++++++++- mobile/src/style.css | 64 ++++++++++++ src/ui/tab_sidebar.rs | 134 +----------------------- 7 files changed, 449 insertions(+), 139 deletions(-) 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