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.
This commit is contained in:
l0ng-ai
2026-10-03 20:27:02 +08:00
committed by GitHub
parent e38f450d1e
commit 99b9327fab
7 changed files with 449 additions and 139 deletions
+131
View File
@@ -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<String> {
let unnamed: Vec<PathBuf> = 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::<Vec<_>>()).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<String> {
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<String> {
let comps: Vec<Vec<String>> = 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<String> = 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"]);
}
}
+136 -2
View File
@@ -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<GroupView> {
let keys: Vec<Option<GroupKey>> = ws
.tabs
.iter()
.map(|tab| place(tab.group, &ws.groups, true, tab.last_auto.clone()))
.collect();
let members = |key: Option<&GroupKey>| -> Vec<String> {
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<GroupView> = 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<String> = 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");
+27
View File
@@ -274,6 +274,33 @@ pub struct WorkspaceView {
pub id: String,
pub name: String,
pub tabs: Vec<TabView>,
/// 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<GroupView>,
/// The tab the desktop last had in front in this workspace.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_tab: Option<String>,
}
/// 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<String>,
/// 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<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+13
View File
@@ -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 {
+76 -5
View File
@@ -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),
+64
View File
@@ -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;
}
+2 -132
View File
@@ -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<String> {
let unnamed: Vec<PathBuf> = 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::<Vec<_>>()).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<String> {
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<String> {
let comps: Vec<Vec<String>> = 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<String> = 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