use std::borrow::Cow;
use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, Instant};
use color_eyre::eyre::Result;
use ratatui::{Frame, buffer::Buffer, layout::Rect, style::Style};
use repon_core::{
Cell, DirtyCounts, EntityKey, EntityState, Filter, Head, Kind, RowSummary, Settled, Snapshot,
SyncState, WorktreeState, summary,
};
use super::Component;
use crate::{
config::Config,
glyphs::{BorderScratch, FULL_SPINNER_INTERVAL, GlyphSet},
selection::Selection,
sort::{RowOrder, SortColumn, order_candidates},
theme::{self, Meaning, Role, Theme},
};
const GUTTER_WIDTH: u16 = 1;
const SELECTED_WIDTH: u16 = 1;
const NAME_MIN_WIDTH: u16 = 28;
const NAME_MAX_WIDTH: u16 = 40;
const BRANCH_MIN_WIDTH: u16 = 24;
const BRANCH_MAX_WIDTH: u16 = 75;
const SYNC_WIDTH: u16 = 9;
const BASE_WIDTH: u16 = 6;
const DIRTY_WIDTH: u16 = 6;
const STATE_WIDTH: u16 = 10;
const BRANCH_CELL_OBJECT_ID_WIDTH: usize = 9;
const GAP: u16 = 1;
const NO_REPOS_MESSAGE: &str = "no repos";
const NO_MATCHES_MESSAGE: &str = "no matches";
const GUTTER_X: u16 = 0;
const SELECTED_X: u16 = GUTTER_X + GUTTER_WIDTH + GAP;
const NAME_X: u16 = SELECTED_X + SELECTED_WIDTH + GAP;
const PACKED_MIN_WIDTH: u16 = NAME_X
+ NAME_MIN_WIDTH
+ GAP
+ BRANCH_MIN_WIDTH
+ GAP
+ SYNC_WIDTH
+ GAP
+ BASE_WIDTH
+ GAP
+ DIRTY_WIDTH
+ GAP
+ STATE_WIDTH;
const HEADER_ROW: u16 = 0;
const FIRST_ENTITY_ROW: u16 = HEADER_ROW + 1;
const CHILD_ROW_MARKER_WIDTH: u16 = 1;
const CHILD_ROW_INDENT_WIDTH: u16 = 2;
const CHILD_ROW_GAP_WIDTH: u16 = GAP;
const CHILD_ROW_PREFIX_WIDTH: u16 =
CHILD_ROW_INDENT_WIDTH + CHILD_ROW_MARKER_WIDTH + CHILD_ROW_GAP_WIDTH;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Column {
x: u16,
width: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Columns {
name: Column,
branch: Column,
sync: Column,
base: Column,
dirty: Column,
state: Column,
}
impl Columns {
fn for_interior_width(width: u16) -> Self {
let (name_width, branch_width) =
grown_name_and_branch(width.saturating_sub(PACKED_MIN_WIDTH));
let name = Column {
x: NAME_X,
width: name_width,
};
let branch = Column {
x: name.x + name.width + GAP,
width: branch_width,
};
let sync = Column {
x: branch.x + branch.width + GAP,
width: SYNC_WIDTH,
};
let base = Column {
x: sync.x + sync.width + GAP,
width: BASE_WIDTH,
};
let dirty = Column {
x: base.x + base.width + GAP,
width: DIRTY_WIDTH,
};
let state = Column {
x: dirty.x + dirty.width + GAP,
width: STATE_WIDTH,
};
Columns {
name,
branch,
sync,
base,
dirty,
state,
}
}
fn for_sort_column(self, column: SortColumn) -> Column {
match column {
SortColumn::Name => self.name,
SortColumn::Branch => self.branch,
SortColumn::Sync => self.sync,
SortColumn::Base => self.base,
SortColumn::Dirty => self.dirty,
SortColumn::State => self.state,
}
}
fn child_name_width(self) -> u16 {
self.name.width - CHILD_ROW_PREFIX_WIDTH
}
}
fn grown_name_and_branch(slack: u16) -> (u16, u16) {
let name_growth = slack.min(NAME_MAX_WIDTH - NAME_MIN_WIDTH);
let branch_growth = (slack - name_growth).min(BRANCH_MAX_WIDTH - BRANCH_MIN_WIDTH);
(
NAME_MIN_WIDTH + name_growth,
BRANCH_MIN_WIDTH + branch_growth,
)
}
pub struct List {
glyphs: Option<&'static GlyphSet>,
started_at: Instant,
show_worktrees: bool,
show_submodules: bool,
show_ignored: bool,
filter: Filter,
pinned: HashSet<EntityKey>,
cursor: usize,
offset: usize,
theme: Theme,
selection: Selection,
row_order: RowOrder,
}
impl Default for List {
fn default() -> Self {
List {
glyphs: None,
started_at: Instant::now(),
show_worktrees: true,
show_submodules: false,
show_ignored: false,
filter: Filter::default(),
pinned: HashSet::new(),
cursor: 0,
offset: 0,
theme: Theme::default(),
selection: Selection::default(),
row_order: RowOrder::default(),
}
}
}
impl List {
fn glyphs(&self) -> &'static GlyphSet {
self.glyphs
.unwrap_or_else(|| GlyphSet::for_config(crate::config::document::Glyphs::default()))
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
snapshot: &Snapshot,
compact: bool,
focused: bool,
) {
let glyphs = self.glyphs();
let loading_frame = spinner_frame(
glyphs.loading,
FULL_SPINNER_INTERVAL,
self.started_at.elapsed(),
);
let visible_rows = visible_row_order(
&snapshot.entities,
Visibility {
worktrees: self.show_worktrees,
submodules: self.show_submodules,
ignored: self.show_ignored,
},
&self.filter,
self.row_order,
&self.pinned,
);
let border_role = if focused {
theme::Role::BorderFocused
} else {
theme::Role::Border
};
let mut scratch = BorderScratch::new();
let mut block = glyphs
.bordered_block(&mut scratch)
.border_style(self.theme.style_for(border_role))
.title(" repos ");
if let Some(counter) =
position_counter(visible_rows.len(), self.cursor, self.selection.count())
{
block = block.title_bottom(ratatui::text::Line::from(counter).right_aligned());
}
let interior = block.inner(area);
frame.render_widget(block, area);
let buf = frame.buffer_mut();
let first_row = if compact { 0 } else { FIRST_ENTITY_ROW };
let columns = Columns::for_interior_width(interior.width);
if !compact {
draw_header(buf, interior, columns, &self.theme, self.row_order, glyphs);
}
if visible_rows.is_empty() {
let message = if self.filter.is_active() {
NO_MATCHES_MESSAGE
} else {
NO_REPOS_MESSAGE
};
let y = interior.y + first_row;
if y < interior.bottom() {
write_cell(
buf,
interior,
interior.x,
y,
interior.width,
message,
self.theme.style_for(theme::Role::Dim),
);
}
}
let skip = visible_rows.len().saturating_sub(1).min(self.offset);
let cursor_screen_row = self.cursor_screen_row(skip);
let ctx = RowContext {
glyphs,
loading_frame,
theme: &self.theme,
columns,
};
let windowed_rows = &visible_rows[skip..];
let parent_visibilities = parent_visible_flags(&snapshot.entities, windowed_rows);
for (screen_row, (entity, parent_visibility)) in windowed_rows
.iter()
.copied()
.zip(parent_visibilities)
.map(|(index, parent_visibility)| (&snapshot.entities[index], parent_visibility))
.enumerate()
{
let Some(y) = interior.y.checked_add(first_row + screen_row as u16) else {
break;
};
if y >= interior.bottom() {
break;
}
let checked = self.selection.contains(&entity.key);
if compact {
draw_row_compact(buf, interior, y, entity, checked, parent_visibility, &ctx);
} else {
draw_row(buf, interior, y, entity, checked, parent_visibility, &ctx);
}
if Some(screen_row) == cursor_screen_row {
buf.set_style(
Rect::new(interior.x, y, interior.width, 1),
self.theme.selection_style(),
);
}
}
}
fn cursor_screen_row(&self, skip: usize) -> Option<usize> {
self.cursor.checked_sub(skip)
}
}
impl Component for List {
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.glyphs = Some(GlyphSet::for_config(config.document.glyphs));
self.show_worktrees = config.document.show_worktrees;
self.show_submodules = config.document.show_submodules;
Ok(())
}
fn draw(
&mut self,
frame: &mut Frame,
area: Rect,
snapshot: &Snapshot,
focused: bool,
) -> Result<()> {
self.render(frame, area, snapshot, false, focused);
Ok(())
}
}
impl List {
pub fn draw_sidebar(
&mut self,
frame: &mut Frame,
area: Rect,
snapshot: &Snapshot,
focused: bool,
) -> Result<()> {
self.render(frame, area, snapshot, true, focused);
Ok(())
}
pub(crate) fn set_filter(&mut self, filter: Filter) {
self.filter = filter;
}
pub(crate) fn set_pinned(&mut self, pinned: HashSet<EntityKey>) {
self.pinned = pinned;
}
pub(crate) fn set_cursor(&mut self, cursor: usize) {
self.cursor = cursor;
}
pub(crate) fn set_offset(&mut self, offset: usize) {
self.offset = offset;
}
pub(crate) fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
pub(crate) fn set_selection(&mut self, selection: Selection) {
self.selection = selection;
}
pub(crate) fn set_row_order(&mut self, order: RowOrder) {
self.row_order = order;
}
pub(crate) fn set_show_worktrees(&mut self, show_worktrees: bool) {
self.show_worktrees = show_worktrees;
}
pub(crate) fn set_show_ignored(&mut self, show_ignored: bool) {
self.show_ignored = show_ignored;
}
}
fn clipped_cell_width(interior: Rect, x: u16, width: u16) -> Option<u16> {
if x >= interior.right() {
None
} else {
Some(width.min(interior.right() - x))
}
}
fn write_cell(
buf: &mut Buffer,
interior: Rect,
x: u16,
y: u16,
width: u16,
text: &str,
style: Style,
) {
let Some(max_width) = clipped_cell_width(interior, x, width) else {
return;
};
buf.set_stringn(x, y, text, max_width as usize, style);
}
struct TruncatingText<'a> {
text: &'a str,
mark: char,
}
fn write_truncating_cell(
buf: &mut Buffer,
interior: Rect,
x: u16,
y: u16,
width: u16,
content: TruncatingText,
style: Style,
) {
let Some(max_width) = clipped_cell_width(interior, x, width) else {
return;
};
let content = truncate_with_mark(content.text, max_width, content.mark);
buf.set_stringn(x, y, &content, max_width as usize, style);
}
fn truncate_with_mark(text: &str, max_width: u16, mark: char) -> std::borrow::Cow<'_, str> {
use unicode_segmentation::UnicodeSegmentation;
if ratatui::text::Span::raw(text).width() <= max_width as usize {
return std::borrow::Cow::Borrowed(text);
}
if max_width == 0 {
return std::borrow::Cow::Borrowed("");
}
let budget = (max_width - 1) as usize;
let mut kept = String::new();
let mut column = 0usize;
for grapheme in text.graphemes(true) {
let grapheme_width = ratatui::text::Span::raw(grapheme).width();
if column + grapheme_width > budget {
break;
}
column += grapheme_width;
kept.push_str(grapheme);
}
kept.push(mark);
std::borrow::Cow::Owned(kept)
}
pub(crate) fn write_cell_runs(
buf: &mut Buffer,
interior: Rect,
x: u16,
y: u16,
width: u16,
runs: &[(String, Style)],
) {
if x >= interior.right() {
return;
}
let end = x.saturating_add(width).min(interior.right());
let mut cursor = x;
for (text, style) in runs {
if cursor >= end {
break;
}
let (next_x, _) = buf.set_stringn(cursor, y, text, (end - cursor) as usize, *style);
cursor = next_x;
}
}
fn position_counter(total: usize, cursor: usize, checked: usize) -> Option<String> {
if total == 0 {
return None;
}
let position = cursor.saturating_add(1).min(total);
Some(if checked > 0 {
format!("{position}/{total}/{checked}")
} else {
format!("{position}/{total}")
})
}
pub(crate) fn kind_is_visible(
kind: Kind,
show_worktrees: bool,
show_submodules: bool,
filter: &Filter,
) -> bool {
match kind {
Kind::Repo => true,
Kind::Worktree => show_worktrees || filter.requests_kind(Kind::Worktree),
Kind::Submodule => show_submodules || filter.requests_kind(Kind::Submodule),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Visibility {
pub(crate) worktrees: bool,
pub(crate) submodules: bool,
pub(crate) ignored: bool,
}
impl Visibility {
#[cfg(test)]
pub(crate) fn everything() -> Self {
Visibility {
worktrees: true,
submodules: true,
ignored: true,
}
}
}
pub(crate) fn visible_row_order(
entities: &[EntityState],
visibility: Visibility,
filter: &Filter,
order: RowOrder,
pinned: &HashSet<EntityKey>,
) -> Vec<usize> {
let mut candidates: Vec<usize> = (0..entities.len())
.filter(|&index| {
let entity = &entities[index];
kind_is_visible(
entity.kind,
visibility.worktrees,
visibility.submodules,
filter,
) && (visibility.ignored || !entity.excluded)
&& (filter.matches(entity) || pinned.contains(&entity.key))
})
.collect();
order_candidates(entities, &mut candidates, order);
grouped_row_order(entities, &candidates)
}
fn group_key(entity: &EntityState) -> &Path {
match entity.kind {
Kind::Repo | Kind::Worktree => &entity.common_dir,
Kind::Submodule => entity
.common_dir
.parent()
.and_then(Path::parent)
.unwrap_or(&entity.common_dir),
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ParentVisibility {
Connected,
Orphaned,
}
impl ParentVisibility {
fn from_traces_to_a_visible_repo(traces_to_a_visible_repo: bool) -> Self {
if traces_to_a_visible_repo {
Self::Connected
} else {
Self::Orphaned
}
}
}
fn parent_visible_flags(entities: &[EntityState], visible_rows: &[usize]) -> Vec<ParentVisibility> {
let mut flags = Vec::with_capacity(visible_rows.len());
let mut previous: Option<(&Path, bool)> = None;
for &index in visible_rows {
let entity = &entities[index];
let key = group_key(entity);
let parent_visible =
previous.is_some_and(|(prev_key, prev_attached)| prev_key == key && prev_attached);
let attached_to_a_visible_repo = matches!(entity.kind, Kind::Repo) || parent_visible;
flags.push(ParentVisibility::from_traces_to_a_visible_repo(
parent_visible,
));
previous = Some((key, attached_to_a_visible_repo));
}
flags
}
pub(crate) fn grouped_row_order(entities: &[EntityState], candidates: &[usize]) -> Vec<usize> {
let mut order = Vec::with_capacity(candidates.len());
let mut placed = vec![false; candidates.len()];
for (position, &index) in candidates.iter().enumerate() {
let entity = &entities[index];
if !matches!(entity.kind, Kind::Repo) {
continue;
}
order.push(index);
placed[position] = true;
let repo_common_dir: &Path = &entity.common_dir;
for (child_position, &child_index) in candidates.iter().enumerate() {
if placed[child_position] {
continue;
}
let child = &entities[child_index];
if matches!(child.kind, Kind::Repo) {
continue;
}
if group_key(child) == repo_common_dir {
order.push(child_index);
placed[child_position] = true;
}
}
}
for (position, already_placed) in placed.into_iter().enumerate() {
if !already_placed {
order.push(candidates[position]);
}
}
order
}
fn is_child_row(kind: Kind) -> bool {
match kind {
Kind::Repo => false,
Kind::Worktree | Kind::Submodule => true,
}
}
pub(crate) fn name_cell_meaning(kind: Kind) -> Meaning {
match kind {
Kind::Repo => Meaning::FreshValue,
Kind::Worktree => Meaning::WorktreeName,
Kind::Submodule => Meaning::SubmoduleName,
}
}
fn draw_name_cell(
buf: &mut Buffer,
interior: Rect,
y: u16,
entity: &EntityState,
parent_visibility: ParentVisibility,
ctx: &RowContext,
) {
let RowContext {
glyphs,
theme,
columns,
..
} = *ctx;
let name_style = theme.style_for(name_cell_meaning(entity.kind).role());
let (name_x, name_width) = if is_child_row(entity.kind) {
let marker_x = interior.x + columns.name.x + CHILD_ROW_INDENT_WIDTH;
let marker = match parent_visibility {
ParentVisibility::Connected => glyphs.child_row,
ParentVisibility::Orphaned => glyphs.orphan_child_row,
};
write_cell(
buf,
interior,
marker_x,
y,
CHILD_ROW_MARKER_WIDTH,
&marker.to_string(),
Style::new(),
);
(
marker_x + CHILD_ROW_MARKER_WIDTH + CHILD_ROW_GAP_WIDTH,
columns.child_name_width(),
)
} else {
(interior.x + columns.name.x, columns.name.width)
};
write_truncating_cell(
buf,
interior,
name_x,
y,
name_width,
TruncatingText {
text: &ignore_marked(entity, glyphs),
mark: glyphs.truncated,
},
name_style,
);
}
fn ignore_marked<'a>(entity: &'a EntityState, glyphs: &'static GlyphSet) -> Cow<'a, str> {
if entity.excluded {
Cow::Owned(format!("{} {}", glyphs.ignored, entity.name))
} else {
Cow::Borrowed(&entity.name)
}
}
fn draw_header(
buf: &mut Buffer,
interior: Rect,
columns: Columns,
theme: &Theme,
order: RowOrder,
glyphs: &'static GlyphSet,
) {
let y = interior.y + HEADER_ROW;
let style = theme.style_for(theme::Role::Dim);
for sort_column in SortColumn::ALL {
let column = columns.for_sort_column(sort_column);
let label = match order.arrow_for(sort_column, glyphs) {
Some(arrow) => format!("{}{arrow}", sort_column.label()),
None => sort_column.label().to_string(),
};
write_cell(
buf,
interior,
interior.x + column.x,
y,
column.width,
&label,
style,
);
}
}
fn draw_selected_marker(
buf: &mut Buffer,
interior: Rect,
y: u16,
checked: bool,
glyphs: &'static GlyphSet,
theme: &Theme,
) {
let marker = if checked {
glyphs.checked.to_string()
} else {
" ".to_string()
};
write_cell(
buf,
interior,
interior.x + SELECTED_X,
y,
SELECTED_WIDTH,
&marker,
theme.checked_style(),
);
}
#[derive(Clone, Copy)]
struct RowContext<'a> {
glyphs: &'static GlyphSet,
loading_frame: char,
theme: &'a Theme,
columns: Columns,
}
fn draw_row(
buf: &mut Buffer,
interior: Rect,
y: u16,
entity: &EntityState,
checked: bool,
parent_visibility: ParentVisibility,
ctx: &RowContext,
) {
let RowContext {
glyphs,
loading_frame,
theme,
columns,
} = *ctx;
let row_summary = summary(entity);
let gutter = gutter_glyph_for(row_summary, glyphs, loading_frame).to_string();
let cell_loading_glyph = (row_summary != RowSummary::InFlight).then_some(loading_frame);
write_cell(
buf,
interior,
interior.x + GUTTER_X,
y,
GUTTER_WIDTH,
&gutter,
Style::new(),
);
draw_selected_marker(buf, interior, y, checked, glyphs, theme);
draw_name_cell(buf, interior, y, entity, parent_visibility, ctx);
write_truncating_cell(
buf,
interior,
interior.x + columns.branch.x,
y,
columns.branch.width,
TruncatingText {
text: &format_head(&entity.branch, cell_loading_glyph),
mark: glyphs.truncated,
},
theme.style_for(cell_role(
entity.branch.settled(),
|_| Meaning::FreshValue,
cell_loading_glyph,
)),
);
write_cell_runs(
buf,
interior,
interior.x + columns.sync.x,
y,
columns.sync.width,
&sync_cell_runs(&entity.sync, glyphs, cell_loading_glyph)
.into_iter()
.map(|(text, role)| (text, theme.style_for(role)))
.collect::<Vec<_>>(),
);
write_cell(
buf,
interior,
interior.x + columns.base.x,
y,
columns.base.width,
&format_base(&entity.base, glyphs, cell_loading_glyph),
theme.style_for(cell_role(
entity.base.settled(),
base_meaning,
cell_loading_glyph,
)),
);
write_cell(
buf,
interior,
interior.x + columns.dirty.x,
y,
columns.dirty.width,
&format_dirty(&entity.dirty, glyphs, cell_loading_glyph),
theme.style_for(cell_role(
entity.dirty.settled(),
dirty_meaning,
cell_loading_glyph,
)),
);
write_cell(
buf,
interior,
interior.x + columns.state.x,
y,
columns.state.width,
&format_state(&entity.state, cell_loading_glyph),
theme.style_for(cell_role(
entity.state.settled(),
state_meaning,
cell_loading_glyph,
)),
);
}
fn draw_row_compact(
buf: &mut Buffer,
interior: Rect,
y: u16,
entity: &EntityState,
checked: bool,
parent_visibility: ParentVisibility,
ctx: &RowContext,
) {
let RowContext {
glyphs,
loading_frame,
theme,
..
} = *ctx;
let gutter = gutter_glyph(entity, glyphs, loading_frame).to_string();
write_cell(
buf,
interior,
interior.x + GUTTER_X,
y,
GUTTER_WIDTH,
&gutter,
Style::new(),
);
draw_selected_marker(buf, interior, y, checked, glyphs, theme);
draw_name_cell(buf, interior, y, entity, parent_visibility, ctx);
}
pub(crate) fn spinner_frame(
loading: &'static [char],
interval: Duration,
elapsed: Duration,
) -> char {
let millis_per_frame = interval.as_millis().max(1);
let step = (elapsed.as_millis() / millis_per_frame) as usize;
loading[step % loading.len()]
}
fn gutter_glyph_for(
row_summary: RowSummary,
glyphs: &'static GlyphSet,
loading_frame: char,
) -> char {
match row_summary {
RowSummary::Fresh => glyphs.fresh,
RowSummary::Stale => glyphs.stale,
RowSummary::Unknown => glyphs.unknown,
RowSummary::Failed => glyphs.failed,
RowSummary::InFlight => loading_frame,
}
}
fn gutter_glyph(entity: &EntityState, glyphs: &'static GlyphSet, loading_frame: char) -> char {
gutter_glyph_for(summary(entity), glyphs, loading_frame)
}
enum CellShape<'a, T> {
Known(&'a T),
Blank,
Loading(char),
}
fn cell_shape<T>(settled: Option<&Settled<T>>, loading_glyph: Option<char>) -> CellShape<'_, T> {
match settled {
Some(Settled::Known {
value,
at: _,
stale: _,
}) => CellShape::Known(value),
Some(Settled::Unknown(_)) => CellShape::Blank,
Some(Settled::Failed(_)) => CellShape::Blank,
Some(Settled::NotApplicable) => CellShape::Blank,
None => match loading_glyph {
Some(glyph) => CellShape::Loading(glyph),
None => CellShape::Blank,
},
}
}
fn render_cell<T>(
settled: Option<&Settled<T>>,
format: impl FnOnce(&T) -> String,
loading_glyph: Option<char>,
) -> String {
match cell_shape(settled, loading_glyph) {
CellShape::Known(value) => format(value),
CellShape::Blank => String::new(),
CellShape::Loading(glyph) => glyph.to_string(),
}
}
fn cell_role<T>(
settled: Option<&Settled<T>>,
meaning_for_value: impl FnOnce(&T) -> Meaning,
loading_glyph: Option<char>,
) -> Role {
match cell_shape(settled, loading_glyph) {
CellShape::Known(value) => meaning_for_value(value).role(),
CellShape::Blank => Meaning::FreshValue.role(),
CellShape::Loading(_) => Meaning::LoadingSpinner.role(),
}
}
fn format_head(cell: &Cell<Head>, loading_glyph: Option<char>) -> String {
render_cell(cell.settled(), head_text, loading_glyph)
}
pub(crate) fn head_text(value: &Head) -> String {
match value {
Head::Branch { name, .. } | Head::Unborn(name) => name.to_string(),
Head::Detached(oid) => oid
.to_string()
.chars()
.take(BRANCH_CELL_OBJECT_ID_WIDTH)
.collect(),
}
}
fn sync_value_runs(value: &SyncState, glyphs: &'static GlyphSet) -> Vec<(String, Meaning)> {
match value {
SyncState::NoRemote => vec![(glyphs.no_remote.to_string(), Meaning::FreshValue)],
SyncState::NoUpstream => vec![(glyphs.no_upstream.to_string(), Meaning::FreshValue)],
SyncState::Tracking(counts) if counts.ahead == 0 && counts.behind == 0 => {
vec![(glyphs.in_sync.to_string(), Meaning::KnownZero)]
}
SyncState::Tracking(counts) => {
let mut runs = Vec::new();
if counts.ahead > 0 {
runs.push((
format!("{}{}", glyphs.ahead, counts.ahead),
Meaning::AheadCount,
));
}
if counts.behind > 0 {
runs.push((
format!("{}{}", glyphs.behind, counts.behind),
Meaning::BehindCount,
));
}
runs
}
}
}
#[allow(dead_code)]
fn sync_glyph(value: &SyncState, glyphs: &'static GlyphSet) -> String {
sync_value_runs(value, glyphs)
.into_iter()
.map(|(text, _)| text)
.collect::<Vec<_>>()
.join(" ")
}
#[allow(dead_code)]
fn format_sync(
cell: &Cell<SyncState>,
glyphs: &'static GlyphSet,
loading_glyph: Option<char>,
) -> String {
render_cell(
cell.settled(),
|value| sync_glyph(value, glyphs),
loading_glyph,
)
}
fn sync_cell_runs(
cell: &Cell<SyncState>,
glyphs: &'static GlyphSet,
loading_glyph: Option<char>,
) -> Vec<(String, Role)> {
match cell_shape(cell.settled(), loading_glyph) {
CellShape::Known(value) => {
let mut runs = sync_value_runs(value, glyphs).into_iter();
let mut out = Vec::new();
if let Some((text, meaning)) = runs.next() {
out.push((text, meaning.role()));
}
for (text, meaning) in runs {
out.push((" ".to_string(), Role::Text));
out.push((text, meaning.role()));
}
out
}
CellShape::Blank => Vec::new(),
CellShape::Loading(glyph) => vec![(glyph.to_string(), Meaning::LoadingSpinner.role())],
}
}
fn format_base(cell: &Cell<u32>, glyphs: &'static GlyphSet, loading_glyph: Option<char>) -> String {
render_cell(
cell.settled(),
|value| {
if *value == 0 {
glyphs.in_sync.to_string()
} else {
format!("{}{}", glyphs.behind, value)
}
},
loading_glyph,
)
}
pub(crate) fn base_meaning(value: &u32) -> Meaning {
if *value == 0 {
Meaning::KnownZero
} else {
Meaning::BehindCount
}
}
fn format_dirty(
cell: &Cell<DirtyCounts>,
glyphs: &'static GlyphSet,
loading_glyph: Option<char>,
) -> String {
render_cell(
cell.settled(),
|value| {
let total = value.total();
if total == 0 {
glyphs.clean.to_string()
} else {
format!("{}{}", glyphs.changed, total)
}
},
loading_glyph,
)
}
pub(crate) fn dirty_meaning(value: &DirtyCounts) -> Meaning {
if value.total() == 0 {
Meaning::KnownZero
} else {
Meaning::Dirty
}
}
pub(crate) fn worktree_state_word(value: &WorktreeState) -> &'static str {
match value {
WorktreeState::Merged => "merged",
WorktreeState::Gone => "gone",
WorktreeState::LocalOnly => "local only",
WorktreeState::Active => "active",
}
}
fn format_state(cell: &Cell<WorktreeState>, loading_glyph: Option<char>) -> String {
render_cell(
cell.settled(),
|value| worktree_state_word(value).to_string(),
loading_glyph,
)
}
pub(crate) fn state_meaning(value: &WorktreeState) -> Meaning {
match value {
WorktreeState::Merged => Meaning::MergedWorktree,
WorktreeState::Gone => Meaning::GoneWorktree,
WorktreeState::LocalOnly => Meaning::LocalOnly,
WorktreeState::Active => Meaning::ActiveWorktree,
}
}
#[cfg(test)]
mod tests {
use std::{path::Path, sync::Arc};
use ratatui::{
Terminal,
backend::TestBackend,
style::{Color, Modifier},
};
use repon_core::{
AheadBehind, EntityKey, EntityState, Generation, Kind, ProbeError, RowSummary, Snapshot,
Timestamp, Unknown,
};
use crate::app::SIDEBAR_WIDTH;
use super::*;
fn entity(name: &str) -> EntityState {
EntityState::new(
EntityKey::new(Arc::from(Path::new(name))),
Arc::from(name),
Arc::from(Path::new(name)),
Kind::Repo,
)
}
fn snapshot(entities: Vec<EntityState>) -> Snapshot {
Snapshot {
generation: Generation::default(),
discovered_at: Timestamp::now(),
entities,
}
}
fn render_with_list(
list: &mut List,
width: u16,
height: u16,
snapshot: &Snapshot,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw(frame, area, snapshot, true)
.expect("draw the list");
})
.expect("draw the frame");
terminal
}
fn render(width: u16, height: u16, snapshot: &Snapshot) -> Terminal<TestBackend> {
render_with_list(&mut List::default(), width, height, snapshot)
}
fn render_sidebar(width: u16, height: u16, snapshot: &Snapshot) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
let mut list = List::default();
terminal
.draw(|frame| {
let area = frame.area();
list.draw_sidebar(frame, area, snapshot, true)
.expect("draw the sidebar");
})
.expect("draw the frame");
terminal
}
#[test]
fn the_sidebar_keeps_the_same_rows_in_the_same_order_as_the_full_list() {
let terminal = render_sidebar(
SIDEBAR_WIDTH,
24,
&snapshot(vec![entity("first"), entity("second")]),
);
let buf = terminal.backend().buffer();
assert_eq!(cell_text(buf, name_x(buf), 1, 5), "first");
assert_eq!(cell_text(buf, name_x(buf), 2, 6), "second");
}
fn init_repo_on_branch(path: &Path, branch: &str) {
std::fs::create_dir_all(path).expect("create repo dir");
let status = std::process::Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", branch])
.arg(path)
.status()
.expect("run git init");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(["commit", "--allow-empty", "-m", "first"])
.status()
.expect("run git commit");
assert!(status.success());
}
fn settled_snapshot_with_a_known_branch(branch: &str) -> repon_core::Snapshot {
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_on_branch(&root, branch);
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
core.settle()
}
fn init_unborn_repo_on_branch(path: &Path, branch: &str) {
std::fs::create_dir_all(path).expect("create repo dir");
let status = std::process::Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", branch])
.arg(path)
.status()
.expect("run git init");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args([
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
])
.status()
.expect("run git remote add");
assert!(status.success());
}
fn settled_snapshot_with_a_resolvable_default_branch(branch: &str) -> repon_core::Snapshot {
use repon_core::{Core, CoreSpec, RepoOverride, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_unborn_repo_on_branch(&root, branch);
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root.clone()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: vec![RepoOverride {
path: root,
default_branch: Some(branch.to_string()),
excluded: false,
}],
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
core.settle()
}
fn settled_snapshot_with_a_nonzero_base_and_dirty_count() -> repon_core::Snapshot {
use repon_core::{Core, CoreSpec, RepoOverride, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_on_branch(&root, "main");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args([
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
])
.status()
.expect("run git remote add");
assert!(status.success());
std::fs::write(root.join("second.txt"), "second").expect("write second file");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["add", "second.txt"])
.status()
.expect("run git add");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(["commit", "-m", "second"])
.status()
.expect("run git commit second");
assert!(status.success());
let output = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["rev-parse", "main"])
.output()
.expect("run git rev-parse main");
assert!(output.status.success());
let main_sha = String::from_utf8(output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
let status = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["update-ref", "refs/remotes/origin/main", &main_sha])
.status()
.expect("run git update-ref");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["checkout", "--quiet", "-b", "feature", "HEAD~1"])
.status()
.expect("run git checkout -b feature HEAD~1");
assert!(status.success());
std::fs::write(root.join("untracked.txt"), "scratch").expect("write untracked file");
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root.clone()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: vec![RepoOverride {
path: root,
default_branch: Some("main".to_string()),
excluded: false,
}],
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
core.settle()
}
fn role_named_in_theming_md(needle: &str) -> Role {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/theming.md"))
.expect("read the theming specification");
const HEADING: &str = "### The map from meaning to role";
let after_heading = &spec[spec
.find(HEADING)
.expect("theming.md must contain the meaning-to-role heading")
+ HEADING.len()..];
let row = after_heading
.lines()
.map(str::trim)
.filter(|line| line.starts_with('|'))
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no meaning-to-role row in theming.md contains {needle:?}"));
let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect();
let [meanings_cell, roles_cell] = cells.as_slice() else {
panic!("theming.md meaning-to-role row does not have exactly two cells: {row:?}");
};
let phrases: Vec<&str> = meanings_cell.split(',').map(str::trim).collect();
let roles: Vec<&str> = roles_cell
.split('/')
.map(|key| key.trim().trim_matches('`'))
.collect();
let phrase_index = phrases
.iter()
.position(|phrase| phrase.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} not found among the row's own phrases: {row:?}"));
let leading_count = phrases.len() - (roles.len() - 1);
let role_key = if phrase_index < leading_count {
roles[0]
} else {
roles[phrase_index - leading_count + 1]
};
Role::ALL
.into_iter()
.find(|role| role.spec_key() == role_key)
.unwrap_or_else(|| panic!("theming.md names an unknown role `{role_key}`"))
}
#[test]
fn two_adjacent_value_cells_take_their_own_meanings_role_not_one_flat_row_style() {
let snapshot = settled_snapshot_with_a_nonzero_base_and_dirty_count();
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let mut list = List::default();
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
assert_eq!(
cell_text(buf, base_x(buf), y, 2),
"↓1",
"sanity: base must show a nonzero behind count"
);
assert_eq!(
cell_text(buf, dirty_x(buf), y, 2),
"●1",
"sanity: dirty must show a nonzero changed count"
);
let base_role = role_named_in_theming_md("Behind count");
let dirty_role = role_named_in_theming_md("Dirty");
assert_ne!(
base_role, dirty_role,
"sanity: the fixture must exercise two different roles"
);
let base_fg = buf[(base_x(buf), y)].fg;
let dirty_fg = buf[(dirty_x(buf), y)].fg;
assert_eq!(
base_fg,
theme::DEFAULT.role_color(base_role),
"base's nonzero count must take theming.md's own `behind` role"
);
assert_eq!(
dirty_fg,
theme::DEFAULT.role_color(dirty_role),
"dirty's nonzero count must take theming.md's own `warn` role"
);
assert_ne!(
base_fg, dirty_fg,
"two adjacent cells with different meanings must render in different colours, \
which a flat row style applied to the whole row cannot produce"
);
}
#[test]
fn a_worktree_and_a_submodule_take_their_own_named_role_while_the_parent_repo_takes_the_default()
{
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (repo_row, _) = find_entity_row(&snapshot, "parent");
let (worktree_row, _) = find_entity_row(&snapshot, "feature-worktree");
let (submodule_row, _) = find_entity_row(&snapshot, "vendor/lib");
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let repo_fg = buf[(name_x(buf), entity_row_y(repo_row))].fg;
let worktree_fg = buf[(child_name_x(buf), entity_row_y(worktree_row))].fg;
let submodule_fg = buf[(child_name_x(buf), entity_row_y(submodule_row))].fg;
assert_eq!(
repo_fg,
theme::DEFAULT.role_color(role_named_in_theming_md("Fresh value")),
"a Repo name has no entry of its own in theming.md's map, so it takes `text`"
);
assert_eq!(
worktree_fg,
theme::DEFAULT.role_color(role_named_in_theming_md("Worktree name")),
"a Worktree name must take theming.md's own `accent` role"
);
assert_eq!(
submodule_fg,
theme::DEFAULT.role_color(role_named_in_theming_md("Submodule name")),
"a Submodule name must take theming.md's own `dim` role"
);
assert_ne!(worktree_fg, submodule_fg);
assert_ne!(repo_fg, worktree_fg);
}
#[test]
fn the_sidebar_shows_only_the_gutter_and_the_name_never_the_other_columns() {
let snapshot = settled_snapshot_with_a_known_branch("a-real-branch-name");
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let full = render(140, 24, &snapshot);
let full_buf = full.backend().buffer();
assert_eq!(
cell_text(full_buf, branch_x(full_buf), 2, 19).trim_end(),
"a-real-branch-name",
"the full list must show the real branch value at its usual column"
);
const {
assert!(
SIDEBAR_WIDTH - 2 < PACKED_MIN_WIDTH,
"the sidebar's interior must stay below the width at which any column grows"
);
assert!(
SIDEBAR_WIDTH - 2 == GUTTER_WIDTH + GAP + SELECTED_WIDTH + GAP + NAME_MIN_WIDTH,
"the sidebar's interior must be exactly the gutter, the marker and a minimum \
name"
);
}
let compact = render_sidebar(SIDEBAR_WIDTH, 24, &snapshot);
let compact_row = cell_text(compact.backend().buffer(), 0, 1, SIDEBAR_WIDTH);
assert!(
!compact_row.contains("a-real-branch-name"),
"the sidebar must never draw the branch column, even for a row that has one: \
{compact_row:?}"
);
}
#[test]
fn an_unborn_rows_base_settles_not_applicable_and_renders_blank_rather_than_spinning() {
let snapshot = settled_snapshot_with_a_resolvable_default_branch("main");
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
assert_eq!(
repon_core::summary(&snapshot.entities[0]),
RowSummary::Fresh,
"sanity check: branch and default_branch must both have settled Known already"
);
assert!(
matches!(
snapshot.entities[0].base.settled(),
Some(repon_core::Settled::NotApplicable)
),
"sanity check: base's own settled shape must be Not applicable, not Unknown, so \
this test proves the criterion rather than merely a shape that also renders \
blank"
);
let name = snapshot.entities[0].name.to_string();
let terminal = render(140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, 1, 2, 1),
" ",
"the gutter must show the row's least-settled settled state, not an outstanding \
cell's own loading mark"
);
assert_eq!(cell_text(buf, name_x(buf), 2, name.len() as u16), name);
assert_eq!(cell_text(buf, branch_x(buf), 2, 4), "main");
assert_eq!(
cell_text(buf, sync_x(buf), 2, 1),
glyphs.no_upstream.to_string(),
"sync is probed, and an unborn HEAD has no branch to configure an upstream on, \
so it must show its settled value rather than a loading mark"
);
assert_eq!(
cell_text(buf, base_x(buf), 2, BASE_WIDTH),
" ".repeat(BASE_WIDTH as usize),
"base is Not applicable on an unborn HEAD and must render blank, never the \
loading mark and never a raw zero"
);
assert_eq!(
cell_text(buf, dirty_x(buf), 2, 1),
glyphs.clean.to_string(),
"dirty is probed too, and this fixture's working tree is clean, so it must show \
its settled value rather than a loading mark"
);
}
#[test]
fn a_row_that_holds_no_value_at_all_shows_its_one_spinner_in_the_gutter_and_every_cell_blank() {
let terminal = render(140, 24, &snapshot(vec![entity("never-probed")]));
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(cell_text(buf, 1, 2, 1), glyphs.loading[0].to_string());
for x in [
branch_x(buf),
sync_x(buf),
base_x(buf),
dirty_x(buf),
state_x(buf),
] {
assert_eq!(
cell_text(buf, x, 2, 1),
" ",
"column at x={x} must stay blank while the row holds no value at all"
);
}
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), 2, 1),
" ",
"an unchecked row must show a blank marker column, not the checked glyph"
);
}
#[test]
fn two_rows_in_one_render_are_computed_independently_and_no_spinner_leaks_across_rows() {
let never_probed = entity("never-probed");
let settled = settled_snapshot_with_a_resolvable_default_branch("main")
.entities
.into_iter()
.next()
.expect("expected one discovered repo");
assert_eq!(repon_core::summary(&settled), RowSummary::Fresh);
let terminal = render(140, 24, &snapshot(vec![never_probed, settled]));
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let frame = glyphs.loading[0].to_string();
assert_eq!(cell_text(buf, 1, 2, 1), frame);
assert_eq!(cell_text(buf, base_x(buf), 2, 1), " ");
assert_eq!(cell_text(buf, 1, 3, 1), " ");
assert_eq!(cell_text(buf, base_x(buf), 3, 1), " ");
}
#[test]
fn spinner_frame_advances_a_step_every_interval_and_wraps_around() {
let loading = &['a', 'b', 'c'];
let interval = Duration::from_millis(80);
assert_eq!(
spinner_frame(loading, interval, Duration::from_millis(0)),
'a'
);
assert_eq!(
spinner_frame(loading, interval, Duration::from_millis(79)),
'a'
);
assert_eq!(
spinner_frame(loading, interval, Duration::from_millis(80)),
'b'
);
assert_eq!(
spinner_frame(loading, interval, Duration::from_millis(160)),
'c'
);
assert_eq!(
spinner_frame(loading, interval, Duration::from_millis(240)),
'a',
"must wrap around rather than stopping at the last frame"
);
}
#[test]
fn the_gutters_loading_frame_advances_as_the_components_own_clock_moves_forward() {
let snap = snapshot(vec![entity("never-probed")]);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let mut at_zero = List {
started_at: Instant::now(),
..List::default()
};
let terminal_zero = render_with_list(&mut at_zero, 140, 24, &snap);
let frame_zero = cell_text(terminal_zero.backend().buffer(), 1, 2, 1);
let mut two_steps_later = List {
started_at: Instant::now() - FULL_SPINNER_INTERVAL * 2,
..List::default()
};
let terminal_later = render_with_list(&mut two_steps_later, 140, 24, &snap);
let frame_later = cell_text(terminal_later.backend().buffer(), 1, 2, 1);
assert_eq!(frame_zero, glyphs.loading[0].to_string());
assert_eq!(frame_later, glyphs.loading[2].to_string());
assert_ne!(
frame_zero, frame_later,
"the gutter's loading mark must move rather than freezing on its first frame"
);
}
#[test]
fn a_row_that_already_shows_its_cheap_columns_still_animates_its_outstanding_cell_on_refresh() {
let mut snap = settled_snapshot_with_a_resolvable_default_branch("main");
snap.entities[0].base = repon_core::Cell::default();
assert!(
snap.entities[0].base.settled().is_none(),
"sanity check: the claim below is about a cell with nothing settled in it yet"
);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let mut at_zero = List {
started_at: Instant::now(),
..List::default()
};
let first_tick = render_with_list(&mut at_zero, 140, 24, &snap);
let base_first = {
let buf = first_tick.backend().buffer();
cell_text(buf, base_x(buf), 2, 1)
};
let mut later = List {
started_at: Instant::now() - FULL_SPINNER_INTERVAL * 5,
..List::default()
};
let second_tick = render_with_list(&mut later, 140, 24, &snap);
let base_second = {
let buf = second_tick.backend().buffer();
cell_text(buf, base_x(buf), 2, 1)
};
assert_eq!(base_first, glyphs.loading[0].to_string());
assert_eq!(base_second, glyphs.loading[5].to_string());
assert_ne!(
base_first, base_second,
"an already-populated row's outstanding cell must show moving spinner frames on \
refresh, never a static screen"
);
}
#[test]
fn the_sidebar_draws_no_header_row() {
let terminal = render_sidebar(
SIDEBAR_WIDTH,
24,
&snapshot(vec![entity("acquiring-gateway")]),
);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, name_x(buf), 1, 17),
"acquiring-gateway",
"with no header row, the first entity must render one row below the border"
);
}
#[test]
fn set_offset_skips_that_many_leading_rows() {
let mut list = List::default();
list.set_offset(1);
let snap = snapshot(vec![
entity("repo-one"),
entity("repo-two"),
entity("repo-three"),
]);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, name_x(buf), 1 + FIRST_ENTITY_ROW, 8),
"repo-two",
"an offset of 1 must skip the first row and start drawing from the second"
);
}
#[test]
fn a_stale_offset_past_the_row_count_is_clamped_rather_than_blanking_the_list() {
let mut list = List::default();
list.set_offset(100);
let snap = snapshot(vec![
entity("repo-one"),
entity("repo-two"),
entity("repo-three"),
]);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, name_x(buf), 1 + FIRST_ENTITY_ROW, 10),
"repo-three",
"a wildly stale offset must still leave the table's own last row drawn"
);
}
fn cell_text(buf: &Buffer, x: u16, y: u16, len: u16) -> String {
(0..len)
.map(|offset| buf[(x + offset, y)].symbol().to_string())
.collect()
}
#[test]
fn the_header_row_places_every_column_name_at_its_literal_spec_offset() {
let at_minimum = render(94, 24, &snapshot(vec![]));
let buf = at_minimum.backend().buffer();
assert_eq!(cell_text(buf, 5, 1, 4), "name");
assert_eq!(cell_text(buf, 34, 1, 6), "branch");
assert_eq!(cell_text(buf, 59, 1, 4), "sync");
assert_eq!(cell_text(buf, 69, 1, 4), "base");
assert_eq!(cell_text(buf, 76, 1, 5), "dirty");
assert_eq!(cell_text(buf, 83, 1, 5), "state");
let mid = render(140, 24, &snapshot(vec![]));
let buf = mid.backend().buffer();
assert_eq!(cell_text(buf, 5, 1, 4), "name");
assert_eq!(cell_text(buf, 46, 1, 6), "branch");
assert_eq!(cell_text(buf, 105, 1, 4), "sync");
assert_eq!(cell_text(buf, 115, 1, 4), "base");
assert_eq!(cell_text(buf, 122, 1, 5), "dirty");
assert_eq!(cell_text(buf, 129, 1, 5), "state");
let past_both_caps = render(220, 24, &snapshot(vec![]));
let buf = past_both_caps.backend().buffer();
assert_eq!(cell_text(buf, 5, 1, 4), "name");
assert_eq!(cell_text(buf, 46, 1, 6), "branch");
assert_eq!(cell_text(buf, 122, 1, 4), "sync");
assert_eq!(cell_text(buf, 132, 1, 4), "base");
assert_eq!(cell_text(buf, 139, 1, 5), "dirty");
assert_eq!(cell_text(buf, 146, 1, 5), "state");
}
#[test]
fn only_the_sorted_columns_header_carries_the_arrow() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let columns = [
(5u16, 6u16, SortColumn::Name),
(34, 8, SortColumn::Branch),
(59, 6, SortColumn::Sync),
(69, 6, SortColumn::Base),
(76, 6, SortColumn::Dirty),
(83, 7, SortColumn::State),
];
for sorted in SortColumn::ALL {
let order = RowOrder::default().choose(sorted);
let mut list = List::default();
list.set_row_order(order);
let terminal = render_with_list(&mut list, 94, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
let arrow = order
.arrow_for(sorted, glyphs)
.expect("the sorted column carries an arrow");
for (x, width, column) in columns {
let drawn = cell_text(buf, x, 1, width);
let expected = if column == sorted {
format!("{}{arrow}", column.label())
} else {
column.label().to_string()
};
assert_eq!(
drawn.trim_end(),
expected,
"sorted by {sorted:?}, {column:?}'s header drew {drawn:?}"
);
}
}
}
#[test]
fn the_natural_order_leaves_every_header_bare() {
let terminal = render(94, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
assert_eq!(cell_text(buf, 76, 1, 6).trim_end(), "dirty");
assert_eq!(cell_text(buf, 83, 1, 7).trim_end(), "state");
}
#[test]
fn the_header_row_colours_its_labels_with_the_themes_dim_role_not_the_dim_attribute() {
let terminal = render(140, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
assert_eq!(
buf[(5, 1)].fg,
Color::DarkGray,
"the header must show theming.md's documented dim default, dark-grey, as a \
foreground colour rather than the DIM text attribute"
);
}
#[test]
fn an_entity_row_places_the_gutter_and_the_name_at_their_literal_spec_offset() {
let terminal = render(140, 24, &snapshot(vec![entity("acquiring-gateway")]));
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(cell_text(buf, 1, 2, 1), glyphs.loading[0].to_string());
assert_eq!(cell_text(buf, 5, 2, 17), "acquiring-gateway");
}
#[test]
fn a_second_entity_renders_one_row_below_the_first() {
let terminal = render(140, 24, &snapshot(vec![entity("first"), entity("second")]));
let buf = terminal.backend().buffer();
assert_eq!(cell_text(buf, 5, 2, 5), "first");
assert_eq!(cell_text(buf, 5, 3, 6), "second");
}
#[test]
fn a_name_longer_than_its_column_is_truncated_at_the_boundary_not_spilled_into_branch() {
let long_name = "n".repeat(60);
let terminal = render(140, 24, &snapshot(vec![entity(&long_name)]));
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let expected = format!("{}{}", "n".repeat(39), glyphs.truncated);
assert_eq!(cell_text(buf, 5, 2, 40), expected);
assert_eq!(
cell_text(buf, 45, 2, 1),
" ",
"the gap before branch must not carry name overflow"
);
assert_eq!(
cell_text(buf, 46, 2, 1),
" ",
"the branch column must not carry name overflow"
);
}
#[test]
fn a_name_exactly_as_wide_as_its_column_carries_no_truncation_mark() {
let exact_name = "n".repeat(40);
let terminal = render(140, 24, &snapshot(vec![entity(&exact_name)]));
let buf = terminal.backend().buffer();
assert_eq!(cell_text(buf, 5, 2, 40), exact_name);
}
#[test]
fn a_truncated_name_carries_the_ascii_tables_own_mark_under_glyphs_ascii() {
let mut list = List::default();
list.register_config_handler(crate::config::Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document: crate::config::document::Document {
glyphs: crate::config::document::Glyphs::Ascii,
..Default::default()
},
warnings: Vec::new(),
zero_config: false,
})
.expect("register config");
let long_name = "n".repeat(60);
let terminal = render_with_list(&mut list, 140, 24, &snapshot(vec![entity(&long_name)]));
let buf = terminal.backend().buffer();
let ascii = GlyphSet::for_config(crate::config::document::Glyphs::Ascii);
let expected = format!("{}{}", "n".repeat(39), ascii.truncated);
assert_eq!(cell_text(buf, 5, 2, 40), expected);
}
#[test]
fn a_truncated_child_row_name_also_carries_the_mark_inside_its_own_reduced_budget() {
let long_child_name = "b".repeat(60);
let parent = entity("parent-repo");
let mut child = entity(&long_child_name);
child.kind = Kind::Worktree;
let terminal = render(140, 24, &snapshot(vec![parent, child]));
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let expected = format!("{}{}", "b".repeat(35), glyphs.truncated);
assert_eq!(cell_text(buf, 9, entity_row_y(1), 36), expected);
}
#[test]
fn a_branch_longer_than_its_column_carries_the_truncation_mark() {
let long_branch = "b".repeat(90);
let snapshot = settled_snapshot_with_a_known_branch(&long_branch);
let terminal = render(140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let expected = format!("{}{}", "b".repeat(57), glyphs.truncated);
assert_eq!(cell_text(buf, 46, 2, 58), expected);
}
#[test]
fn a_truncated_branch_carries_the_ascii_tables_own_mark_under_glyphs_ascii() {
let mut list = List::default();
list.register_config_handler(crate::config::Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document: crate::config::document::Document {
glyphs: crate::config::document::Glyphs::Ascii,
..Default::default()
},
warnings: Vec::new(),
zero_config: false,
})
.expect("register config");
let long_branch = "b".repeat(90);
let snapshot = settled_snapshot_with_a_known_branch(&long_branch);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let ascii = GlyphSet::for_config(crate::config::document::Glyphs::Ascii);
let expected = format!("{}{}", "b".repeat(57), ascii.truncated);
assert_eq!(cell_text(buf, 46, 2, 58), expected);
}
#[test]
fn truncate_with_mark_reserves_the_last_column_only_when_a_cut_actually_happens() {
assert_eq!(
truncate_with_mark("short", 10, '$'),
std::borrow::Cow::Borrowed("short"),
"text that already fits must be returned unchanged, with no mark appended"
);
assert_eq!(
truncate_with_mark("exact", 5, '$'),
std::borrow::Cow::Borrowed("exact"),
"text exactly as wide as the budget must not be treated as needing a cut"
);
assert_eq!(
truncate_with_mark("nnnnnnnnnn", 5, '$'),
"nnnn$".to_string()
);
assert_eq!(
truncate_with_mark("nnnnnnnnnn", 1, '$'),
"$".to_string(),
"a one-column budget spends its whole column on the mark, keeping nothing"
);
assert_eq!(
truncate_with_mark("nnnnnnnnnn", 0, '$'),
"",
"a zero-column budget has no room for the mark either"
);
}
#[test]
fn growth_stops_at_the_caps_and_the_rest_of_a_wide_frame_stays_filler() {
let wide = render(220, 24, &snapshot(vec![]));
let buf = wide.backend().buffer();
assert_eq!(cell_text(buf, 146, 1, 5), "state");
assert_eq!(
cell_text(buf, 156, 1, 63),
" ".repeat(63),
"everything past the last column must stay filler, never a stretched column"
);
}
#[test]
fn the_panel_has_rounded_corners_tiled_to_the_frame_edge_with_a_focused_border_colour() {
let terminal = render(140, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
crate::test_support::assert_frame_drawn_with(
buf,
Rect::new(0, 0, 140, 24),
GlyphSet::for_config(crate::config::document::Glyphs::Full).border,
" repos ",
"the list panel's frame",
);
assert_eq!(
buf[(0, 0)].fg,
Color::LightBlue,
"the border must show theming.md's documented border_focused default, light-blue"
);
}
#[test]
fn the_list_border_dims_to_role_border_once_another_panel_is_focused() {
let mut list = List::default();
let backend = TestBackend::new(140, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw(frame, area, &snapshot(vec![]), false)
.expect("draw the list");
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(
buf[(0, 0)].fg,
theme::DEFAULT.role_color(Role::Border),
"expected the list's border to dim to Role::Border while another panel holds the \
keyboard, not stay BorderFocused regardless"
);
}
#[test]
fn the_sidebars_border_also_dims_to_role_border_once_another_panel_is_focused() {
let mut list = List::default();
let backend = TestBackend::new(SIDEBAR_WIDTH, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw_sidebar(frame, area, &snapshot(vec![]), false)
.expect("draw the sidebar");
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(
buf[(0, 0)].fg,
theme::DEFAULT.role_color(Role::Border),
"expected the sidebar's border to dim the same way the full list's does"
);
}
#[test]
fn the_panels_frame_degrades_to_the_ascii_tables_own_characters() {
let mut list = List::default();
list.register_config_handler(crate::config::Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document: crate::config::document::Document {
glyphs: crate::config::document::Glyphs::Ascii,
..Default::default()
},
warnings: Vec::new(),
zero_config: false,
})
.expect("register config");
let terminal = render_with_list(&mut list, 140, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
crate::test_support::assert_frame_drawn_with(
buf,
Rect::new(0, 0, 140, 24),
GlyphSet::for_config(crate::config::document::Glyphs::Ascii).border,
" repos ",
"the list panel's frame under the ascii table",
);
}
fn bottom_row_text(buf: &Buffer, area: Rect) -> String {
(area.x..area.right())
.map(|x| buf[(x, area.bottom() - 1)].symbol())
.collect()
}
#[test]
fn the_bottom_border_carries_the_cursors_position_and_the_total_visible_rows() {
let mut list = List::default();
list.set_cursor(1);
let area = Rect::new(0, 0, 140, 24);
let terminal = render_with_list(
&mut list,
area.width,
area.height,
&snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]),
);
let buf = terminal.backend().buffer();
let border = GlyphSet::for_config(crate::config::document::Glyphs::Full).border;
let expected_tail = format!("2/3{}", border.bottom_right);
assert!(
bottom_row_text(buf, area).ends_with(&expected_tail),
"expected the cursor's position (2) and the total (3) right-aligned against the \
bottom-right corner, got: {:?}",
bottom_row_text(buf, area)
);
}
#[test]
fn the_bottom_border_also_carries_the_checked_count_once_the_selection_is_non_empty() {
let mut list = List::default();
list.set_cursor(1);
let entities = vec![entity("alpha"), entity("beta"), entity("gamma")];
let checked_key = entities[0].key.clone();
list.set_selection(checked_selection([checked_key]));
let area = Rect::new(0, 0, 140, 24);
let terminal = render_with_list(&mut list, area.width, area.height, &snapshot(entities));
let buf = terminal.backend().buffer();
let border = GlyphSet::for_config(crate::config::document::Glyphs::Full).border;
let expected_tail = format!("2/3/1{}", border.bottom_right);
assert!(
bottom_row_text(buf, area).ends_with(&expected_tail),
"expected the checked count to join the counter as a third number, got: {:?}",
bottom_row_text(buf, area)
);
}
#[test]
fn the_bottom_border_carries_no_counter_when_the_list_is_empty() {
let area = Rect::new(0, 0, 140, 24);
let terminal = render(area.width, area.height, &snapshot(vec![]));
let buf = terminal.backend().buffer();
let border = GlyphSet::for_config(crate::config::document::Glyphs::Full).border;
let expected_bottom = format!(
"{}{}{}",
border.bottom_left,
border
.horizontal
.to_string()
.repeat(area.width as usize - 2),
border.bottom_right
);
assert_eq!(
bottom_row_text(buf, area),
expected_bottom,
"expected a plain dash run with no counter when there is nothing to number"
);
}
#[test]
fn position_counter_reads_cursor_and_selection_into_one_slash_separated_string() {
assert_eq!(
position_counter(0, 0, 0),
None,
"nothing to number when total is zero"
);
assert_eq!(position_counter(5, 0, 0), Some("1/5".to_string()));
assert_eq!(
position_counter(5, 4, 0),
Some("5/5".to_string()),
"0-indexed cursor 4 of 5 rows is position 5"
);
assert_eq!(
position_counter(5, 99, 0),
Some("5/5".to_string()),
"a cursor past the row count must clamp to the last row rather than overrun it"
);
assert_eq!(
position_counter(5, 2, 3),
Some("3/5/3".to_string()),
"a non-empty Selection appends its count as a third number"
);
}
#[test]
fn the_panel_title_renders_inline_in_the_top_border_row_rather_than_a_separate_row() {
let terminal = render(140, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
let top_row: String = (0..140).map(|x| buf[(x, 0)].symbol().to_string()).collect();
assert!(
top_row.contains("repos"),
"expected the title inline in the top border row, got: {top_row:?}"
);
}
#[test]
fn an_empty_snapshot_says_so_rather_than_rendering_an_empty_box() {
let terminal = render(140, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(
buf,
absolute_x(0),
entity_row_y(0),
NO_REPOS_MESSAGE.len() as u16
),
NO_REPOS_MESSAGE,
"an empty snapshot with no Filter must say so on the first row below the header"
);
}
#[test]
fn a_filter_matching_nothing_says_so_distinctly_from_an_empty_snapshot() {
let mut list = List::default();
list.set_filter(Filter::parse("name:does-not-exist-anywhere"));
let snap = snapshot(vec![entity("alpha")]);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(
buf,
absolute_x(0),
entity_row_y(0),
NO_MATCHES_MESSAGE.len() as u16
),
NO_MATCHES_MESSAGE,
"a Filter matching zero rows must say so, distinctly from the no-filter empty state"
);
}
#[test]
fn the_sidebar_also_says_so_when_nothing_is_discovered() {
let terminal = render_sidebar(SIDEBAR_WIDTH, 24, &snapshot(vec![]));
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, 1, 1, NO_REPOS_MESSAGE.len() as u16),
NO_REPOS_MESSAGE,
"the sidebar must show the same empty-state message, one row higher (no header)"
);
}
#[test]
fn base_occupies_its_spec_stated_width_and_position_after_sync() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/default-branch.md"))
.expect("read the default branch specification");
let sentence = spec
.lines()
.find(|line| line.starts_with("Name ") && line.contains(", then the filler column"))
.expect("default-branch.md must state the list's column widths");
let widths_text = sentence
.split(", then the filler column")
.next()
.expect("the column widths sentence must name a filler column");
let mut widths: Vec<(String, u16, u16)> = Vec::new();
for entry in widths_text.split(", ") {
let parts: Vec<&str> = entry.split_whitespace().collect();
let (name, min, max) = match parts.as_slice() {
[name, width] => (name, width, width),
[name, min, "to", max] => (name, min, max),
_ => panic!("unreadable column width entry: {entry:?}"),
};
let parse = |token: &str| -> u16 {
token
.parse()
.unwrap_or_else(|_| panic!("not a column width: {token:?} in {entry:?}"))
};
widths.push((name.to_lowercase(), parse(min), parse(max)));
}
let by_name = |name: &str| {
let column = widths
.iter()
.find(|(n, _, _)| n == name)
.unwrap_or_else(|| {
panic!("default-branch.md's column widths sentence has no {name:?} column")
});
(column.1, column.2)
};
let sync_index = widths
.iter()
.position(|(n, _, _)| n == "sync")
.expect("a sync column");
let base_index = widths
.iter()
.position(|(n, _, _)| n == "base")
.expect("a base column");
assert_eq!(
base_index,
sync_index + 1,
"base must be the column immediately after sync in default-branch.md's own list"
);
assert_eq!(
(BASE_WIDTH, BASE_WIDTH),
by_name("base"),
"BASE_WIDTH must match default-branch.md's stated width, which base neither grows \
past nor shrinks below"
);
assert_eq!(
(NAME_MIN_WIDTH, NAME_MAX_WIDTH),
by_name("name"),
"the name column's minimum and cap must match default-branch.md's stated pair"
);
assert_eq!(
(BRANCH_MIN_WIDTH, BRANCH_MAX_WIDTH),
by_name("branch"),
"the branch column's minimum and cap must match default-branch.md's stated pair"
);
let gaps = GAP * (widths.len() as u16 - 1);
let lead = GUTTER_WIDTH + GAP + SELECTED_WIDTH + GAP;
let packed_min = lead + widths.iter().map(|(_, min, _)| min).sum::<u16>() + gaps;
let packed_max = lead + widths.iter().map(|(_, _, max)| max).sum::<u16>() + gaps;
assert_eq!(
packed_min,
number_after_in(sentence, "the minimums are "),
"default-branch.md's own stated minimum total must be the sum of its own widths"
);
assert_eq!(
packed_max,
number_after_in(sentence, "both caps together are "),
"default-branch.md's own stated capped total must be the sum of its own caps"
);
let expected_base_x =
|name: u16, branch: u16| lead + name + GAP + branch + GAP + by_name("sync").0 + GAP;
assert_eq!(
Columns::for_interior_width(packed_min).base.x,
expected_base_x(by_name("name").0, by_name("branch").0),
"with no slack, base must sit where default-branch.md's own minimums predict"
);
assert_eq!(
Columns::for_interior_width(packed_max).base.x,
expected_base_x(by_name("name").1, by_name("branch").1),
"past both caps, base must sit where default-branch.md's own caps predict"
);
}
fn number_after_in(text: &str, needle: &str) -> u16 {
let after = text
.split(needle)
.nth(1)
.unwrap_or_else(|| panic!("the spec sentence must still say {needle:?}: {text:?}"));
let end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
after[..end]
.parse()
.unwrap_or_else(|_| panic!("no number after {needle:?} in {text:?}"))
}
#[test]
fn write_cell_truncates_exactly_at_the_given_width() {
let interior = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(interior);
write_cell(&mut buf, interior, 0, 0, 5, "abcdefgh", Style::new());
assert_eq!(cell_text(&buf, 0, 0, 5), "abcde");
assert_eq!(cell_text(&buf, 5, 0, 1), " ");
}
#[test]
fn write_cell_never_writes_past_the_interiors_own_right_edge() {
let full = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(full);
let interior = Rect::new(0, 0, 8, 1);
write_cell(&mut buf, interior, 6, 0, 10, "abcdefghij", Style::new());
assert_eq!(cell_text(&buf, 6, 0, 2), "ab");
assert_eq!(
cell_text(&buf, 8, 0, 1),
" ",
"must not spill past the interior's own right edge even though the raw buffer \
has room"
);
}
#[test]
fn render_cell_renders_a_known_value_through_the_formatter() {
let settled = Settled::Known {
value: 5u32,
at: Timestamp::now(),
stale: false,
};
assert_eq!(
render_cell(Some(&settled), |value| value.to_string(), Some('⠋')),
"5",
"a Known value must render even when the caller supplies a loading glyph"
);
}
#[test]
fn render_cell_renders_a_known_stale_value_the_same_as_a_known_fresh_one() {
let settled = Settled::Known {
value: 5u32,
at: Timestamp::now(),
stale: true,
};
assert_eq!(
render_cell(Some(&settled), |value| value.to_string(), None),
"5"
);
}
#[test]
fn render_cell_renders_unknown_as_blank_even_with_a_loading_glyph_supplied() {
let settled: Settled<u32> = Settled::Unknown(Unknown::TimedOut);
assert_eq!(
render_cell(Some(&settled), |value| value.to_string(), Some('⠋')),
"",
"Unknown is a settled fact, distinct from Loading, and must never show the \
loading mark"
);
}
#[test]
fn render_cell_renders_failed_as_blank_even_with_a_loading_glyph_supplied() {
let settled: Settled<u32> = Settled::Failed(ProbeError::Read(Arc::from("boom")));
assert_eq!(
render_cell(Some(&settled), |value| value.to_string(), Some('⠋')),
""
);
}
#[test]
fn render_cell_renders_not_applicable_as_blank_even_with_a_loading_glyph_supplied() {
let settled: Settled<u32> = Settled::NotApplicable;
assert_eq!(
render_cell(Some(&settled), |value| value.to_string(), Some('⠋')),
""
);
}
#[test]
fn render_cell_renders_nothing_settled_as_blank_when_no_loading_glyph_is_supplied() {
let settled: Option<&Settled<u32>> = None;
assert_eq!(render_cell(settled, |value| value.to_string(), None), "");
}
#[test]
fn render_cell_renders_nothing_settled_as_the_loading_glyph_when_one_is_supplied() {
let settled: Option<&Settled<u32>> = None;
assert_eq!(
render_cell(settled, |value| value.to_string(), Some('⠋')),
"⠋"
);
}
#[test]
fn no_numeric_bearing_cell_ever_renders_a_raw_default_instead_of_blank() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let unset: Cell<u32> = Cell::default();
let unset_dirty: Cell<DirtyCounts> = Cell::default();
let unset_sync: Cell<SyncState> = Cell::default();
for text in [
format_base(&unset, glyphs, None),
format_dirty(&unset_dirty, glyphs, None),
format_sync(&unset_sync, glyphs, None),
] {
assert_eq!(
text, "",
"an uncomputed numeric-bearing cell must render blank when withheld a \
loading glyph"
);
assert_ne!(
text, "0",
"an uncomputed numeric-bearing cell must never render a raw zero default"
);
}
for text in [
format_base(&unset, glyphs, Some('⠋')),
format_dirty(&unset_dirty, glyphs, Some('⠋')),
format_sync(&unset_sync, glyphs, Some('⠋')),
] {
assert_eq!(
text, "⠋",
"an uncomputed numeric-bearing cell offered a loading glyph must show it \
rather than a raw zero"
);
}
}
#[test]
fn sync_glyph_renders_each_sync_state_through_its_own_named_glyph() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(sync_glyph(&SyncState::NoRemote, glyphs), "∅");
assert_eq!(sync_glyph(&SyncState::NoUpstream, glyphs), "-");
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0
}),
glyphs
),
"≡"
);
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 3,
behind: 0
}),
glyphs
),
"↑3"
);
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 5
}),
glyphs
),
"↓5"
);
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 2,
behind: 4
}),
glyphs
),
"↑2 ↓4",
"diverged both ways must show both counts, ahead before behind"
);
}
#[test]
fn sync_glyph_renders_each_sync_state_through_the_ascii_table_too() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::Ascii);
assert_eq!(sync_glyph(&SyncState::NoRemote, glyphs), "x");
assert_eq!(sync_glyph(&SyncState::NoUpstream, glyphs), "-");
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 1,
behind: 0
}),
glyphs
),
">1"
);
assert_eq!(
sync_glyph(
&SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 1
}),
glyphs
),
"<1"
);
}
fn settled_snapshot_with_an_ahead_and_behind_sync() -> repon_core::Snapshot {
use repon_core::{Core, CoreSpec, RepoOverride, SetSpec};
use std::time::Duration;
fn git(root: &Path, args: &[&str], what: &str) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(args)
.status()
.unwrap_or_else(|error| panic!("run git {what}: {error}"));
assert!(status.success(), "git {what} failed");
}
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
init_repo_on_branch(&root, "main");
std::fs::write(root.join("second.txt"), "second").expect("write second file");
git(&root, &["add", "second.txt"], "add second");
git(&root, &["commit", "-m", "second"], "commit second");
let output = std::process::Command::new("git")
.arg("-C")
.arg(&root)
.args(["rev-parse", "main"])
.output()
.expect("run git rev-parse main");
assert!(output.status.success());
let upstream_sha = String::from_utf8(output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
git(
&root,
&["checkout", "--quiet", "-b", "feature", "HEAD~1"],
"checkout feature",
);
std::fs::write(root.join("theirs.txt"), "theirs").expect("write feature file");
git(&root, &["add", "theirs.txt"], "add feature file");
git(&root, &["commit", "-m", "feature"], "commit feature");
git(
&root,
&["update-ref", "refs/remotes/origin/feature", &upstream_sha],
"update-ref feature",
);
git(
&root,
&[
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
],
"remote add",
);
git(
&root,
&["config", "branch.feature.remote", "origin"],
"config branch remote",
);
git(
&root,
&["config", "branch.feature.merge", "refs/heads/feature"],
"config branch merge",
);
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root.clone()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: vec![RepoOverride {
path: root,
default_branch: Some("main".to_string()),
excluded: false,
}],
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
core.settle()
}
#[test]
fn the_rendered_sync_cell_keeps_a_space_between_an_ahead_and_a_behind_run() {
let snapshot = settled_snapshot_with_an_ahead_and_behind_sync();
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let mut list = List::default();
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
assert_eq!(
cell_text(buf, sync_x(buf), y, 5),
"↑1 ↓1",
"the cell that renders must carry the separator, not only `sync_glyph`'s own join"
);
let ahead_fg = buf[(sync_x(buf), y)].fg;
let behind_fg = buf[(sync_x(buf) + 3, y)].fg;
assert_eq!(
ahead_fg,
theme::DEFAULT.role_color(role_named_in_theming_md("Ahead count")),
"the ahead count takes theming.md's own `ok` role"
);
assert_eq!(
behind_fg,
theme::DEFAULT.role_color(role_named_in_theming_md("Behind count")),
"the behind count keeps its own role rather than the cell settling on one"
);
}
#[test]
fn a_live_themes_own_colours_reach_the_border_the_column_header_and_a_value_cell() {
let snapshot = settled_snapshot_with_an_ahead_and_behind_sync();
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let live_theme = Theme {
border_focused: Color::Rgb(9, 8, 7),
dim: Color::Rgb(11, 22, 33),
text: Color::Rgb(44, 55, 66),
ok: Color::Rgb(77, 88, 99),
..Theme::default()
};
let mut list = List::default();
list.set_theme(live_theme);
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
assert_eq!(
buf[(0, 0)].fg,
live_theme.border_focused,
"the border must read the live theme, not theme::DEFAULT"
);
assert_eq!(
buf[(name_x(buf), 1)].fg,
live_theme.dim,
"the column header must read the live theme"
);
assert_eq!(
buf[(name_x(buf), y)].fg,
live_theme.text,
"the name cell must read the live theme"
);
assert_eq!(
buf[(sync_x(buf), y)].fg,
live_theme.ok,
"the ahead count cell must read the live theme"
);
}
#[test]
fn sync_value_runs_gives_an_ahead_and_a_behind_count_each_their_own_meaning_in_one_cell() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
sync_value_runs(
&SyncState::Tracking(AheadBehind {
ahead: 2,
behind: 4
}),
glyphs
),
vec![
("↑2".to_string(), Meaning::AheadCount),
("↓4".to_string(), Meaning::BehindCount),
]
);
}
#[test]
fn sync_value_runs_gives_a_known_zero_and_a_lone_ahead_or_behind_count_their_own_meaning() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
sync_value_runs(
&SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 0
}),
glyphs
),
vec![("≡".to_string(), Meaning::KnownZero)]
);
assert_eq!(
sync_value_runs(
&SyncState::Tracking(AheadBehind {
ahead: 3,
behind: 0
}),
glyphs
),
vec![("↑3".to_string(), Meaning::AheadCount)]
);
assert_eq!(
sync_value_runs(
&SyncState::Tracking(AheadBehind {
ahead: 0,
behind: 5
}),
glyphs
),
vec![("↓5".to_string(), Meaning::BehindCount)]
);
}
#[test]
fn base_meaning_names_a_known_zero_and_a_behind_count() {
assert_eq!(base_meaning(&0), Meaning::KnownZero);
assert_eq!(base_meaning(&1), Meaning::BehindCount);
}
#[test]
fn dirty_meaning_names_a_known_zero_and_a_nonzero_dirty_count() {
assert_eq!(
dirty_meaning(&DirtyCounts {
modified: 0,
untracked: 0,
deleted: 0
}),
Meaning::KnownZero
);
assert_eq!(
dirty_meaning(&DirtyCounts {
modified: 1,
untracked: 0,
deleted: 0
}),
Meaning::Dirty
);
}
#[test]
fn state_meaning_names_each_of_the_four_worktree_states_through_its_own_meaning() {
assert_eq!(
state_meaning(&WorktreeState::Merged),
Meaning::MergedWorktree
);
assert_eq!(state_meaning(&WorktreeState::Gone), Meaning::GoneWorktree);
assert_eq!(state_meaning(&WorktreeState::LocalOnly), Meaning::LocalOnly);
assert_eq!(
state_meaning(&WorktreeState::Active),
Meaning::ActiveWorktree
);
}
#[test]
fn every_worktree_state_reads_as_its_own_distinct_word() {
let states = [
WorktreeState::Merged,
WorktreeState::Gone,
WorktreeState::LocalOnly,
WorktreeState::Active,
];
let words: Vec<&str> = states
.iter()
.map(|state| worktree_state_word(state))
.collect();
for (index, word) in words.iter().enumerate() {
for (other_index, other) in words.iter().enumerate() {
if index != other_index {
assert_ne!(
word, other,
"got two Worktree states reading the same word: {words:?}"
);
}
}
}
}
#[test]
fn name_cell_meaning_names_each_kind_through_its_own_meaning() {
assert_eq!(name_cell_meaning(Kind::Repo), Meaning::FreshValue);
assert_eq!(name_cell_meaning(Kind::Worktree), Meaning::WorktreeName);
assert_eq!(name_cell_meaning(Kind::Submodule), Meaning::SubmoduleName);
}
#[test]
fn cell_role_takes_the_loading_meaning_over_meaning_for_value_when_a_glyph_is_supplied() {
let settled: Option<&Settled<u32>> = None;
assert_eq!(
cell_role(settled, |_| Meaning::BehindCount, Some('⠋')),
Meaning::LoadingSpinner.role()
);
}
#[test]
fn cell_role_falls_back_to_fresh_value_with_nothing_settled_and_no_loading_glyph() {
let settled: Option<&Settled<u32>> = None;
assert_eq!(
cell_role(settled, |_| Meaning::BehindCount, None),
Meaning::FreshValue.role()
);
}
#[test]
fn cell_role_ignores_meaning_for_value_for_every_blank_settled_shape() {
let unknown: Settled<u32> = Settled::Unknown(Unknown::TimedOut);
let failed: Settled<u32> = Settled::Failed(ProbeError::Read(Arc::from("boom")));
let not_applicable: Settled<u32> = Settled::NotApplicable;
for settled in [&unknown, &failed, ¬_applicable] {
assert_eq!(
cell_role(Some(settled), |_| Meaning::BehindCount, None),
Meaning::FreshValue.role()
);
}
}
#[test]
fn gutter_glyph_for_maps_every_row_summary_to_its_own_glyph() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let frame = glyphs.loading[3];
assert_eq!(
gutter_glyph_for(RowSummary::Fresh, glyphs, frame),
glyphs.fresh
);
assert_eq!(
gutter_glyph_for(RowSummary::Stale, glyphs, frame),
glyphs.stale
);
assert_eq!(
gutter_glyph_for(RowSummary::Unknown, glyphs, frame),
glyphs.unknown
);
assert_eq!(
gutter_glyph_for(RowSummary::Failed, glyphs, frame),
glyphs.failed
);
assert_eq!(
gutter_glyph_for(RowSummary::InFlight, glyphs, frame),
frame,
"in flight shows whichever frame the caller selected, not a fixed one"
);
}
#[test]
fn gutter_glyph_for_maps_every_row_summary_to_the_ascii_sets_own_glyph() {
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::Ascii);
let frame = glyphs.loading[1];
assert_eq!(
gutter_glyph_for(RowSummary::Fresh, glyphs, frame),
glyphs.fresh
);
assert_eq!(
gutter_glyph_for(RowSummary::Stale, glyphs, frame),
glyphs.stale
);
assert_eq!(
gutter_glyph_for(RowSummary::Unknown, glyphs, frame),
glyphs.unknown
);
assert_eq!(
gutter_glyph_for(RowSummary::Failed, glyphs, frame),
glyphs.failed
);
assert_eq!(
gutter_glyph_for(RowSummary::InFlight, glyphs, frame),
frame,
"the ascii set's own three-frame spinner, distinct from the full set's ten-frame \
one"
);
}
fn production_source() -> String {
crate::test_support::production_source(include_str!("list.rs"))
}
#[test]
fn no_wildcard_match_arm_hides_a_cell_rendering_default() {
let source = production_source();
let offending_lines: Vec<&str> = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.filter(|line| line.contains("_ =>"))
.collect();
assert!(
offending_lines.is_empty(),
"found a wildcard match arm, which can hide an unhandled cell state: {offending_lines:?}"
);
}
#[test]
fn every_column_formatter_reaches_settled_known_only_through_render_cell() {
let occurrences: usize = production_source()
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.map(|line| line.matches("Settled::Known").count())
.sum();
assert_eq!(
occurrences, 1,
"expected `Settled::Known` matched in exactly one place (render_cell), found \
{occurrences}"
);
}
#[test]
fn no_column_formatter_reads_is_in_flight_to_decide_a_cells_text() {
let source = production_source();
let offending_lines: Vec<&str> = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.filter(|line| line.contains("is_in_flight"))
.collect();
assert!(
offending_lines.is_empty(),
"a column formatter must never read is_in_flight; only the row's gutter does, \
found at: {offending_lines:?}"
);
}
fn entity_of_kind(name: &str, kind: Kind, common_dir: &str) -> EntityState {
EntityState::new(
EntityKey::new(Arc::from(Path::new(name))),
Arc::from(name),
Arc::from(Path::new(common_dir)),
kind,
)
}
fn every_index(entities: &[EntityState]) -> Vec<usize> {
(0..entities.len()).collect()
}
#[test]
fn grouped_row_order_places_each_repos_children_immediately_after_it_in_original_order() {
let entities = vec![
entity_of_kind("worktree-b", Kind::Worktree, "/repo-b"),
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("submodule-a", Kind::Submodule, "/repo-a/modules/lib"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
entity_of_kind("worktree-a", Kind::Worktree, "/repo-a"),
];
let order = grouped_row_order(&entities, &every_index(&entities));
assert_eq!(
order,
vec![1, 2, 4, 3, 0],
"expected repo-a (1), then its own submodule (2) and worktree (4) in their \
original relative order, then repo-b (3) and its own worktree (0)"
);
}
#[test]
fn a_sort_reorders_within_each_group_and_never_flattens_them() {
let entities = vec![
entity_of_kind("zed-worktree", Kind::Worktree, "/zed"),
entity_of_kind("apex", Kind::Repo, "/apex"),
entity_of_kind("apex-worktree-z", Kind::Worktree, "/apex"),
entity_of_kind("zed", Kind::Repo, "/zed"),
entity_of_kind("apex-worktree-a", Kind::Worktree, "/apex"),
];
let filter = Filter::default();
for column in SortColumn::ALL {
let natural = RowOrder::default().choose(column);
for order in [natural, natural.choose(column)] {
let rows: Vec<&EntityState> = visible_row_order(
&entities,
Visibility::everything(),
&filter,
order,
&HashSet::new(),
)
.into_iter()
.map(|index| &entities[index])
.collect();
let names: Vec<&str> = rows.iter().map(|row| row.name.as_ref()).collect();
assert_eq!(
rows.len(),
entities.len(),
"{order:?} lost a row: {names:?}"
);
let mut group: Option<&Path> = None;
for row in &rows {
match row.kind {
Kind::Repo => group = Some(&row.common_dir),
_ => assert_eq!(
Some(group_key(row)),
group,
"{order:?} put {:?} outside its own Repo's group: {names:?}",
row.name
),
}
}
assert_eq!(
rows.iter().filter(|row| row.kind == Kind::Repo).count(),
2,
"{order:?} lost a Repo: {names:?}"
);
}
}
}
#[test]
fn a_name_sort_orders_the_repos_and_each_repos_own_children() {
let entities = vec![
entity_of_kind("zed-worktree", Kind::Worktree, "/zed"),
entity_of_kind("apex", Kind::Repo, "/apex"),
entity_of_kind("apex-worktree-z", Kind::Worktree, "/apex"),
entity_of_kind("zed", Kind::Repo, "/zed"),
entity_of_kind("apex-worktree-a", Kind::Worktree, "/apex"),
];
let order = RowOrder::default().choose(SortColumn::Name);
let names: Vec<&str> = visible_row_order(
&entities,
Visibility::everything(),
&Filter::default(),
order,
&HashSet::new(),
)
.into_iter()
.map(|index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
[
"apex",
"apex-worktree-a",
"apex-worktree-z",
"zed",
"zed-worktree"
]
);
}
#[test]
fn a_child_whose_parent_is_absent_is_appended_rather_than_dropped() {
let entities = vec![
entity_of_kind("orphan-worktree", Kind::Worktree, "/gone"),
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("orphan-submodule", Kind::Submodule, "/missing/modules/lib"),
];
let order = grouped_row_order(&entities, &every_index(&entities));
assert_eq!(
order,
vec![1, 0, 2],
"expected repo-a first, then both parentless children in their original \
relative order, with no row dropped"
);
assert_eq!(
order.len(),
entities.len(),
"every entity must reach the table exactly once"
);
}
#[test]
fn an_excluded_row_is_a_candidate_only_while_ignored_is_on() {
let mut entities = vec![
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
];
entities[0].excluded = true;
let names = |visibility| {
visible_row_order(
&entities,
visibility,
&Filter::default(),
RowOrder::Natural,
&HashSet::new(),
)
.into_iter()
.map(|index| entities[index].name.to_string())
.collect::<Vec<String>>()
};
assert_eq!(
names(Visibility {
ignored: false,
..Visibility::everything()
}),
vec!["repo-b".to_string()]
);
assert_eq!(
names(Visibility::everything()),
vec!["repo-a".to_string(), "repo-b".to_string()]
);
}
#[test]
fn a_pinned_row_is_still_hidden_while_it_is_ignored() {
let mut entities = vec![entity_of_kind("repo-a", Kind::Repo, "/repo-a")];
entities[0].excluded = true;
let pinned: HashSet<EntityKey> = std::iter::once(entities[0].key.clone()).collect();
let visible = visible_row_order(
&entities,
Visibility {
ignored: false,
..Visibility::everything()
},
&Filter::default(),
RowOrder::Natural,
&pinned,
);
assert!(visible.is_empty(), "got {visible:?}");
}
#[test]
fn a_filter_matching_every_row_leaves_the_order_identical_to_unfiltered() {
let entities = vec![
entity_of_kind("worktree-b-x", Kind::Worktree, "/repo-b"),
entity_of_kind("repo-a-x", Kind::Repo, "/repo-a"),
entity_of_kind("submodule-a-x", Kind::Submodule, "/repo-a/modules/lib"),
entity_of_kind("repo-b-x", Kind::Repo, "/repo-b"),
entity_of_kind("worktree-a-x", Kind::Worktree, "/repo-a"),
];
let filter = Filter::parse("x");
assert!(
filter.is_active(),
"fixture's own filter must be active, or this proves nothing"
);
for entity in &entities {
assert!(
filter.matches(entity),
"fixture must have every row match {:?}, or this is not the case this test names",
entity.name
);
}
let visible = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&HashSet::new(),
);
let unfiltered = grouped_row_order(&entities, &every_index(&entities));
assert_eq!(
visible, unfiltered,
"a Filter matching every row must produce exactly the unfiltered, grouped order"
);
}
#[test]
fn a_filter_matching_only_children_appends_them_with_no_parent_dragged_in() {
let entities = vec![
entity_of_kind("worktree-b", Kind::Worktree, "/repo-b"),
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("submodule-a", Kind::Submodule, "/repo-a/modules/lib"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
entity_of_kind("worktree-a", Kind::Worktree, "/repo-a"),
];
let filter = Filter::parse("worktree");
let visible = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&HashSet::new(),
);
assert_eq!(
visible,
vec![0, 4],
"expected only the two Worktrees, in their own original relative order, with \
neither Repo dragged in"
);
}
#[test]
fn a_filter_matching_a_parent_and_some_of_its_children_groups_them() {
let entities = vec![
entity_of_kind("child-worktree", Kind::Worktree, "/repo-a"),
entity_of_kind("keep-submodule", Kind::Submodule, "/repo-a/modules/lib"),
entity_of_kind("keep-a", Kind::Repo, "/repo-a"),
entity_of_kind("other-repo", Kind::Repo, "/repo-b"),
entity_of_kind("other-worktree", Kind::Worktree, "/repo-b"),
];
let filter = Filter::parse("keep");
let visible = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&HashSet::new(),
);
let names: Vec<&str> = visible
.iter()
.map(|&index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
vec!["keep-a", "keep-submodule"],
"the matching Repo must lead, immediately followed by its own matching \
Submodule, even though the Submodule preceded its Repo in discovery order"
);
}
#[test]
fn a_parent_hidden_by_a_preference_behaves_like_one_the_filter_dropped() {
let entities = vec![
entity_of_kind("repo-a-x", Kind::Repo, "/repo-a"),
entity_of_kind("worktree-b-x", Kind::Worktree, "/repo-b"),
entity_of_kind("repo-b-x", Kind::Repo, "/repo-b"),
entity_of_kind("submodule-a-x", Kind::Submodule, "/repo-a/modules/lib"),
entity_of_kind("worktree-a-x", Kind::Worktree, "/repo-a"),
];
let filter = Filter::parse("x");
assert!(
filter.is_active(),
"fixture's own filter must be active, or this proves nothing"
);
let visible = visible_row_order(
&entities,
Visibility {
worktrees: false,
..Visibility::everything()
},
&filter,
RowOrder::Natural,
&HashSet::new(),
);
let names: Vec<&str> = visible
.iter()
.map(|&index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
vec!["repo-a-x", "submodule-a-x", "repo-b-x"],
"both Worktrees must be hidden by show_worktrees, and submodule-a-x must still \
group immediately under repo-a-x rather than trailing after repo-b-x"
);
}
#[test]
fn a_row_pinned_by_an_in_flight_run_stays_visible_even_once_it_stops_matching_the_committed_filter()
{
let entities = vec![
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
];
let filter = Filter::parse("name:repo-b");
let mut pinned = HashSet::new();
pinned.insert(entities[0].key.clone());
let visible = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&pinned,
);
let names: Vec<&str> = visible
.iter()
.map(|&index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
vec!["repo-a", "repo-b"],
"repo-a fails the Committed Filter but must still appear, pinned"
);
}
#[test]
fn a_row_pinned_by_an_in_flight_run_leaves_the_list_the_frame_the_run_moves_past_it() {
let entities = vec![
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
];
let filter = Filter::parse("name:repo-b");
let mut pinned = HashSet::new();
pinned.insert(entities[0].key.clone());
let while_pinned = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&pinned,
);
assert!(
while_pinned.contains(&0),
"sanity: repo-a must still be a candidate while its key is in `pinned`"
);
pinned.remove(&entities[0].key);
let once_unpinned = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&pinned,
);
assert!(
!once_unpinned.contains(&0),
"repo-a must leave the moment `pinned` no longer names it, not on some later call"
);
}
#[test]
fn a_pinned_row_still_renders_its_own_cells_live_only_its_membership_is_held() {
let mut repo_a = entity("repo-a");
repo_a.dirty = Cell::already_settled(Settled::Known {
value: DirtyCounts {
modified: 3,
untracked: 0,
deleted: 0,
},
at: Timestamp::now(),
stale: false,
});
let filter = Filter::parse("name:does-not-match-repo-a");
assert!(
!filter.matches(&repo_a),
"fixture's own Filter must fail to match repo-a, or this proves nothing"
);
let mut list = List::default();
list.set_filter(filter);
let mut pinned = HashSet::new();
pinned.insert(repo_a.key.clone());
list.set_pinned(pinned);
let terminal = render_with_list(&mut list, 140, 24, &snapshot(vec![repo_a]));
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, name_x(buf), y, 6),
"repo-a",
"the pinned row must still draw, by name"
);
assert_eq!(
cell_text(buf, dirty_x(buf), y, 2),
format!("{}3", glyphs.changed),
"the pinned row's own dirty count must still read live, not blank or frozen"
);
}
#[test]
fn pinning_never_reaches_a_row_outside_the_runs_own_selection() {
let entities = vec![
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("repo-b", Kind::Repo, "/repo-b"),
];
let filter = Filter::parse("name:repo-c");
let mut pinned = HashSet::new();
pinned.insert(entities[0].key.clone());
let visible = visible_row_order(
&entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&pinned,
);
let names: Vec<&str> = visible
.iter()
.map(|&index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
vec!["repo-a"],
"repo-a is pinned so it stays despite matching nothing; repo-b is neither \
pinned nor matching and must stay absent"
);
}
#[test]
fn a_pinned_worktree_stays_hidden_while_show_worktrees_is_off() {
let entities = vec![
entity_of_kind("repo-a", Kind::Repo, "/repo-a"),
entity_of_kind("worktree-a", Kind::Worktree, "/repo-a"),
];
let filter = Filter::default();
assert!(
!filter.requests_kind(Kind::Worktree),
"fixture's own filter must not itself request Worktrees, or this proves nothing \
about pinning bypassing show_worktrees"
);
let mut pinned = HashSet::new();
pinned.insert(entities[1].key.clone());
let visible = visible_row_order(
&entities,
Visibility {
worktrees: false,
..Visibility::everything()
},
&filter,
RowOrder::Natural,
&pinned,
);
let names: Vec<&str> = visible
.iter()
.map(|&index| entities[index].name.as_ref())
.collect();
assert_eq!(
names,
vec!["repo-a"],
"worktree-a is pinned but show_worktrees is off, so it must stay hidden: \
pinning overrides the Filter, never show_worktrees"
);
}
fn worktree_add(parent: &Path, worktree: &Path, branch: &str) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(parent)
.args([
"worktree",
"add",
"-b",
branch,
worktree.to_str().expect("utf8 path"),
])
.status()
.expect("run git worktree add");
assert!(status.success());
}
fn write_gitmodules(parent: &Path, name: &str, relative_path: &str) {
std::fs::write(
parent.join(".gitmodules"),
format!(
"[submodule \"{name}\"]\n\tpath = {relative_path}\n\turl = \
https://example.invalid/{name}.git\n"
),
)
.expect("write .gitmodules");
}
fn init_detached_repo_with_a_commit(path: &Path) -> String {
std::fs::create_dir_all(path).expect("create repo dir");
let status = std::process::Command::new("git")
.arg("init")
.args(["--quiet", "--initial-branch", "main"])
.arg(path)
.status()
.expect("run git init");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(["commit", "--allow-empty", "-m", "first"])
.status()
.expect("run git commit");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["remote", "add", "origin", "https://example.invalid/lib.git"])
.status()
.expect("run git remote add");
assert!(status.success());
let output = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "HEAD"])
.output()
.expect("run git rev-parse");
assert!(output.status.success());
let sha = String::from_utf8(output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["checkout", "--quiet", "--detach", &sha])
.status()
.expect("run git checkout --detach");
assert!(status.success());
sha.chars().take(BRANCH_CELL_OBJECT_ID_WIDTH).collect()
}
fn init_detached_repo_with_a_resolvable_default_branch(path: &Path) -> String {
let short_id = init_detached_repo_with_a_commit(path);
let output = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "HEAD"])
.output()
.expect("run git rev-parse");
assert!(output.status.success());
let sha = String::from_utf8(output.stdout)
.expect("utf8 sha")
.trim()
.to_string();
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["update-ref", "refs/remotes/origin/main", &sha])
.status()
.expect("run git update-ref");
assert!(status.success());
let remote_refs_dir = path
.join(".git")
.join("refs")
.join("remotes")
.join("origin");
std::fs::create_dir_all(&remote_refs_dir).expect("create refs/remotes/origin dir");
std::fs::write(
remote_refs_dir.join("HEAD"),
"ref: refs/remotes/origin/main\n",
)
.expect("write refs/remotes/origin/HEAD");
short_id
}
#[test]
fn a_shown_submodules_row_carries_the_unknown_gutter_mark_even_with_its_own_default_branch_resolved()
{
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo_on_branch(&parent, "main");
write_gitmodules(&parent, "lib", "vendor/lib");
init_detached_repo_with_a_resolvable_default_branch(&parent.join("vendor").join("lib"));
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: true,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let snapshot = core.settle();
let (row, entity) = find_entity_row(&snapshot, "vendor/lib");
assert!(matches!(entity.kind, Kind::Submodule));
assert!(
matches!(
entity.default_branch.settled(),
Some(Settled::Known {
value: _,
at: _,
stale: _
})
),
"expected this fixture's own default_branch to resolve, got {:?}",
entity.default_branch.settled()
);
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(row);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, absolute_x(GUTTER_X), y, 1),
glyphs.unknown.to_string(),
"expected the unknown gutter mark even though this Submodule's own default \
branch resolved, since state/base are Unknown by kind rather than by a probe \
against it"
);
}
fn settled_snapshot_with_a_worktree_and_a_submodule() -> (repon_core::Snapshot, String) {
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo_on_branch(&parent, "main");
worktree_add(&parent, &root.join("feature-worktree"), "feature");
write_gitmodules(&parent, "lib", "vendor/lib");
let submodule_short_id =
init_detached_repo_with_a_commit(&parent.join("vendor").join("lib"));
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: true,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
(core.settle(), submodule_short_id)
}
fn list_showing_submodules() -> List {
let mut list = List::default();
list.register_config_handler(crate::config::Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document: crate::config::document::Document {
show_submodules: true,
..Default::default()
},
warnings: Vec::new(),
zero_config: false,
})
.expect("register config");
list
}
fn entity_row_y(row: usize) -> u16 {
1 + FIRST_ENTITY_ROW + row as u16
}
fn absolute_x(relative: u16) -> u16 {
1 + relative
}
fn columns_of(buf: &Buffer) -> Columns {
Columns::for_interior_width(buf.area.width - 2)
}
fn name_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).name.x)
}
fn name_width(buf: &Buffer) -> u16 {
columns_of(buf).name.width
}
fn child_name_x(buf: &Buffer) -> u16 {
name_x(buf) + CHILD_ROW_PREFIX_WIDTH
}
fn child_name_width(buf: &Buffer) -> u16 {
columns_of(buf).child_name_width()
}
fn branch_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).branch.x)
}
fn branch_width(buf: &Buffer) -> u16 {
columns_of(buf).branch.width
}
fn sync_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).sync.x)
}
fn base_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).base.x)
}
fn dirty_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).dirty.x)
}
fn state_x(buf: &Buffer) -> u16 {
absolute_x(columns_of(buf).state.x)
}
fn find_entity_row<'a>(
snapshot: &'a repon_core::Snapshot,
name: &str,
) -> (usize, &'a EntityState) {
grouped_row_order(&snapshot.entities, &every_index(&snapshot.entities))
.into_iter()
.map(|index| &snapshot.entities[index])
.enumerate()
.find(|(_, entity)| entity.name.as_ref() == name)
.unwrap_or_else(|| panic!("no entity named {name:?} in the grouped row order"))
}
#[test]
fn a_child_row_is_indented_and_marked_while_its_parent_row_is_not() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (repo_row, _) = find_entity_row(&snapshot, "parent");
let (worktree_row, _) = find_entity_row(&snapshot, "feature-worktree");
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let repo_y = entity_row_y(repo_row);
let worktree_y = entity_row_y(worktree_row);
assert_eq!(
cell_text(buf, name_x(buf), repo_y, 6),
"parent",
"the top-level Repo row's name must start flush at the name column's own start"
);
assert_eq!(
cell_text(buf, name_x(buf), worktree_y, 2),
" ",
"a child row's own indent must leave the name column's own start blank"
);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, name_x(buf) + CHILD_ROW_INDENT_WIDTH, worktree_y, 1),
glyphs.child_row.to_string(),
"expected the active table's own child marker, read from the table rather than \
restated"
);
assert_eq!(
cell_text(
buf,
child_name_x(buf),
worktree_y,
"feature-worktree".len() as u16
),
"feature-worktree",
"expected the child's own name text right after the marker and its gap"
);
}
#[test]
fn a_child_rows_indent_is_two_columns_before_its_marker() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (worktree_row, _) = find_entity_row(&snapshot, "feature-worktree");
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let worktree_y = entity_row_y(worktree_row);
assert_eq!(
cell_text(buf, name_x(buf), worktree_y, 2),
" ",
"a child row's indent must leave exactly two columns blank before its marker"
);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, name_x(buf) + 2, worktree_y, 1),
glyphs.child_row.to_string(),
"the marker must sit two columns in, not four"
);
}
#[test]
fn a_submodule_row_and_a_worktree_row_share_the_same_child_marker_from_the_table() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (worktree_row, worktree_entity) = find_entity_row(&snapshot, "feature-worktree");
let (submodule_row, submodule_entity) = find_entity_row(&snapshot, "vendor/lib");
assert!(matches!(worktree_entity.kind, Kind::Worktree));
assert!(matches!(submodule_entity.kind, Kind::Submodule));
for (glyphs_config, table) in [
(
crate::config::document::Glyphs::Full,
GlyphSet::for_config(crate::config::document::Glyphs::Full),
),
(
crate::config::document::Glyphs::Ascii,
GlyphSet::for_config(crate::config::document::Glyphs::Ascii),
),
] {
let mut list = list_showing_submodules();
list.register_config_handler(crate::config::Config {
config_dir: std::path::PathBuf::new(),
data_dir: std::path::PathBuf::new(),
document: crate::config::document::Document {
show_submodules: true,
glyphs: glyphs_config,
..Default::default()
},
warnings: Vec::new(),
zero_config: false,
})
.expect("register config");
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let worktree_y = entity_row_y(worktree_row);
let submodule_y = entity_row_y(submodule_row);
let worktree_marker =
cell_text(buf, name_x(buf) + CHILD_ROW_INDENT_WIDTH, worktree_y, 1);
let submodule_marker =
cell_text(buf, name_x(buf) + CHILD_ROW_INDENT_WIDTH, submodule_y, 1);
assert_eq!(
worktree_marker, submodule_marker,
"a Worktree and a Submodule row must share one marker under {glyphs_config:?}"
);
assert_eq!(
worktree_marker,
table.child_row.to_string(),
"expected the marker read off the active table itself"
);
}
}
#[test]
fn a_child_row_with_no_visible_parent_draws_the_orphan_marker_instead_of_the_connector() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let mut list = list_showing_submodules();
list.set_filter(Filter::parse("kind:worktree"));
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let worktree_y = entity_row_y(0);
assert_eq!(
cell_text(buf, name_x(buf) + CHILD_ROW_INDENT_WIDTH, worktree_y, 1),
glyphs.orphan_child_row.to_string(),
"expected the orphan marker, not the connector, since no Repo row is ever a \
candidate under kind:worktree"
);
}
#[test]
fn the_compact_render_path_also_draws_the_orphan_marker_for_a_child_with_no_visible_parent() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let mut list = List::default();
list.set_filter(Filter::parse("kind:worktree"));
let backend = TestBackend::new(140, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw_sidebar(frame, area, &snapshot, true)
.expect("draw the sidebar");
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, name_x(buf) + CHILD_ROW_INDENT_WIDTH, 1, 1),
glyphs.orphan_child_row.to_string(),
"expected the orphan marker from draw_row_compact, the sidebar's own row \
renderer, since no Repo row is ever a candidate under kind:worktree"
);
}
#[test]
fn a_child_scrolled_to_the_top_of_the_viewport_draws_the_orphan_marker_when_its_parent_is_scrolled_off()
{
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (repo_row, _) = find_entity_row(&snapshot, "parent");
let (worktree_row, _) = find_entity_row(&snapshot, "feature-worktree");
assert_eq!(
worktree_row,
repo_row + 1,
"expected the Worktree to sit directly under its Repo in grouped order"
);
let mut list = list_showing_submodules();
list.set_offset(worktree_row);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(
buf,
name_x(buf) + CHILD_ROW_INDENT_WIDTH,
entity_row_y(0),
1
),
glyphs.orphan_child_row.to_string(),
"expected the orphan marker: the Worktree's own Repo is scrolled off the top of \
the viewport, so no parent row is visible anywhere on screen"
);
}
#[test]
fn a_child_whose_own_row_above_is_a_sibling_rather_than_the_repo_still_draws_the_connector() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let (submodule_row, submodule_entity) = find_entity_row(&snapshot, "vendor/lib");
assert!(matches!(submodule_entity.kind, Kind::Submodule));
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(
buf,
name_x(buf) + CHILD_ROW_INDENT_WIDTH,
entity_row_y(submodule_row),
1
),
glyphs.child_row.to_string(),
"the submodule's Repo is visible two rows up, through an unbroken run of its \
own already-attached Worktree sibling, so the connector still applies"
);
}
#[test]
fn every_sibling_of_a_hidden_parent_draws_the_orphan_marker_not_only_the_first() {
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo_on_branch(&parent, "main");
worktree_add(&parent, &root.join("wt-a"), "feature-a");
worktree_add(&parent, &root.join("wt-b"), "feature-b");
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let snapshot = core.settle();
let mut list = List::default();
list.set_filter(Filter::parse("kind:worktree"));
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
for row in [0usize, 1] {
assert_eq!(
cell_text(
buf,
name_x(buf) + CHILD_ROW_INDENT_WIDTH,
entity_row_y(row),
1
),
glyphs.orphan_child_row.to_string(),
"row {row} must show the orphan marker: the Repo is never a candidate under \
kind:worktree, however many Worktree siblings survive next to each other"
);
}
}
#[test]
fn a_different_repos_row_directly_above_a_child_does_not_count_as_its_visible_parent() {
let repo_a = EntityState::new(
EntityKey::new(Arc::from(Path::new("/roots/a"))),
Arc::from("repo-a"),
Arc::from(Path::new("/roots/a")),
Kind::Repo,
);
let worktree_b = EntityState::new(
EntityKey::new(Arc::from(Path::new("/roots/b/wt"))),
Arc::from("wt-b"),
Arc::from(Path::new("/roots/b")),
Kind::Worktree,
);
let snap = snapshot(vec![repo_a, worktree_b]);
let terminal = render(140, 24, &snap);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(
buf,
name_x(buf) + CHILD_ROW_INDENT_WIDTH,
entity_row_y(1),
1
),
glyphs.orphan_child_row.to_string(),
"wt-b's own parent is /roots/b, not /roots/a, so a different Repo directly \
above it on screen must not draw the connector"
);
}
#[test]
fn a_shown_submodules_row_renders_its_path_a_short_id_and_blank_base_and_state() {
let (snapshot, short_id) = settled_snapshot_with_a_worktree_and_a_submodule();
let (row, entity) = find_entity_row(&snapshot, "vendor/lib");
assert!(matches!(entity.kind, Kind::Submodule));
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(row);
assert_eq!(
cell_text(buf, child_name_x(buf), y, "vendor/lib".len() as u16),
"vendor/lib",
"expected the submodule's declared relative path as its name"
);
assert_eq!(
cell_text(buf, branch_x(buf), y, BRANCH_CELL_OBJECT_ID_WIDTH as u16),
short_id,
"expected the real commit's own nine-character abbreviated id in branch"
);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, sync_x(buf), y, 1),
glyphs.no_upstream.to_string(),
"expected no-upstream in sync, since a detached Submodule has no branch at all"
);
assert_eq!(
cell_text(buf, base_x(buf), y, BASE_WIDTH),
" ".repeat(BASE_WIDTH as usize),
"base is Unknown for a Submodule and must still render blank"
);
assert_eq!(
cell_text(buf, state_x(buf), y, STATE_WIDTH),
" ".repeat(STATE_WIDTH as usize),
"state is Unknown for a Submodule and must still render blank"
);
}
#[test]
fn an_uninitialised_submodule_still_renders_a_row_with_blank_cells_and_the_unknown_mark() {
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let parent = root.join("parent");
init_repo_on_branch(&parent, "main");
write_gitmodules(&parent, "lib", "vendor/lib");
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: true,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let snapshot = core.settle();
let (row, entity) = find_entity_row(&snapshot, "vendor/lib");
assert!(matches!(entity.kind, Kind::Submodule));
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(row);
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, absolute_x(GUTTER_X), y, 1),
glyphs.unknown.to_string(),
"expected the unknown gutter mark on an uninitialised Submodule's row"
);
assert_eq!(
cell_text(buf, branch_x(buf), y, branch_width(buf)),
" ".repeat(branch_width(buf) as usize),
"branch must render blank rather than any value at all"
);
assert_eq!(
cell_text(buf, sync_x(buf), y, SYNC_WIDTH),
" ".repeat(SYNC_WIDTH as usize),
"sync must render blank rather than any value at all"
);
assert_eq!(
cell_text(buf, dirty_x(buf), y, DIRTY_WIDTH),
" ".repeat(DIRTY_WIDTH as usize),
"dirty must render blank rather than any value at all"
);
}
#[test]
fn hidden_submodules_draw_no_row_while_shown_ones_do_from_the_same_snapshot() {
let (snapshot, _) = settled_snapshot_with_a_worktree_and_a_submodule();
let hidden_terminal = render(140, 24, &snapshot);
let hidden_buf = hidden_terminal.backend().buffer();
let hidden_text: String = hidden_buf
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(
!hidden_text.contains("vendor/lib"),
"a hidden Submodule must draw no row at all"
);
let mut shown_list = list_showing_submodules();
let shown_terminal = render_with_list(&mut shown_list, 140, 24, &snapshot);
let shown_buf = shown_terminal.backend().buffer();
let shown_text: String = shown_buf
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(
shown_text.contains("vendor/lib"),
"the same Submodule, shown, must draw its row"
);
}
fn arithmetic_triples(sentence: &str) -> Vec<[u16; 3]> {
sentence
.match_indices(" minus ")
.map(|(at, separator)| {
let before = &sentence[..at];
let total_start = before
.rfind(|c: char| !c.is_ascii_digit())
.map_or(0, |index| index + 1);
let rest = &sentence[at + separator.len()..];
let (cost, after) = rest
.split_once(" = ")
.unwrap_or_else(|| panic!("a \" minus \" with no \" = \": {sentence:?}"));
let budget_end = after
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after.len());
let number = |text: &str| -> u16 {
text.parse()
.unwrap_or_else(|_| panic!("not a number: {text:?} in {sentence:?}"))
};
[
number(&before[total_start..]),
number(cost),
number(&after[..budget_end]),
]
})
.collect()
}
#[test]
fn child_name_budget_matches_the_specs_own_arithmetic() {
let spec_path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/layout-and-provenance.md");
let spec = std::fs::read_to_string(&spec_path)
.unwrap_or_else(|err| panic!("read {}: {err}", spec_path.display()));
let needle = "A child name gets ";
let start = spec
.find(needle)
.expect("expected the spec to still state the child-name-budget sentence")
+ needle.len();
let sentence = &spec[start
..start
+ spec[start..]
.find(". ")
.expect("the child-name-budget sentence must end")];
let triples = arithmetic_triples(sentence);
let [
[minimum, minimum_cost, minimum_budget],
[cap, cap_cost, cap_budget],
] = triples[..]
else {
panic!("expected the spec to state one sum per end of the rule, got {triples:?}");
};
assert_eq!(
minimum - minimum_cost,
minimum_budget,
"the spec's own arithmetic must hold at the name column's minimum"
);
assert_eq!(
cap - cap_cost,
cap_budget,
"the spec's own arithmetic must hold at the name column's cap"
);
assert_eq!(
(NAME_MIN_WIDTH, NAME_MAX_WIDTH),
(minimum, cap),
"the name column's minimum and cap must match the spec's own two figures"
);
assert_eq!(
(CHILD_ROW_PREFIX_WIDTH, CHILD_ROW_PREFIX_WIDTH),
(minimum_cost, cap_cost),
"the reserved prefix (indent, marker, gap) must match the spec's own figure, and \
must not change between the two ends of the rule"
);
assert_eq!(
Columns::for_interior_width(PACKED_MIN_WIDTH).child_name_width(),
minimum_budget,
"a child name on a frame with no slack must get the spec's own minimum budget"
);
assert_eq!(
Columns::for_interior_width(u16::MAX).child_name_width(),
cap_budget,
"a child name on a frame past the cap must get the spec's own capped budget"
);
}
fn rev_parse(path: &Path, rev: &str) -> String {
let output = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", rev])
.output()
.expect("run git rev-parse");
assert!(output.status.success());
String::from_utf8(output.stdout)
.expect("utf8 sha")
.trim()
.to_string()
}
fn commit_allow_empty(path: &Path, message: &str) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
.args(["commit", "--allow-empty", "-m", message])
.status()
.expect("run git commit");
assert!(status.success());
}
struct HeadShapeMatrixIds {
manage_detached_id: String,
pr_920_detached_id: String,
vendor_lib_detached_id: String,
}
fn settled_snapshot_for_the_head_shape_matrix() -> (repon_core::Snapshot, HeadShapeMatrixIds) {
use repon_core::{Core, CoreSpec, RepoOverride, SetSpec};
use std::time::Duration;
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let manage = root.join("manage");
init_repo_on_branch(&manage, "main");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args([
"remote",
"add",
"origin",
"https://example.invalid/manage.git",
])
.status()
.expect("run git remote add");
assert!(status.success());
let c1 = rev_parse(&manage, "HEAD");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args(["branch", "feature"])
.status()
.expect("run git branch feature");
assert!(status.success());
let feature_worktree = root.join("feature-worktree");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args([
"worktree",
"add",
feature_worktree.to_str().expect("utf8 path"),
"feature",
])
.status()
.expect("run git worktree add");
assert!(status.success());
commit_allow_empty(&feature_worktree, "feature work");
std::fs::write(feature_worktree.join("untracked.txt"), "x").expect("write untracked file");
commit_allow_empty(&manage, "main moved on");
let c3 = rev_parse(&manage, "HEAD");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args(["update-ref", "refs/remotes/origin/main", &c3])
.status()
.expect("run git update-ref");
assert!(status.success());
let pr_920 = root.join("pr-920");
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args([
"worktree",
"add",
"--detach",
pr_920.to_str().expect("utf8 path"),
&c3,
])
.status()
.expect("run git worktree add --detach");
assert!(status.success());
let status = std::process::Command::new("git")
.arg("-C")
.arg(&manage)
.args(["checkout", "--quiet", "--detach", &c1])
.status()
.expect("run git checkout --detach");
assert!(status.success());
write_gitmodules(&manage, "lib", "vendor/lib");
let vendor_lib_detached_id =
init_detached_repo_with_a_commit(&manage.join("vendor").join("lib"));
let brand_new = root.join("brand-new");
init_unborn_repo_on_branch(&brand_new, "main");
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: vec![RepoOverride {
path: brand_new.clone(),
default_branch: Some("main".to_string()),
excluded: false,
}],
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: true,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
let snapshot = core.settle();
(
snapshot,
HeadShapeMatrixIds {
manage_detached_id: c1.chars().take(BRANCH_CELL_OBJECT_ID_WIDTH).collect(),
pr_920_detached_id: c3.chars().take(BRANCH_CELL_OBJECT_ID_WIDTH).collect(),
vendor_lib_detached_id,
},
)
}
fn padded(text: &str, width: u16) -> String {
format!("{text:<width$}", width = width as usize)
}
fn child_name_cell(buf: &Buffer, glyphs: &'static GlyphSet, name: &str) -> String {
format!(
"{}{}{}{}",
" ".repeat(CHILD_ROW_INDENT_WIDTH as usize),
glyphs.child_row,
" ".repeat(CHILD_ROW_GAP_WIDTH as usize),
padded(name, child_name_width(buf))
)
}
#[test]
fn the_head_shape_matrix_renders_every_cell_of_every_row_correctly_at_once() {
let (snapshot, ids) = settled_snapshot_for_the_head_shape_matrix();
assert_eq!(
snapshot.entities.len(),
5,
"expected exactly the fixture's five rows"
);
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
struct Row<'a> {
name: &'a str,
gutter: char,
name_cell: String,
branch: &'a str,
sync: &'a str,
base: &'a str,
dirty: &'a str,
state: &'a str,
}
let rows = [
Row {
name: "manage",
gutter: glyphs.fresh,
name_cell: padded("manage", name_width(buf)),
branch: ids.manage_detached_id.as_str(),
sync: "-",
base: "↓1",
dirty: "●2",
state: "",
},
Row {
name: "feature-worktree",
gutter: glyphs.fresh,
name_cell: child_name_cell(buf, glyphs, "feature-worktree"),
branch: "feature",
sync: "-",
base: "↓1",
dirty: "●1",
state: "local only",
},
Row {
name: "pr-920",
gutter: glyphs.fresh,
name_cell: child_name_cell(buf, glyphs, "pr-920"),
branch: ids.pr_920_detached_id.as_str(),
sync: "-",
base: "≡",
dirty: "·",
state: "merged",
},
Row {
name: "vendor/lib",
gutter: glyphs.unknown,
name_cell: child_name_cell(buf, glyphs, "vendor/lib"),
branch: ids.vendor_lib_detached_id.as_str(),
sync: "-",
base: "",
dirty: "·",
state: "",
},
Row {
name: "brand-new",
gutter: glyphs.fresh,
name_cell: padded("brand-new", name_width(buf)),
branch: "main",
sync: "-",
base: "",
dirty: "·",
state: "",
},
];
for row in rows {
let (index, entity) = find_entity_row(&snapshot, row.name);
let y = entity_row_y(index);
assert_eq!(entity.name.as_ref(), row.name);
assert_eq!(
cell_text(buf, absolute_x(GUTTER_X), y, 1),
row.gutter.to_string(),
"{}: gutter",
row.name
);
assert_eq!(
cell_text(buf, name_x(buf), y, name_width(buf)),
row.name_cell,
"{}: name",
row.name
);
assert_eq!(
cell_text(buf, branch_x(buf), y, branch_width(buf)),
padded(row.branch, branch_width(buf)),
"{}: branch",
row.name
);
assert_eq!(
cell_text(buf, sync_x(buf), y, SYNC_WIDTH),
padded(row.sync, SYNC_WIDTH),
"{}: sync",
row.name
);
assert_eq!(
cell_text(buf, base_x(buf), y, BASE_WIDTH),
padded(row.base, BASE_WIDTH),
"{}: base",
row.name
);
assert_eq!(
cell_text(buf, dirty_x(buf), y, DIRTY_WIDTH),
padded(row.dirty, DIRTY_WIDTH),
"{}: dirty",
row.name
);
assert_eq!(
cell_text(buf, state_x(buf), y, STATE_WIDTH),
padded(row.state, STATE_WIDTH),
"{}: state",
row.name
);
}
}
#[test]
fn head_detached_reaches_every_kind_of_detached_row_without_opening_the_detail_pane() {
let (snapshot, _ids) = settled_snapshot_for_the_head_shape_matrix();
let filter = Filter::parse("head:detached");
let visible = visible_row_order(
&snapshot.entities,
Visibility::everything(),
&filter,
RowOrder::Natural,
&HashSet::new(),
);
let names: std::collections::BTreeSet<&str> = visible
.iter()
.map(|&index| snapshot.entities[index].name.as_ref())
.collect();
assert_eq!(
names,
["manage", "pr-920", "vendor/lib"].into_iter().collect(),
"head:detached must reach a detached Repo, Worktree and Submodule alike, and \
nothing else in the matrix"
);
assert!(
!names.contains("feature-worktree"),
"an attached row must never match head:detached, or this term matches every row"
);
assert!(
!names.contains("brand-new"),
"an unborn row must never match head:detached either"
);
}
#[test]
fn format_head_is_called_from_exactly_one_production_site_besides_its_own_declaration() {
let files: usize = crate::test_support::workspace_crate_src_dirs()
.iter()
.map(|dir| crate::test_support::rust_source_files(dir).len())
.sum();
assert!(
files > 0,
"scanned zero source files, so a count of zero below would report a second \
branch-cell rule rather than a scan that read nothing"
);
let offending = crate::test_support::production_lines_containing("format_head(");
assert_eq!(
offending.len(),
2,
"expected exactly two matches (format_head's own declaration and its one call \
site in draw_row); a count that moved means a second, potentially kind-specific \
branch-cell rule crept in, at: {offending:?}"
);
}
#[test]
fn the_branch_cells_colour_cannot_disambiguate_a_branch_name_from_an_object_id() {
let (snapshot, _ids) = settled_snapshot_for_the_head_shape_matrix();
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let (attached_row, _) = find_entity_row(&snapshot, "feature-worktree");
let (detached_row, _) = find_entity_row(&snapshot, "manage");
let attached_fg = buf[(branch_x(buf), entity_row_y(attached_row))].fg;
let detached_fg = buf[(branch_x(buf), entity_row_y(detached_row))].fg;
assert_eq!(
attached_fg, detached_fg,
"a branch name and a detached object id must take the same colour, since colour \
is never the only carrier of meaning (theming.md); the detail pane's full id is \
the only discriminator (ADR 0019's accepted cost)"
);
}
fn init_detached_repo_with_a_commit_and_core_abbrev(path: &Path, abbrev: &str) -> String {
let id = init_detached_repo_with_a_commit(path);
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["config", "core.abbrev", abbrev])
.status()
.expect("run git config core.abbrev");
assert!(status.success());
id
}
fn settled_snapshot_of_one_repo_at(root: &Path) -> repon_core::Snapshot {
use repon_core::{Core, CoreSpec, SetSpec};
use std::time::Duration;
let core = Core::start_discovered(CoreSpec {
set: SetSpec {
name: "test".to_string(),
roots: vec![root.to_path_buf()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: Duration::from_secs(3600),
status_stale_after: Duration::from_secs(3600),
generation_deadline: Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let keys: Vec<_> = core
.snapshot()
.entities
.iter()
.map(|entity| entity.key.clone())
.collect();
core.refresh(&keys);
core.settle()
}
#[test]
fn the_branch_cells_abbreviation_is_fixed_regardless_of_the_repositorys_own_core_abbrev() {
for abbrev in ["4", "40"] {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
let expected_id = init_detached_repo_with_a_commit_and_core_abbrev(&root, abbrev);
assert_eq!(expected_id.len(), BRANCH_CELL_OBJECT_ID_WIDTH);
let snapshot = settled_snapshot_of_one_repo_at(&root);
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let terminal = render(140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
assert_eq!(
cell_text(buf, branch_x(buf), y, BRANCH_CELL_OBJECT_ID_WIDTH as u16),
expected_id,
"core.abbrev={abbrev}: expected the fixed nine-character id regardless of \
the repository's own abbreviation setting"
);
}
}
#[test]
fn branch_cell_object_id_width_matches_head_mds_own_prose() {
fn number_word_to_digit(word: &str) -> usize {
match word {
"one" => 1,
"two" => 2,
"three" => 3,
"four" => 4,
"five" => 5,
"six" => 6,
"seven" => 7,
"eight" => 8,
"nine" => 9,
"ten" => 10,
other => panic!("unrecognised number word {other:?} in head.md's own prose"),
}
}
let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/head.md");
let spec = std::fs::read_to_string(&spec_path)
.unwrap_or_else(|error| panic!("read {}: {error}", spec_path.display()));
let needle = "The abbreviation is ";
let start = spec
.find(needle)
.expect("expected head.md to still state the abbreviation-width sentence")
+ needle.len();
let word = spec[start..]
.split_whitespace()
.next()
.expect("a word after 'The abbreviation is '");
assert_eq!(
BRANCH_CELL_OBJECT_ID_WIDTH,
number_word_to_digit(word),
"the production constant must match head.md's own stated width"
);
}
#[test]
fn ahead_and_behind_read_as_distinct_counts_once_colour_is_set_aside() {
let snapshot = settled_snapshot_with_an_ahead_and_behind_sync();
let terminal = render(140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
let ahead_text = cell_text(buf, sync_x(buf), y, 2);
let behind_text = cell_text(buf, sync_x(buf) + 3, y, 2);
assert_eq!(ahead_text.trim(), "↑1");
assert_eq!(behind_text.trim(), "↓1");
assert_ne!(
ahead_text.trim(),
behind_text.trim(),
"ahead and behind must read as distinct text with no colour, got {ahead_text:?} \
and {behind_text:?}"
);
}
#[test]
fn dirty_the_provenance_gutter_and_two_worktree_states_read_as_distinct_text_once_colour_is_set_aside()
{
let (snapshot, _ids) = settled_snapshot_for_the_head_shape_matrix();
let mut list = list_showing_submodules();
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let (dirty_row, _) = find_entity_row(&snapshot, "feature-worktree");
let (clean_row, _) = find_entity_row(&snapshot, "pr-920");
let dirty_text = cell_text(buf, dirty_x(buf), entity_row_y(dirty_row), DIRTY_WIDTH);
let clean_text = cell_text(buf, dirty_x(buf), entity_row_y(clean_row), DIRTY_WIDTH);
assert_ne!(
dirty_text.trim(),
clean_text.trim(),
"Dirty and a known zero must read as distinct text with no colour, got \
{dirty_text:?} and {clean_text:?}"
);
let (unknown_row, _) = find_entity_row(&snapshot, "vendor/lib");
let (fresh_row, _) = find_entity_row(&snapshot, "manage");
let unknown_gutter = cell_text(buf, absolute_x(GUTTER_X), entity_row_y(unknown_row), 1);
let fresh_gutter = cell_text(buf, absolute_x(GUTTER_X), entity_row_y(fresh_row), 1);
assert_ne!(
unknown_gutter, fresh_gutter,
"the provenance gutter's Unknown mark must read distinct from Fresh's blank with \
no colour, got {unknown_gutter:?} and {fresh_gutter:?}"
);
let (local_only_row, _) = find_entity_row(&snapshot, "feature-worktree");
let (merged_row, _) = find_entity_row(&snapshot, "pr-920");
let local_only_text =
cell_text(buf, state_x(buf), entity_row_y(local_only_row), STATE_WIDTH);
let merged_text = cell_text(buf, state_x(buf), entity_row_y(merged_row), STATE_WIDTH);
assert_eq!(local_only_text.trim(), "local only");
assert_eq!(merged_text.trim(), "merged");
assert_ne!(local_only_text.trim(), merged_text.trim());
}
#[test]
fn loading_and_fresh_stay_distinguishable_because_loading_moves_and_fresh_does_not() {
let mut snap = settled_snapshot_with_a_resolvable_default_branch("main");
snap.entities[0].base = repon_core::Cell::default();
assert!(
snap.entities[0].base.settled().is_none(),
"sanity check: base carries nothing settled yet"
);
assert!(
snap.entities[0].branch.settled().is_some(),
"sanity check: branch is already Fresh in the same row"
);
let mut at_zero = List {
started_at: Instant::now(),
..List::default()
};
let first_tick = render_with_list(&mut at_zero, 140, 24, &snap);
let base_first = {
let buf = first_tick.backend().buffer();
cell_text(buf, base_x(buf), 2, 1)
};
let branch_first = {
let buf = first_tick.backend().buffer();
cell_text(buf, branch_x(buf), entity_row_y(0), branch_width(buf))
};
let mut later = List {
started_at: Instant::now() - FULL_SPINNER_INTERVAL * 5,
..List::default()
};
let second_tick = render_with_list(&mut later, 140, 24, &snap);
let base_second = {
let buf = second_tick.backend().buffer();
cell_text(buf, base_x(buf), 2, 1)
};
let branch_second = {
let buf = second_tick.backend().buffer();
cell_text(buf, branch_x(buf), entity_row_y(0), branch_width(buf))
};
assert_ne!(
base_first, base_second,
"Loading must keep moving between ticks"
);
assert_eq!(
branch_first, branch_second,
"a Fresh, already-settled cell must render identically across ticks: the static \
baseline Loading's motion is what tells the two apart with no colour"
);
}
fn is_reversed(buf: &Buffer, x: u16, y: u16) -> bool {
buf[(x, y)].modifier.contains(Modifier::REVERSED)
}
#[test]
fn the_cursor_rows_highlight_covers_every_cell_of_its_full_interior_width_and_no_other_row() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut list = List::default();
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let interior_width = 138;
for x in 1..1 + interior_width {
assert!(
is_reversed(buf, x, entity_row_y(1)),
"cursor row cell at x={x} must be reversed, not just the cells with text in \
them"
);
}
for row in [0, 2] {
for x in 1..1 + interior_width {
assert!(
!is_reversed(buf, x, entity_row_y(row)),
"row {row} cell at x={x} is not the cursor row and must not be reversed"
);
}
}
}
#[test]
fn the_cursor_rows_foreground_is_uniform_across_every_column_not_banded_by_role_colour() {
let snapshot = settled_snapshot_with_a_nonzero_base_and_dirty_count();
assert_eq!(snapshot.entities.len(), 1, "expected one discovered repo");
let base_role = role_named_in_theming_md("Behind count");
let dirty_role = role_named_in_theming_md("Dirty");
assert_ne!(
theme::DEFAULT.role_color(base_role),
theme::DEFAULT.role_color(dirty_role),
"sanity: the fixture must exercise two different role colours"
);
let mut list = List::default();
list.set_cursor(0);
let terminal = render_with_list(&mut list, 140, 24, &snapshot);
let buf = terminal.backend().buffer();
let y = entity_row_y(0);
let interior_width: u16 = 138;
let mut distinct_colours = std::collections::HashSet::new();
let mut columns_checked = 0;
for x in 1..1 + interior_width {
distinct_colours.insert(buf[(x, y)].fg);
columns_checked += 1;
}
assert_eq!(
columns_checked, 138,
"must read every column of the row's interior, not sample one"
);
assert_eq!(
distinct_colours,
std::collections::HashSet::from([Color::Reset]),
"expected all {columns_checked} columns to share one uniform reset foreground \
once the cursor highlights the row; got distinct foregrounds {distinct_colours:?}, \
which is exactly the per-cell banding this test exists to catch"
);
}
#[test]
fn the_cursor_row_is_reverse_video_by_default_and_the_other_rows_are_not() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut list = List::default();
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert!(
is_reversed(buf, name_x(buf), entity_row_y(1)),
"the cursor row (offset 1, \"beta\") must render reversed with no theme selection \
colours set"
);
for (row, name) in [(0, "alpha"), (2, "gamma")] {
assert!(
!is_reversed(buf, name_x(buf), entity_row_y(row)),
"row {row} (\"{name}\") is not the cursor row and must not be reversed"
);
}
}
#[test]
fn exactly_one_row_carries_the_cursor_highlight_at_a_time() {
let snap = snapshot(vec![
entity("alpha"),
entity("beta"),
entity("gamma"),
entity("delta"),
]);
let mut list = List::default();
list.set_cursor(2);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let highlighted_rows = (0..4)
.filter(|&row| is_reversed(buf, name_x(buf), entity_row_y(row)))
.count();
assert_eq!(
highlighted_rows, 1,
"exactly one row must carry the cursor highlight, got {highlighted_rows}"
);
}
#[test]
fn the_cursor_highlight_follows_the_screen_row_once_the_viewport_has_scrolled() {
let snap = snapshot(vec![
entity("alpha"),
entity("beta"),
entity("gamma"),
entity("delta"),
]);
let mut list = List::default();
list.set_offset(1);
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, name_x(buf), entity_row_y(0), 4),
"beta",
"an offset of 1 must put the cursor's own row, `beta`, on screen row 0"
);
assert!(
is_reversed(buf, name_x(buf), entity_row_y(0)),
"the cursor row must carry the highlight at its screen row, not at its row_order index"
);
assert!(
!is_reversed(buf, name_x(buf), entity_row_y(1)),
"`gamma` is not the cursor row and must carry no highlight"
);
}
#[test]
fn a_cursor_above_the_window_highlights_no_row_rather_than_the_windows_first() {
let snap = snapshot(vec![
entity("alpha"),
entity("beta"),
entity("gamma"),
entity("delta"),
]);
let mut list = List::default();
list.set_offset(2);
list.set_cursor(0);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let highlighted_rows = (0..2)
.filter(|&row| is_reversed(buf, name_x(buf), entity_row_y(row)))
.count();
assert_eq!(
highlighted_rows, 0,
"a cursor above the window must leave every drawn row unhighlighted, got \
{highlighted_rows}"
);
}
#[test]
fn moving_the_cursor_moves_the_highlight_off_the_old_row_and_onto_the_new_one() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut list = List::default();
let backend = TestBackend::new(140, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
list.set_cursor(0);
terminal
.draw(|frame| {
let area = frame.area();
list.draw(frame, area, &snap, true).expect("draw the list");
})
.expect("draw the frame");
{
let buf = terminal.backend().buffer();
assert!(
is_reversed(buf, name_x(buf), entity_row_y(0)),
"row 0 must be reversed while the cursor sits on it"
);
assert!(
!is_reversed(buf, name_x(buf), entity_row_y(1)),
"row 1 must not be reversed before the cursor ever reaches it"
);
}
list.set_cursor(1);
terminal
.draw(|frame| {
let area = frame.area();
list.draw(frame, area, &snap, true).expect("draw the list");
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert!(
!is_reversed(buf, name_x(buf), entity_row_y(0)),
"row 0 must lose the highlight once the cursor moves off it"
);
assert!(
is_reversed(buf, name_x(buf), entity_row_y(1)),
"row 1 must gain the highlight once the cursor moves onto it"
);
}
#[test]
fn a_theme_with_explicit_selection_colours_paints_the_cursor_row_with_them() {
let snap = snapshot(vec![entity("alpha"), entity("beta")]);
let mut list = List::default();
list.set_theme(Theme {
selection_fg: Some(Color::Black),
selection_bg: Some(Color::LightBlue),
..Theme::default()
});
list.set_cursor(0);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let cursor_cell = &buf[(name_x(buf), entity_row_y(0))];
assert_eq!(cursor_cell.fg, Color::Black);
assert_eq!(cursor_cell.bg, Color::LightBlue);
assert!(
!cursor_cell.modifier.contains(Modifier::REVERSED),
"an explicit selection colour must not also be reversed"
);
let other_cell = &buf[(name_x(buf), entity_row_y(1))];
assert_ne!(
other_cell.bg,
Color::LightBlue,
"a row that is not the cursor must not take the selection background"
);
}
#[test]
fn the_sidebar_also_reverses_the_cursor_row_and_no_other() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut list = List::default();
list.set_cursor(1);
let terminal = {
let backend = TestBackend::new(SIDEBAR_WIDTH, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw_sidebar(frame, area, &snap, true)
.expect("draw the sidebar");
})
.expect("draw the frame");
terminal
};
let buf = terminal.backend().buffer();
let sidebar_row_y = |row: u16| 1 + row;
assert!(
is_reversed(buf, name_x(buf), sidebar_row_y(1)),
"the sidebar's cursor row must be reversed"
);
for row in [0, 2] {
assert!(
!is_reversed(buf, name_x(buf), sidebar_row_y(row)),
"sidebar row {row} is not the cursor row and must not be reversed"
);
}
}
#[test]
fn the_full_list_and_the_sidebar_each_highlight_their_own_row_zero_at_cursor_zero() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut full_list = List::default();
full_list.set_cursor(0);
let full_terminal = render_with_list(&mut full_list, 140, 24, &snap);
let full_buf = full_terminal.backend().buffer();
assert!(
is_reversed(full_buf, name_x(full_buf), entity_row_y(0)),
"the full list must highlight its own row 0 at cursor 0"
);
let mut sidebar_list = List::default();
sidebar_list.set_cursor(0);
let sidebar_terminal = {
let backend = TestBackend::new(SIDEBAR_WIDTH, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
sidebar_list
.draw_sidebar(frame, area, &snap, true)
.expect("draw the sidebar");
})
.expect("draw the frame");
terminal
};
let sidebar_buf = sidebar_terminal.backend().buffer();
assert!(
is_reversed(sidebar_buf, name_x(sidebar_buf), 1),
"the sidebar must highlight its own row 0 (y=1, one line higher than the full \
list's row 0 because it has no header) at cursor 0"
);
}
#[test]
fn the_highlight_is_keyed_to_position_in_the_filtered_row_order_not_the_raw_snapshot_index() {
let snap = snapshot(vec![entity("alpha"), entity("beta"), entity("gamma")]);
let mut list = List::default();
list.set_filter(Filter::parse("-beta"));
list.set_cursor(1);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert_eq!(
cell_text(buf, name_x(buf), entity_row_y(0), 5),
"alpha",
"sanity: beta filtered out, alpha is still offset 0"
);
assert_eq!(
cell_text(buf, name_x(buf), entity_row_y(1), 5),
"gamma",
"sanity: with beta filtered out, gamma is now offset 1"
);
assert!(
is_reversed(buf, name_x(buf), entity_row_y(1)),
"gamma, now at the cursor's offset, must carry the highlight"
);
assert!(
!is_reversed(buf, name_x(buf), entity_row_y(0)),
"alpha must not carry the highlight"
);
}
fn checked_selection(keys: impl IntoIterator<Item = EntityKey>) -> Selection {
let mut selection = Selection::new();
selection.select_all_visible(&keys.into_iter().collect::<Vec<_>>());
selection
}
fn columns_showing(buf: &Buffer, y: u16, interior_width: u16, glyph: char) -> Vec<u16> {
(1..1 + interior_width)
.filter(|&x| buf[(x, y)].symbol().starts_with(glyph))
.collect()
}
#[test]
fn a_checked_rows_marker_appears_exactly_once_at_its_own_column_and_no_other_row_shows_it() {
let entities = vec![entity("alpha"), entity("beta"), entity("gamma")];
let checked_key = entities[1].key.clone();
let snap = snapshot(entities);
let mut list = List::default();
list.set_selection(checked_selection([checked_key]));
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let interior_width = 138;
assert_eq!(
columns_showing(buf, entity_row_y(1), interior_width, glyphs.checked),
vec![absolute_x(SELECTED_X)],
"the checked row must show the marker glyph exactly once, at its own column"
);
for row in [0, 2] {
assert_eq!(
columns_showing(buf, entity_row_y(row), interior_width, glyphs.checked),
Vec::<u16>::new(),
"row {row} is not checked and must not show the marker glyph anywhere"
);
}
}
#[test]
fn a_checked_row_that_is_not_the_cursor_shows_the_marker_but_is_not_reversed() {
let entities = vec![entity("alpha"), entity("beta")];
let checked_key = entities[1].key.clone();
let snap = snapshot(entities);
let mut list = List::default();
list.set_cursor(0);
list.set_selection(checked_selection([checked_key]));
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), entity_row_y(1), 1),
glyphs.checked.to_string(),
"the checked row (\"beta\") must show the marker glyph"
);
assert!(
!is_reversed(buf, absolute_x(SELECTED_X), entity_row_y(1)),
"the checked row is not the cursor and its marker must not be reversed"
);
}
#[test]
fn the_cursor_row_that_is_not_checked_is_reversed_and_shows_no_marker() {
let snap = snapshot(vec![entity("alpha"), entity("beta")]);
let mut list = List::default();
list.set_cursor(0);
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
assert!(
is_reversed(buf, name_x(buf), entity_row_y(0)),
"the cursor row must be reversed"
);
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), entity_row_y(0), 1),
" ",
"the cursor row is not checked and must show a blank marker column"
);
}
#[test]
fn a_row_that_is_both_the_cursor_and_checked_is_reversed_and_still_shows_the_marker() {
let entities = vec![entity("alpha"), entity("beta")];
let checked_key = entities[0].key.clone();
let snap = snapshot(entities);
let mut list = List::default();
list.set_cursor(0);
list.set_selection(checked_selection([checked_key]));
let terminal = render_with_list(&mut list, 140, 24, &snap);
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
assert!(
is_reversed(buf, name_x(buf), entity_row_y(0)),
"a row that is both must still be reversed"
);
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), entity_row_y(0), 1),
glyphs.checked.to_string(),
"a row that is both must still show the marker glyph"
);
assert!(
is_reversed(buf, absolute_x(SELECTED_X), entity_row_y(0)),
"the marker's own cell must carry the reversed modifier too, inside the bar \
rather than punched out of it"
);
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), entity_row_y(1), 1),
" ",
"the other row is neither the cursor nor checked and must show no marker"
);
}
#[test]
fn the_sidebar_also_shows_the_marker_on_a_checked_row_and_no_other() {
let entities = vec![entity("alpha"), entity("beta"), entity("gamma")];
let checked_key = entities[1].key.clone();
let snap = snapshot(entities);
let mut list = List::default();
list.set_selection(checked_selection([checked_key]));
let terminal = {
let backend = TestBackend::new(SIDEBAR_WIDTH, 24);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
list.draw_sidebar(frame, area, &snap, true)
.expect("draw the sidebar");
})
.expect("draw the frame");
terminal
};
let buf = terminal.backend().buffer();
let glyphs = GlyphSet::for_config(crate::config::document::Glyphs::default());
let sidebar_row_y = |row: u16| 1 + row;
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), sidebar_row_y(1), 1),
glyphs.checked.to_string(),
"the sidebar's checked row must show the marker glyph"
);
for row in [0, 2] {
assert_eq!(
cell_text(buf, absolute_x(SELECTED_X), sidebar_row_y(row), 1),
" ",
"sidebar row {row} is not checked and must show a blank marker column"
);
}
}
#[test]
fn no_production_source_reaches_for_modifier_underlined_anywhere_in_the_workspace() {
let offending = crate::test_support::production_lines_containing("UNDERLINED");
assert_eq!(
offending,
Vec::<String>::new(),
"expected no production source to reach for Modifier::UNDERLINED: the Selection's \
own mark is a glyph in its own column now, not a row-wide underline"
);
}
#[test]
fn theming_md_names_the_selections_marker_column_and_its_composition_with_the_cursor() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/theming.md"))
.expect("read the theming specification");
let section = spec
.split("### The Selection")
.nth(1)
.expect("theming.md must contain a \"### The Selection\" section")
.split("## Colour is never the only carrier")
.next()
.expect("\"### The Selection\" must precede the next top-level heading");
assert!(
section.contains("marker column"),
"expected theming.md's \"The Selection\" section to name the marker-column \
treatment"
);
assert!(
section.contains("reversed"),
"expected theming.md to name the cursor row's reversed treatment, so the \
composition below has something to compose with"
);
assert!(
section.contains("inside the reversed"),
"expected theming.md to name the composed, both-at-once treatment: the marker \
shown inside the reversed bar for a row that is both"
);
assert!(
!section.contains("underlined"),
"expected the underline treatment to be gone from theming.md's \"The Selection\" \
section, replaced by the marker column rather than merely joined by it"
);
}
}