use super::indicator_columns;
use super::*;
#[test]
fn indicator_columns_returns_none_when_neither_subtree_present() {
let mut list = make_list(&["/r"]);
list.repos[0].status = Some(empty_status("main"));
let layout = row_layout(&list.repos, &[], 40);
let cols = indicator_columns(&list.repos[0], 0, &layout);
assert_eq!(cols.stash, None);
assert_eq!(cols.worktree, None);
}
#[test]
fn indicator_columns_locates_stash_then_worktree() {
let mut list = make_list(&["/r"]);
let mut status = empty_status("main");
status.stashes.push(stash_entry(0));
status.worktree_info.push(worktree_entry("wt"));
list.repos[0].status = Some(status);
let layout = row_layout(&list.repos, &[], 40);
let cols = indicator_columns(&list.repos[0], 0, &layout);
assert_eq!(cols.stash, Some((9, 12)));
assert_eq!(cols.worktree, Some((13, 15)));
}
#[test]
fn indicator_columns_accounts_for_ahead_behind() {
let mut list = make_list(&["/r"]);
let mut status = empty_status("main");
status.ahead = 3;
status.behind = 22;
status.stashes.push(stash_entry(0));
list.repos[0].status = Some(status);
let layout = row_layout(&list.repos, &[], 40);
let cols = indicator_columns(&list.repos[0], 0, &layout);
assert_eq!(cols.stash, Some((9, 12)));
}
#[test]
fn rows_share_the_same_status_columns() {
let mut list = make_list(&["/r", "/long-repo-name"]);
let mut short = empty_status("main");
short.stashes.push(stash_entry(0));
let mut long = empty_status("feature/very-long-branch");
long.stashes.push(stash_entry(0));
list.repos[0].status = Some(short);
list.repos[1].status = Some(long);
let layout = row_layout(&list.repos, &[], 90);
let starts: Vec<u16> = list
.repos
.iter()
.map(|r| {
indicator_columns(r, 0, &layout)
.stash
.expect("stash range")
.0
})
.collect();
assert_eq!(starts[0], starts[1], "attention rails are not aligned");
}
#[test]
fn branch_cell_is_capped_to_a_third_of_the_panel() {
let mut list = make_list(&["/r"]);
list.repos[0].status = Some(empty_status(
"codex/some-extremely-long-generated-branch-name",
));
let layout = row_layout(&list.repos, &[], 30);
assert_eq!(layout.branch_col, 10);
}
#[test]
fn display_path_uses_relative_path_under_a_root() {
let roots = vec![PathBuf::from("/ws")];
assert_eq!(
display_path(&PathBuf::from("/ws/hbre/libmm"), &roots),
"hbre/libmm"
);
assert_eq!(
display_path(&PathBuf::from("/elsewhere/pinned"), &roots),
"pinned"
);
}
#[test]
fn display_path_keeps_basename_for_top_level_repo() {
let roots = vec![PathBuf::from("/ws")];
assert_eq!(
display_path(&PathBuf::from("/ws/kernel-6.12"), &roots),
"kernel-6.12"
);
}
#[test]
fn display_path_names_a_root_that_is_itself_a_repo() {
let roots = vec![PathBuf::from("/ws")];
assert_eq!(display_path(&PathBuf::from("/ws"), &roots), "ws");
}
#[cfg(unix)]
#[test]
fn breadcrumbs_survive_a_symlinked_root() {
let tmp = tempfile::TempDir::new().unwrap();
let real = tmp.path().join("real");
let repo = real.join("hbre").join("libmm");
std::fs::create_dir_all(&repo).unwrap();
let alias = tmp.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
let list = RepoList::new(
vec![repo.canonicalize().unwrap()],
vec![alias],
true,
Arc::new(Theme::default()),
);
assert_eq!(list.repos[0].display, "hbre/libmm");
}
#[test]
fn middle_ellipsize_keeps_head_and_tail() {
assert_eq!(middle_ellipsize("hbre/camsys", 30), "hbre/camsys");
let deep = "kernel-6.12/drivers/media/platform/horizon/camsys";
assert_eq!(middle_ellipsize(deep, 20), "kernel-6.12/…/camsys");
assert_eq!(middle_ellipsize(deep, 12), "…/camsys");
assert_eq!(middle_ellipsize(deep, 5), "kern…");
assert_eq!(middle_ellipsize(deep, 0), "");
}
#[test]
fn middle_ellipsize_handles_windows_paths() {
let deep = r"C:\Users\RUNNER~\.config\gitpane\config\gone";
assert_eq!(middle_ellipsize(deep, 30), "C:/…/gone");
assert_eq!(middle_ellipsize(deep, 6), "…/gone");
assert_eq!(middle_ellipsize(deep, 5), "C:\\U…");
}
#[test]
fn renders_breadcrumb_paths_and_ellipsizes_deep_ones() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Arc::new(Theme::default());
let roots = vec![PathBuf::from("/ws")];
let mut list = RepoList::new(
vec![
PathBuf::from("/ws/hbre/camsys"),
PathBuf::from("/ws/kernel-6.1/drivers/media/platform/horizon/camsys"),
PathBuf::from("/ws/kernel-6.12/drivers/media/platform/horizon/camsys"),
PathBuf::from("/ws/build"),
],
roots,
true,
theme,
);
list.render_area = Rect::new(0, 0, 40, 8);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("hbre/camsys"), "got: {text}");
assert!(text.contains("kernel-6.1/…/camsys"), "got: {text}");
assert!(text.contains("kernel-6.12/…/camsys"), "got: {text}");
assert!(text.contains("build"), "got: {text}");
}
#[test]
fn renders_persistent_hint_for_missing_root_beside_normal_repos() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Arc::new(Theme::default());
let tmp = tempfile::tempdir().unwrap();
let existing = tmp.path().to_path_buf();
let missing = tmp.path().join("gone"); let mut list = RepoList::new(
vec![existing.join("repo-a"), existing.join("repo-b")],
vec![existing.clone(), missing.clone()],
true,
theme,
);
list.render_area = Rect::new(0, 0, 40, 8);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("root does not exist:"), "got: {text}");
assert!(text.contains("gone"), "got: {text}");
assert!(text.contains("repo-a"), "got: {text}");
assert!(text.contains("repo-b"), "got: {text}");
}
#[test]
fn missing_root_hint_reserves_space_for_the_label() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Arc::new(Theme::default());
let tmp = tempfile::tempdir().unwrap();
let existing = tmp.path().to_path_buf();
let missing = tmp
.path()
.join("this-repository-basename-is-very-long-indeed");
let mut list = RepoList::new(
vec![existing.join("repo-a")],
vec![existing.clone(), missing.clone()],
true,
theme,
);
list.render_area = Rect::new(0, 0, 40, 8);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains('…'),
"ellipsis must survive the label budget: {text}"
);
}
#[test]
fn renders_no_hint_when_all_roots_exist() {
use ratatui::{Terminal, backend::TestBackend};
let theme = Arc::new(Theme::default());
let tmp = tempfile::tempdir().unwrap();
let mut list = RepoList::new(
vec![tmp.path().join("repo-a")],
vec![tmp.path().to_path_buf()],
true,
theme,
);
list.render_area = Rect::new(0, 0, 40, 8);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("repo-a"), "got: {text}");
assert!(!text.contains("root does not exist"), "got: {text}");
}
#[test]
fn default_branch_shown_dimmed_deviating_branch_colored() {
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/a", "/b"]);
list.repos[0].status = Some(empty_status("main"));
list.repos[1].status = Some(empty_status("devel"));
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let buf = terminal.backend().buffer();
let row_fg_at = |row_y: usize, needle: &str| {
let row: String = buf.content[row_y * 40..(row_y + 1) * 40]
.iter()
.map(|c| c.symbol())
.collect();
let at = row
.find(needle)
.unwrap_or_else(|| panic!("{needle} not drawn in row {row_y}: {row}"));
buf.content[row_y * 40 + at].style().fg
};
let t = &Theme::default().repo_list;
assert_eq!(row_fg_at(1, "main"), Some(t.branch_default));
assert_eq!(row_fg_at(2, "devel"), Some(t.branch));
}
#[test]
fn stash_toggle_glyph_sits_where_the_hit_test_says() {
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/r"]);
let mut status = empty_status("main");
status.stashes.push(stash_entry(0));
list.repos[0].status = Some(status);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let buf = terminal.backend().buffer();
let row: String = buf.content[40..80].iter().map(|c| c.symbol()).collect();
let drawn = row
.chars()
.position(|c| c == '\u{25b6}')
.expect("chevron drawn") as u16;
let layout = row_layout(&list.repos, &[], 38);
let cols = indicator_columns(&list.repos[0], 1, &layout);
assert_eq!(drawn, cols.stash.expect("stash range").0);
}
#[test]
fn attention_cell_packs_per_row_without_reserved_columns() {
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/a", "/b"]);
let mut a = empty_status("main");
a.stashes.push(stash_entry(0)); a.ahead = 4;
let mut b = empty_status("main");
b.behind = 1; list.repos[0].status = Some(a);
list.repos[1].status = Some(b);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let buf = terminal.backend().buffer();
let glyph_x = |row_y: usize, glyph: char| -> usize {
let row: String = buf.content[row_y * 40..(row_y + 1) * 40]
.iter()
.map(|c| c.symbol())
.collect();
row.chars()
.position(|c| c == glyph)
.unwrap_or_else(|| panic!("{glyph} not drawn in row {row_y}"))
};
let rail = 1 + row_layout(&list.repos, &[], 38).attention_x() as usize;
assert_eq!(glyph_x(1, '\u{25b6}'), rail, "row a starts at the rail");
assert_eq!(glyph_x(2, '\u{2193}'), rail, "row b starts at the rail too");
}
#[test]
fn status_block_hugs_widest_name_and_clamps_to_the_edge() {
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/r", "/long-repo-name"]);
let mut short = empty_status("main");
short.stashes.push(stash_entry(0));
let mut long = empty_status("main");
long.stashes.push(stash_entry(0));
list.repos[0].status = Some(short);
list.repos[1].status = Some(long);
let stash_cell = |list: &mut RepoList, width: u16, at: usize| -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let buf = terminal.backend().buffer();
[1usize, 2]
.iter()
.map(|row_y| {
let chars: Vec<char> = buf.content
[row_y * width as usize..(row_y + 1) * width as usize]
.iter()
.flat_map(|c| c.symbol().chars())
.collect();
chars[at..at + 3].iter().collect()
})
.collect()
};
for cell in stash_cell(&mut list, 40, 23) {
assert_eq!(cell, "\u{25b6}$1");
}
for cell in stash_cell(&mut list, 20, 16) {
assert_eq!(cell, "\u{25b6}$1");
}
}
#[test]
fn narrow_panel_renders_without_panic() {
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/long-repo-name"]);
let mut status = empty_status("feature/very-long-branch");
status.ahead = 3;
status.stashes.push(stash_entry(0));
status.worktree_info.push(worktree_entry("wt"));
list.repos[0].status = Some(status);
for width in 3..=6u16 {
let mut terminal = Terminal::new(TestBackend::new(width, 4)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let inner = width.saturating_sub(2);
let layout = row_layout(&list.repos, &[], inner);
let _ = indicator_columns(&list.repos[0], 1, &layout);
}
}
#[test]
fn focus_mode_keeps_selected_repos_subrows_bright() {
use ratatui::style::Modifier;
use ratatui::{Terminal, backend::TestBackend};
let mut list = make_list(&["/a", "/b"]);
let mut status = empty_status("main");
status.worktree_info.push(worktree_entry("feature"));
status.stashes.push(stash_entry(0));
list.expanded_stashes.insert(RepoId(PathBuf::from("/a")));
list.update_status(0, status);
list.repos[1].status = Some(empty_status("devel"));
list.select_repo_row(0);
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
let mut row_dimmed = |list: &mut RepoList, row_y: usize| {
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
terminal.backend().buffer().content[row_y * 40 + 2]
.style()
.add_modifier
.contains(Modifier::DIM)
};
assert!(!row_dimmed(&mut list, 1), "selected repo stays bright");
assert!(!row_dimmed(&mut list, 2), "its worktree stays bright");
assert!(!row_dimmed(&mut list, 3), "its stash stays bright");
assert!(row_dimmed(&mut list, 4), "the other repo dims");
for subrow in [1, 2] {
list.state.select(Some(subrow));
assert!(!row_dimmed(&mut list, 1), "parent repo stays bright");
assert!(!row_dimmed(&mut list, 2), "worktree stays bright");
assert!(!row_dimmed(&mut list, 3), "stash stays bright");
assert!(row_dimmed(&mut list, 4), "the other repo still dims");
}
}
#[test]
fn pinned_submodule_labels_as_nested_basename() {
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("parent");
let sub = parent.join("modules").join("dep");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join(".git"), "gitdir: ../../.git/modules/dep\n").unwrap();
let list = make_list(&[&parent.display().to_string(), &sub.display().to_string()]);
let (nested, label) = row_label(&list.repos, &list.repos[1]);
assert!(
nested,
"gitlink under a listed parent must nest without status"
);
assert_eq!(label, "dep");
let (parent_nested, parent_label) = row_label(&list.repos, &list.repos[0]);
assert!(!parent_nested);
assert_eq!(parent_label, "parent");
let layout = row_layout(&list.repos, &[], 60);
assert_eq!(layout.name_col, 11);
}
#[test]
fn plain_nested_repo_keeps_its_breadcrumb() {
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("parent");
let plain = parent.join("tools").join("side");
std::fs::create_dir_all(plain.join(".git")).unwrap();
let list = make_list(&[&parent.display().to_string(), &plain.display().to_string()]);
let (nested, _) = row_label(&list.repos, &list.repos[1]);
assert!(!nested);
}
#[test]
fn nested_submodule_row_draws_the_connector() {
use ratatui::{Terminal, backend::TestBackend};
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("parent");
let sub = parent.join("dep");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join(".git"), "gitdir: ../.git/modules/dep\n").unwrap();
let mut list = make_list(&[&parent.display().to_string(), &sub.display().to_string()]);
list.render_area = Rect::new(0, 0, 40, 6);
let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap();
terminal
.draw(|f| {
list.draw(f, f.area()).unwrap();
})
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect();
assert!(
text.contains("\u{2514} dep"),
"no nested connector in: {text}"
);
assert!(
!text.contains("parent/dep") && !text.contains("parent\\dep"),
"nested row still shows its breadcrumb: {text}"
);
}