use super::*;
use crate::session::ActivityState;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex, PoisonError};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Group {
pub key: String,
pub label: String,
pub depth: usize,
pub collapsed: bool,
pub sessions: usize,
pub running: usize,
pub waiting: usize,
pub asking: usize,
pub cost: Option<f64>,
pub last_active: String,
pub branch: Option<String>,
}
impl Group {
fn add(&mut self, s: &Session) {
self.sessions += 1;
if s.is_running() {
self.running += 1;
match s.activity_state {
ActivityState::Asking => {
self.waiting += 1;
self.asking += 1;
}
ActivityState::WaitingForInput => self.waiting += 1,
_ => {}
}
}
if s.cost_available
&& !s.cost_is_free
&& let Some(c) = s.total_cost
{
self.cost = Some(self.cost.unwrap_or(0.0) + c);
}
let newer = match (
crate::util::parse_ts(&s.last_active),
crate::util::parse_ts(&self.last_active),
) {
(Some(a), Some(b)) => a > b,
(Some(_), None) => true,
_ => false,
};
if newer {
self.last_active = s.last_active.clone();
}
}
}
pub(super) struct Tree {
pub groups: Vec<Group>,
pub rows: Vec<Row>,
pub indent: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
struct Place {
repo: String,
repo_label: String,
checkout: Option<(String, String)>,
}
const PLACE_TTL: Duration = Duration::from_secs(60);
type Located = Option<(PathBuf, PathBuf)>;
static PLACES: LazyLock<Mutex<HashMap<String, (Located, Instant)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn locate_cached(dir: &str) -> Located {
let mut cache = PLACES.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((found, at)) = cache.get(dir)
&& at.elapsed() < PLACE_TTL
{
return found.clone();
}
let found = locate(Path::new(dir));
cache.insert(dir.to_string(), (found.clone(), Instant::now()));
found
}
fn locate(dir: &Path) -> Located {
if !dir.is_absolute() {
return None;
}
let root = dir.ancestors().find(|d| d.join(".git").exists())?;
let git = root.join(".git");
if git.is_dir() {
return Some((git, root.to_path_buf()));
}
let pointer = std::fs::read_to_string(&git).ok()?;
let target = PathBuf::from(pointer.trim().strip_prefix("gitdir:")?.trim());
let target = if target.is_absolute() {
target
} else {
root.join(target)
};
let common = match std::fs::read_to_string(target.join("commondir")) {
Ok(rel) => {
let joined = target.join(rel.trim());
PathBuf::from(crate::collide::normalise(&joined.to_string_lossy(), "/"))
}
Err(_) => target,
};
Some((common, root.to_path_buf()))
}
fn repo_dir(common: &Path) -> PathBuf {
match common.file_name() {
Some(name) if name == ".git" => common.parent().unwrap_or(common).to_path_buf(),
_ => common.to_path_buf(),
}
}
fn place(s: &Session) -> Place {
let dir = s.label_source.as_str();
if let Some(r) = &s.remote {
return Place {
repo: format!("host:{}:{dir}", r.host),
repo_label: format!("{}:{dir}", r.host),
checkout: None,
};
}
match locate_cached(dir) {
Some((common, root)) => {
let home = repo_dir(&common);
let checkout_label = if root == home {
format!("{} (main)", file_name(&root))
} else {
match root.strip_prefix(&home) {
Ok(inside) => inside.to_string_lossy().into_owned(),
Err(_) => crate::util::tildify(&root.to_string_lossy()),
}
};
Place {
repo: format!("repo:{}", common.display()),
repo_label: crate::util::tildify(&home.to_string_lossy()),
checkout: Some((format!("wt:{}", root.display()), checkout_label)),
}
}
None if dir.is_empty() => Place {
repo: "dir:".into(),
repo_label: "(no directory)".into(),
checkout: None,
},
None => Place {
repo: format!("dir:{dir}"),
repo_label: crate::util::tildify(dir),
checkout: None,
},
}
}
fn file_name(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string_lossy().into_owned())
}
type Checkout = (Option<(String, String)>, Vec<usize>);
fn user_key(s: &Session) -> String {
format!("user:{}", crate::config::user_label(s.owner.as_deref()))
}
fn scoped(scope: &str, key: String) -> String {
match scope.is_empty() {
true => key,
false => format!("{scope}/{key}"),
}
}
pub(super) fn build(
sessions: &[Session],
ordered: &[usize],
collapsed: &HashSet<String>,
sort: (ColumnId, bool),
children: impl Fn(usize) -> usize,
) -> Tree {
let mut tree = Tree {
groups: Vec::new(),
rows: Vec::new(),
indent: Vec::new(),
};
let ordered_sessions = ordered.iter().map(|&i| &sessions[i]);
if columns::users_in_view(ordered_sessions) < 2 {
repos(
&mut tree, sessions, ordered, collapsed, sort, &children, None,
);
return tree;
}
let mut users: Vec<(String, Vec<usize>)> = Vec::new();
for &i in ordered {
let key = user_key(&sessions[i]);
match users.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(i),
None => users.push((key, vec![i])),
}
}
rank(&mut users, |(_, m)| m.clone(), sessions, sort);
for (key, members) in users {
let label = crate::config::user_label(sessions[members[0]].owner.as_deref()).to_string();
tree.indent.push(String::new());
let mut g = Group {
collapsed: collapsed.contains(&key),
key: key.clone(),
label,
depth: 0,
..Group::default()
};
for &i in &members {
g.add(&sessions[i]);
}
let folded = g.collapsed;
tree.groups.push(g);
tree.rows.push(Row::Group(tree.groups.len() - 1));
if !folded {
repos(
&mut tree,
sessions,
&members,
collapsed,
sort,
&children,
Some(&key),
);
}
}
tree
}
fn adds_up(col: ColumnId) -> bool {
matches!(
col,
ColumnId::Cost
| ColumnId::CostHour
| ColumnId::CostToday
| ColumnId::TokenTotal
| ColumnId::TokenRate
| ColumnId::Tools
| ColumnId::Cpu
| ColumnId::Memory
)
}
fn summand(col: ColumnId, s: &Session) -> f64 {
let cpu = s.process.as_ref().map_or(0.0, |p| p.cpu as f64);
let memory = s.process.as_ref().map_or(0.0, |p| p.memory as f64);
match col {
ColumnId::Cost if s.cost_available && !s.cost_is_free => s.total_cost.unwrap_or(0.0),
ColumnId::CostHour => s.cost_hour,
ColumnId::CostToday => s.cost_today,
ColumnId::TokenTotal => (s.input_tokens + s.output_tokens) as f64,
ColumnId::TokenRate => s.tokens_per_min,
ColumnId::Tools => s.tool_count as f64,
ColumnId::Cpu => cpu,
ColumnId::Memory => memory,
_ => 0.0,
}
}
fn rank<T>(
groups: &mut Vec<T>,
members: impl Fn(&T) -> Vec<usize>,
sessions: &[Session],
(col, asc): (ColumnId, bool),
) {
if !adds_up(col) {
return;
}
let mut keyed: Vec<(f64, T)> = std::mem::take(groups)
.into_iter()
.map(|g| {
let total = members(&g)
.iter()
.map(|&i| summand(col, &sessions[i]))
.sum();
(total, g)
})
.collect();
keyed.sort_by(|a, b| {
let ord = a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal);
if asc { ord } else { ord.reverse() }
});
groups.extend(keyed.into_iter().map(|(_, g)| g));
}
fn repos(
tree: &mut Tree,
sessions: &[Session],
ordered: &[usize],
collapsed: &HashSet<String>,
sort: (ColumnId, bool),
children: &impl Fn(usize) -> usize,
user: Option<&str>,
) {
let scope = user.unwrap_or_default();
let depth = usize::from(user.is_some());
let mut repos: Vec<(Place, Vec<Checkout>)> = Vec::new();
for &i in ordered {
let p = place(&sessions[i]);
let at = match repos.iter().position(|(r, _)| r.repo == p.repo) {
Some(at) => at,
None => {
repos.push((p.clone(), Vec::new()));
repos.len() - 1
}
};
let checkouts = &mut repos[at].1;
match checkouts.iter_mut().find(|(c, _)| *c == p.checkout) {
Some((_, members)) => members.push(i),
None => checkouts.push((p.checkout.clone(), vec![i])),
}
}
rank(
&mut repos,
|(_, checkouts)| checkouts.iter().flat_map(|(_, m)| m.clone()).collect(),
sessions,
sort,
);
for (_, checkouts) in &mut repos {
rank(checkouts, |(_, m)| m.clone(), sessions, sort);
}
let heading = |tree: &mut Tree, key: String, label: String, depth, members: &[usize]| {
let key = scoped(scope, key);
let mut g = Group {
collapsed: collapsed.contains(&key),
key,
label,
depth,
..Group::default()
};
for &i in members {
g.add(&sessions[i]);
}
let folded = g.collapsed;
tree.groups.push(g);
tree.rows.push(Row::Group(tree.groups.len() - 1));
folded
};
let leaves = |tree: &mut Tree, members: &[usize], cont: &str| {
for (k, &i) in members.iter().enumerate() {
let last = k + 1 == members.len();
tree.rows.push(Row::Session(i));
tree.indent
.push(format!("{cont}{}", if last { "└─ " } else { "├─ " }));
let rail = format!("{cont}{}", if last { " " } else { "│ " });
for index in 0..children(i) {
tree.rows.push(Row::Subagent { parent: i, index });
tree.indent.push(rail.clone());
}
}
};
let r_count = repos.len();
for (r, (repo, checkouts)) in repos.into_iter().enumerate() {
let (glyph, rail) = match (user.is_some(), r + 1 == r_count) {
(false, _) => ("", ""),
(true, false) => ("├─ ", "│ "),
(true, true) => ("└─ ", " "),
};
let all: Vec<usize> = checkouts.iter().flat_map(|(_, m)| m.clone()).collect();
tree.indent.push(glyph.to_string());
if heading(tree, repo.repo, repo.repo_label, depth, &all) {
continue;
}
if checkouts.len() == 1 {
leaves(tree, &all, rail);
continue;
}
let n = checkouts.len();
for (c, (checkout, members)) in checkouts.into_iter().enumerate() {
let last = c + 1 == n;
let (key, label) = checkout.unwrap_or_default();
tree.indent
.push(format!("{rail}{}", if last { "└─ " } else { "├─ " }));
let folded = heading(tree, key, label, depth + 1, &members);
if let Some(g) = tree.groups.last_mut() {
g.branch = columns::branch_of(&sessions[members[0]]);
}
if !folded {
leaves(
tree,
&members,
&format!("{rail}{}", if last { " " } else { "│ " }),
);
}
}
}
}
impl App {
pub fn selected_group(&self) -> Option<&Group> {
match self.selected_row()? {
Row::Group(g) => self.groups.get(g),
_ => None,
}
}
pub fn on_group(&self) -> bool {
self.selected_group().is_some()
}
pub(super) fn fork_point(&self) -> Result<(PathBuf, PathBuf), &'static str> {
let dir = match self.selected_row() {
Some(Row::Group(g)) => {
let key = self.groups.get(g).map_or("", |g| g.key.as_str());
let key = key
.strip_prefix("user:")
.and_then(|rest| rest.split_once('/'))
.map_or(key, |(_, key)| key);
if let Some(common) = key.strip_prefix("repo:") {
repo_dir(Path::new(common))
} else if let Some(root) = key.strip_prefix("wt:") {
PathBuf::from(root)
} else if key.starts_with("host:") {
return Err("That repository is on another machine");
} else {
return Err("Not in a git repository");
}
}
_ => {
let s = self.selected_session().ok_or("Nothing selected")?;
if s.remote.is_some() {
return Err("That session is on another machine");
}
PathBuf::from(&s.label_source)
}
};
let (common, root) =
locate_cached(&dir.to_string_lossy()).ok_or("Not in a git repository")?;
Ok((root, repo_dir(&common)))
}
pub(super) fn toggle_tree(&mut self) {
self.tree = !self.tree;
self.refilter();
self.save_prefs();
self.set_status(if self.tree {
"Tree view: grouped by repository and checkout"
} else {
"Flat view"
});
}
pub(super) fn toggle_group(&mut self) {
let Some(key) = self.selected_group().map(|g| g.key.clone()) else {
return;
};
if !self.collapsed.remove(&key) {
self.collapsed.insert(key);
}
self.refilter();
self.save_prefs();
}
pub(super) fn reveal(&mut self, key: &str) {
if !self.tree {
return;
}
let Some(s) = self.sessions.iter().find(|s| s.key() == key) else {
return;
};
let p = place(s);
let user = user_key(s);
let mut changed = self.collapsed.remove(&user);
let mut keys = vec![p.repo];
keys.extend(p.checkout.map(|(checkout, _)| checkout));
for key in keys {
changed |= self.collapsed.remove(&scoped(&user, key.clone()));
changed |= self.collapsed.remove(&key);
}
if changed {
self.refilter();
self.save_prefs();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{session, test_app};
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Scratch {
let root = std::env::temp_dir().join(format!(
"cctop-tree-{}-{name}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("scratch directory");
Scratch(root)
}
fn repo(&self, rel: &str) -> String {
let dir = self.0.join(rel);
std::fs::create_dir_all(dir.join(".git")).expect("repo");
std::fs::write(dir.join(".git/HEAD"), "ref: refs/heads/main\n").expect("HEAD");
dir.to_string_lossy().into_owned()
}
fn worktree(&self, repo: &str, rel: &str, name: &str) -> String {
let gitdir = Path::new(repo).join(".git/worktrees").join(name);
std::fs::create_dir_all(&gitdir).expect("gitdir");
std::fs::write(gitdir.join("commondir"), "../..\n").expect("commondir");
std::fs::write(gitdir.join("HEAD"), format!("ref: refs/heads/{name}\n")).expect("HEAD");
let dir = self.0.join(rel);
std::fs::create_dir_all(&dir).expect("worktree");
std::fs::write(dir.join(".git"), format!("gitdir: {}\n", gitdir.display()))
.expect(".git file");
dir.to_string_lossy().into_owned()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
fn groups_of(app: &App) -> Vec<(usize, String)> {
app.visible
.iter()
.filter_map(|r| match r {
Row::Group(g) => Some((app.groups[*g].depth, app.groups[*g].label.clone())),
_ => None,
})
.collect()
}
fn position(app: &App, id: &str) -> usize {
app.visible
.iter()
.position(|r| {
matches!(r, Row::Session(_))
&& r.session().map(|i| app.sessions[i].session_id.as_str()) == Some(id)
})
.expect("session row")
}
#[test]
fn worktrees_of_one_repository_share_a_heading() {
let fx = Scratch::new("worktrees");
let repo = fx.repo("cctop");
let wt = fx.worktree(&repo, "cctop/.claude/worktrees/agent-1", "agent-1");
let deep = format!("{repo}/src");
std::fs::create_dir_all(&deep).unwrap();
let other = fx.repo("elsewhere");
let mut app = test_app();
app.tree = true;
app.sessions = vec![
session("main", true, &repo),
session("sub", true, &deep),
session("wt", true, &wt),
session("other", false, &other),
];
app.refilter();
let groups = groups_of(&app);
let repos: Vec<_> = groups.iter().filter(|g| g.0 == 0).collect();
assert_eq!(repos.len(), 2, "two repositories: {groups:?}");
let checkouts: Vec<&str> = groups
.iter()
.filter(|g| g.0 == 1)
.map(|g| g.1.as_str())
.collect();
assert_eq!(checkouts.len(), 2, "cctop has two checkouts: {groups:?}");
assert!(checkouts.contains(&"cctop (main)"));
assert!(checkouts.contains(&".claude/worktrees/agent-1"));
let cctop = app
.groups
.iter()
.find(|g| g.depth == 0 && g.label.ends_with("cctop"))
.expect("cctop heading");
assert_eq!(cctop.sessions, 3);
let main = app
.groups
.iter()
.find(|g| g.label == "cctop (main)")
.unwrap();
assert_eq!(main.sessions, 2);
assert_eq!(main.branch.as_deref(), Some("main"));
let at = app
.visible
.iter()
.position(|r| matches!(r, Row::Group(g) if app.groups[*g].label.ends_with("elsewhere")))
.unwrap();
assert_eq!(app.visible[at + 1], Row::Session(3));
assert_eq!(app.indent[at + 1], "└─ ");
}
#[test]
fn a_heading_totals_its_sessions() {
let fx = Scratch::new("totals");
let repo = fx.repo("r");
let mut app = test_app();
app.tree = true;
let mut a = session("a", true, &repo);
a.cost_available = true;
a.total_cost = Some(1.5);
a.activity_state = ActivityState::WaitingForInput;
let mut b = session("b", true, &repo);
b.cost_available = true;
b.total_cost = Some(2.0);
b.activity_state = ActivityState::Asking;
let mut c = session("c", false, &repo);
c.activity_state = ActivityState::WaitingForInput;
app.sessions = vec![a, b, c];
app.refilter();
let g = &app.groups[0];
assert_eq!(g.sessions, 3);
assert_eq!(g.running, 2);
assert_eq!(g.waiting, 2);
assert_eq!(g.asking, 1);
assert_eq!(g.cost, Some(3.5));
assert_eq!(app.matched, 3);
}
#[test]
fn folding_a_heading_hides_its_sessions() {
let fx = Scratch::new("fold");
let repo = fx.repo("r");
let mut app = test_app();
app.tree = true;
app.sessions = vec![session("a", true, &repo), session("b", true, &repo)];
app.refilter();
assert_eq!(app.visible.len(), 3);
app.selected = 0;
assert!(app.on_group());
assert!(app.selected_session().is_none(), "a heading is no session");
app.toggle_group();
assert_eq!(app.visible, vec![Row::Group(0)]);
assert!(app.groups[0].collapsed);
assert_eq!(app.groups[0].sessions, 2, "the folded total still counts");
app.refilter();
assert_eq!(app.visible.len(), 1, "the fold survives a refresh");
app.toggle_group();
assert_eq!(app.visible.len(), 3);
assert_eq!(app.selected, 0);
}
#[test]
fn the_sort_applies_within_each_group() {
let fx = Scratch::new("sort");
let (x, y) = (fx.repo("x"), fx.repo("y"));
let mut app = test_app();
app.tree = true;
let priced = |id: &str, dir: &str, cost: f64| {
let mut s = session(id, true, dir);
s.cost_available = true;
s.total_cost = Some(cost);
s
};
app.sessions = vec![
priced("x-cheap", &x, 1.0),
priced("y-mid", &y, 5.0),
priced("x-dear", &x, 9.0),
];
app.sort_col = ColumnId::Cost;
app.sort_asc = false;
app.refilter();
assert!(position(&app, "x-dear") < position(&app, "x-cheap"));
assert!(position(&app, "x-cheap") < position(&app, "y-mid"));
assert_eq!(app.indent[position(&app, "x-dear")], "├─ ");
assert_eq!(app.indent[position(&app, "x-cheap")], "└─ ");
}
#[test]
fn groups_rank_by_their_total_when_the_column_adds_up() {
let fx = Scratch::new("rank");
let (many, one) = (fx.repo("many"), fx.repo("one"));
let mut app = test_app();
app.tree = true;
let priced = |id: &str, dir: &str, cost: f64| {
let mut s = session(id, true, dir);
s.cost_available = true;
s.total_cost = Some(cost);
s
};
app.sessions = vec![
priced("m1", &many, 4.0),
priced("m2", &many, 4.0),
priced("m3", &many, 4.0),
priced("o1", &one, 9.0),
];
let first_repo = |app: &App| {
app.groups
.iter()
.find(|g| g.depth == 0)
.map(|g| g.label.clone())
.unwrap()
};
app.sort_col = ColumnId::Cost;
app.sort_asc = false;
app.refilter();
assert!(first_repo(&app).ends_with("many"), "{:?}", groups_of(&app));
assert!(position(&app, "m3") < position(&app, "o1"));
assert!(position(&app, "m1") < position(&app, "o1"));
app.sort_asc = true;
app.refilter();
assert!(first_repo(&app).ends_with("one"), "{:?}", groups_of(&app));
app.sessions[3].context = Some(crate::session::ContextUsage {
used: 150_000,
max: 200_000,
compacted: false,
});
app.sort_col = ColumnId::Context;
app.sort_asc = false;
app.refilter();
assert!(first_repo(&app).ends_with("one"), "{:?}", groups_of(&app));
}
#[test]
fn filtering_keeps_a_match_and_its_ancestors() {
let fx = Scratch::new("filter");
let (x, y) = (fx.repo("x"), fx.repo("y"));
let mut app = test_app();
app.tree = true;
let mut hit = session("hit", true, &x);
hit.title = Some("kingfisher".into());
app.sessions = vec![hit, session("miss", true, &x), session("far", true, &y)];
app.search = "kingfisher".into();
app.refilter();
assert_eq!(app.visible.len(), 2, "{:?}", app.visible);
assert!(matches!(app.visible[0], Row::Group(_)));
assert_eq!(app.visible[1], Row::Session(0));
assert_eq!(app.groups[0].sessions, 1);
assert_eq!(app.matched, 1);
}
#[test]
fn subagents_hang_under_their_session_in_the_tree() {
let fx = Scratch::new("subagents");
let repo = fx.repo("r");
let mut app = test_app();
app.tree = true;
let mut parent = session("p", true, &repo);
parent.subagents = vec![crate::session::Subagent {
agent_id: "agent-1".into(),
agent_type: "general-purpose".into(),
description: "look".into(),
model: "claude-opus-5".into(),
started_at: None,
last_active: None,
duration_ms: 0,
status: crate::session::SubagentStatus::Running,
cost: 0.0,
tool_count: 0,
tool_use_id: None,
context: None,
ghost: false,
}];
let mut older = session("q", true, &repo);
older.last_active = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
app.sessions = vec![parent, older];
app.expanded.insert(app.sessions[0].key());
app.refilter();
let at = position(&app, "p");
assert_eq!(
app.visible[at + 1],
Row::Subagent {
parent: 0,
index: 0
}
);
assert_eq!(app.indent[at], "├─ ");
assert_eq!(app.indent[at + 1], "│ ");
}
#[test]
fn toggling_the_tree_keeps_the_cursor_on_its_session() {
let fx = Scratch::new("toggle");
let (x, y) = (fx.repo("x"), fx.repo("y"));
let mut app = test_app();
app.sessions = vec![session("a", true, &x), session("b", true, &y)];
app.refilter();
app.selected = position(&app, "b");
app.toggle_tree();
assert!(app.tree);
assert_eq!(app.selected_session().unwrap().session_id, "b");
assert_eq!(app.visible.len(), 4);
app.toggle_tree();
assert!(!app.tree);
assert_eq!(app.selected_session().unwrap().session_id, "b");
assert_eq!(app.visible.len(), 2);
assert!(app.groups.is_empty() && app.indent.is_empty());
}
#[test]
fn the_keys_fold_a_heading_and_leave_the_panels_alone() {
use crate::ui::tests::key;
use ratatui::crossterm::event::KeyCode;
let fx = Scratch::new("keys");
let repo = fx.repo("r");
let mut app = test_app();
app.sessions = vec![session("a", true, &repo)];
app.refilter();
app.on_key(key(KeyCode::Char('T')));
assert!(app.tree);
app.selected = 0;
assert!(app.on_group());
app.on_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::List, "no row menu for a heading");
assert_eq!(app.visible.len(), 1, "Enter folded it");
app.on_key(key(KeyCode::Char(' ')));
assert_eq!(app.visible.len(), 2, "Space unfolded it");
assert!(app.marked.is_empty(), "and marked nothing");
let tab = app.bottom_tab;
app.on_key(key(KeyCode::Right));
assert_ne!(app.bottom_tab, tab, "→ still moves the panels");
assert_eq!(app.visible.len(), 2);
}
#[test]
fn f_resolves_where_a_worktree_forks_from() {
use crate::ui::tests::key;
use ratatui::crossterm::event::KeyCode;
let fx = Scratch::new("fork");
let repo = fx.repo("r");
let wt = fx.worktree(&repo, "wt", "side");
let plain = fx.0.join("plain");
std::fs::create_dir_all(&plain).expect("plain dir");
let mut app = test_app();
app.tree = true;
app.sessions = vec![
session("a", true, &repo),
session("b", true, &wt),
session("c", true, &plain.to_string_lossy()),
];
app.refilter();
let heading = |app: &App, prefix: &str| {
app.visible
.iter()
.position(|r| matches!(r, Row::Group(g) if app.groups[*g].key.starts_with(prefix)))
.expect(prefix)
};
app.selected = heading(&app, "repo:");
app.on_key(key(KeyCode::Char('F')));
assert_eq!(app.mode, Mode::NewWorktree);
assert_eq!(app.worktree_base, Path::new(&repo));
assert_eq!(app.worktree_repo, Path::new(&repo));
app.mode = Mode::List;
app.selected = heading(&app, "wt:");
app.on_key(key(KeyCode::Char('F')));
assert_eq!(app.worktree_base, Path::new(&wt), "forks from the worktree");
assert_eq!(
app.worktree_repo,
Path::new(&repo),
"but lands in the main repo"
);
app.mode = Mode::List;
app.selected = heading(&app, "dir:");
app.on_key(key(KeyCode::Char('F')));
assert_eq!(app.mode, Mode::List, "no repository, no prompt");
}
#[test]
fn revealing_a_session_unfolds_its_group() {
let fx = Scratch::new("reveal");
let repo = fx.repo("r");
let mut app = test_app();
app.tree = true;
app.sessions = vec![session("a", true, &repo)];
app.refilter();
app.selected = 0;
app.toggle_group();
assert_eq!(app.visible.len(), 1);
let key = app.sessions[0].key();
app.reveal(&key);
assert_eq!(app.visible.len(), 2);
}
#[test]
fn a_second_user_adds_a_level_above_the_repositories() {
let fx = Scratch::new("users");
let (x, y) = (fx.repo("x"), fx.repo("y"));
let mut app = test_app();
app.tree = true;
let mut ana_x = session("ana-x", true, &x);
ana_x.owner = Some("ana".into());
let mut ana_y = session("ana-y", true, &y);
ana_y.owner = Some("ana".into());
ana_y.last_active = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let mut mine = session("mine", true, &x);
mine.last_active = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
app.sessions = vec![ana_x, ana_y, mine];
app.refilter();
let groups = groups_of(&app);
let users: Vec<&str> = groups
.iter()
.filter(|g| g.0 == 0)
.map(|g| g.1.as_str())
.collect();
assert_eq!(
users,
["ana", crate::config::MY_USER.as_str()],
"{groups:?}"
);
assert_eq!(
groups.iter().filter(|g| g.0 == 1).count(),
3,
"x and y under ana, x again under me: {groups:?}"
);
let at = position(&app, "ana-x");
assert_eq!(app.indent[at - 1], "├─ ");
assert_eq!(app.indent[at], "│ └─ ");
assert_eq!(app.indent[position(&app, "ana-y")], " └─ ");
app.selected = at - 1;
app.toggle_group();
let _ = position(&app, "mine");
assert!(
app.visible.iter().all(|r| r.session() != Some(0)),
"ana-x is folded away"
);
app.search = "user:ana".into();
app.refilter();
assert!(
groups_of(&app).iter().all(|g| g.1 != "ana"),
"{:?}",
groups_of(&app)
);
}
#[test]
fn a_relative_directory_is_its_own_group() {
assert_eq!(locate(Path::new("src")), None);
let s = session("a", false, "src");
assert_eq!(place(&s).repo, "dir:src");
}
}