use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anstyle::Style;
use unicode_width::UnicodeWidthStr;
use worktrunk::styling::{ADDITION, DELETION, Stream, supports_hyperlinks};
use crate::display::{shorten_path, terminal_width};
use super::collect::{TaskKind, parse_port_from_url};
use super::columns::{COLUMN_SPECS, ColumnKind, ColumnSpec, column_display_index};
pub use super::columns::DiffVariant;
const COMMIT_HASH_WIDTH: usize = 8;
fn fit_header(header: &str, data_width: usize) -> usize {
data_width.max(header.width())
}
fn try_allocate(
remaining: &mut usize,
ideal_width: usize,
min_width: Option<usize>,
spacing: usize,
is_first: bool,
) -> usize {
if ideal_width == 0 {
return 0;
}
let spacing_cost = if is_first { 0 } else { spacing };
if *remaining >= ideal_width + spacing_cost {
*remaining -= ideal_width + spacing_cost;
return ideal_width;
}
if let Some(min) = min_width
&& *remaining >= min + spacing_cost
{
let width = *remaining - spacing_cost;
*remaining = 0;
return width;
}
0
}
#[derive(Clone, Copy, Debug)]
pub struct DiffWidths {
pub total: usize,
pub positive_digits: usize, pub negative_digits: usize, }
#[derive(Clone, Debug)]
pub struct ColumnWidths {
pub branch: usize,
pub status: usize, pub time: usize,
pub url: usize,
pub ci_status: usize,
pub ahead_behind: DiffWidths,
pub working_diff: DiffWidths,
pub branch_diff: DiffWidths,
pub upstream: DiffWidths,
}
#[derive(Clone, Copy, Debug)]
pub struct ColumnDataFlags {
pub status: bool, pub working_diff: bool,
pub ahead_behind: bool,
pub branch_diff: bool,
pub upstream: bool,
pub url: bool,
pub ci_status: bool,
pub path: bool, }
#[derive(Clone, Debug)]
pub struct LayoutMetadata {
pub widths: ColumnWidths,
pub data_flags: ColumnDataFlags,
pub status_position_mask: super::model::PositionMask,
}
const EMPTY_PENALTY: u8 = 10;
#[derive(Clone, Copy, Debug)]
pub struct DiffDisplayConfig {
pub variant: DiffVariant,
pub positive_style: Style,
pub negative_style: Style,
pub always_show_zeros: bool,
}
impl DiffDisplayConfig {
#[cfg(unix)] pub fn format_aligned(&self, positive: usize, negative: usize) -> String {
const DIGITS: usize = 3;
let positive_width = 1 + DIGITS; let negative_width = 1 + DIGITS;
let total_width = positive_width + 1 + negative_width;
let config = DiffColumnConfig {
positive_digits: DIGITS,
negative_digits: DIGITS,
total_width,
display: *self,
};
config.render_segment(positive, negative).render()
}
pub fn format_plain(&self, positive: usize, negative: usize) -> Option<String> {
if !self.always_show_zeros && positive == 0 && negative == 0 {
return None;
}
let symbols = self.variant.symbols();
let mut parts = Vec::with_capacity(2);
if positive > 0 || self.always_show_zeros {
parts.push(format!(
"{}{}{}{}",
self.positive_style,
symbols.positive,
positive,
self.positive_style.render_reset()
));
}
if negative > 0 || self.always_show_zeros {
parts.push(format!(
"{}{}{}{}",
self.negative_style,
symbols.negative,
negative,
self.negative_style.render_reset()
));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
}
#[derive(Clone, Copy)]
pub(super) struct DiffSymbols {
pub(super) positive: &'static str,
pub(super) negative: &'static str,
}
impl DiffVariant {
pub(super) fn symbols(self) -> DiffSymbols {
match self {
DiffVariant::Signs => DiffSymbols {
positive: "+",
negative: "-",
},
DiffVariant::Arrows => DiffSymbols {
positive: "↑",
negative: "↓",
},
DiffVariant::UpstreamArrows => DiffSymbols {
positive: "⇡",
negative: "⇣",
},
}
}
}
impl ColumnKind {
pub fn diff_display_config(self) -> Option<DiffDisplayConfig> {
match self {
ColumnKind::WorkingDiff | ColumnKind::BranchDiff => Some(DiffDisplayConfig {
variant: DiffVariant::Signs,
positive_style: ADDITION,
negative_style: DELETION,
always_show_zeros: false,
}),
ColumnKind::AheadBehind => Some(DiffDisplayConfig {
variant: DiffVariant::Arrows,
positive_style: ADDITION,
negative_style: DELETION.dimmed(),
always_show_zeros: false,
}),
ColumnKind::Upstream => Some(DiffDisplayConfig {
variant: DiffVariant::UpstreamArrows,
positive_style: ADDITION,
negative_style: DELETION.dimmed(),
always_show_zeros: false, }),
_ => None,
}
}
pub(crate) fn format_diff_plain(self, positive: usize, negative: usize) -> Option<String> {
let config = self.diff_display_config()?;
config.format_plain(positive, negative)
}
pub fn has_data(self, flags: &ColumnDataFlags) -> bool {
match self {
ColumnKind::Gutter => true, ColumnKind::Branch => true,
ColumnKind::Status => flags.status,
ColumnKind::WorkingDiff => flags.working_diff,
ColumnKind::AheadBehind => flags.ahead_behind,
ColumnKind::BranchDiff => flags.branch_diff,
ColumnKind::Path => flags.path,
ColumnKind::Upstream => flags.upstream,
ColumnKind::Url => flags.url,
ColumnKind::Time => true,
ColumnKind::CiStatus => flags.ci_status,
ColumnKind::Commit => true,
ColumnKind::Summary => true, ColumnKind::Message => true,
}
}
fn ideal(
self,
widths: &ColumnWidths,
max_path_width: usize,
commit_width: usize,
) -> Option<(usize, ColumnFormat)> {
let text = |w: usize| (w > 0).then_some((w, ColumnFormat::Text));
let diff = |dw: DiffWidths| -> Option<(usize, ColumnFormat)> {
if dw.total == 0 {
return None;
}
let display = self.diff_display_config()?;
Some((
dw.total,
ColumnFormat::Diff(DiffColumnConfig {
positive_digits: dw.positive_digits,
negative_digits: dw.negative_digits,
total_width: dw.total,
display,
}),
))
};
match self {
ColumnKind::Gutter => text(2), ColumnKind::Branch => text(widths.branch),
ColumnKind::Status => text(widths.status),
ColumnKind::Path => text(max_path_width),
ColumnKind::Time => text(widths.time),
ColumnKind::Url => text(widths.url),
ColumnKind::CiStatus => text(widths.ci_status),
ColumnKind::Commit => text(commit_width),
ColumnKind::Summary => None, ColumnKind::Message => None,
ColumnKind::WorkingDiff => diff(widths.working_diff),
ColumnKind::AheadBehind => diff(widths.ahead_behind),
ColumnKind::BranchDiff => diff(widths.branch_diff),
ColumnKind::Upstream => diff(widths.upstream),
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum ColumnFormat {
Text,
Diff(DiffColumnConfig),
}
#[derive(Clone, Copy, Debug)]
pub struct DiffColumnConfig {
pub positive_digits: usize,
pub negative_digits: usize,
pub total_width: usize,
pub display: DiffDisplayConfig,
}
#[derive(Clone, Debug)]
pub struct ColumnLayout {
pub kind: ColumnKind,
pub header: &'static str,
pub start: usize,
pub width: usize,
pub format: ColumnFormat,
}
pub struct LayoutConfig {
pub columns: Vec<ColumnLayout>,
pub main_worktree_path: PathBuf,
pub max_message_len: usize,
pub max_summary_len: usize,
pub hidden_column_count: usize,
pub status_position_mask: super::model::PositionMask,
}
#[derive(Clone, Copy)]
struct ColumnCandidate<'a> {
spec: &'a ColumnSpec,
priority: u8,
}
#[derive(Clone, Copy)]
struct PendingColumn<'a> {
spec: &'a ColumnSpec,
priority: u8,
width: usize,
format: ColumnFormat,
}
fn estimate_url_width(url_template: Option<&str>, hyperlinks_supported: bool) -> usize {
let Some(template) = url_template else {
return 0;
};
if hyperlinks_supported {
if template.contains("hash_port")
|| template.contains(":{{")
|| parse_port_from_url(template).is_some()
{
return 6; }
}
template
.replace("{{ branch | hash_port }}", "12345")
.replace("{{ branch }}", "feature-xx")
.len()
}
fn build_estimated_widths(
max_branch: usize,
skip_tasks: &HashSet<TaskKind>,
has_branch_worktree_mismatch: bool,
url_width: usize,
) -> LayoutMetadata {
let status_fixed = fit_header(ColumnKind::Status.header(), 8);
let working_diff_fixed = fit_header(ColumnKind::WorkingDiff.header(), 9); let ahead_behind_fixed = fit_header(ColumnKind::AheadBehind.header(), 7); let branch_diff_fixed = fit_header(ColumnKind::BranchDiff.header(), 9); let upstream_fixed = fit_header(ColumnKind::Upstream.header(), 7); let age_estimate = 4; let ci_estimate = fit_header(ColumnKind::CiStatus.header(), 1);
let data_flags = ColumnDataFlags {
status: true,
working_diff: true,
ahead_behind: true,
branch_diff: !skip_tasks.contains(&TaskKind::BranchDiff),
upstream: true,
url: !skip_tasks.contains(&TaskKind::UrlStatus),
ci_status: !skip_tasks.contains(&TaskKind::CiStatus),
path: has_branch_worktree_mismatch,
};
let url_estimate = if url_width > 0 {
fit_header(ColumnKind::Url.header(), url_width)
} else {
0
};
let widths = ColumnWidths {
branch: max_branch,
status: status_fixed,
time: age_estimate,
url: url_estimate,
ci_status: ci_estimate,
ahead_behind: DiffWidths {
total: ahead_behind_fixed,
positive_digits: 2,
negative_digits: 2,
},
working_diff: DiffWidths {
total: working_diff_fixed,
positive_digits: 3,
negative_digits: 3,
},
branch_diff: DiffWidths {
total: branch_diff_fixed,
positive_digits: 3,
negative_digits: 3,
},
upstream: DiffWidths {
total: upstream_fixed,
positive_digits: 2,
negative_digits: 2,
},
};
LayoutMetadata {
widths,
data_flags,
status_position_mask: super::model::PositionMask::FULL,
}
}
fn allocate_columns_with_priority(
metadata: &LayoutMetadata,
skip_tasks: &HashSet<TaskKind>,
max_path_width: usize,
commit_width: usize,
terminal_width: usize,
main_worktree_path: PathBuf,
) -> LayoutConfig {
let spacing = 2;
let mut remaining = terminal_width;
let mut candidates: Vec<ColumnCandidate> = COLUMN_SPECS
.iter()
.filter(|spec| {
spec.requires_task
.is_none_or(|task| !skip_tasks.contains(&task))
})
.map(|spec| ColumnCandidate {
spec,
priority: if spec.kind.has_data(&metadata.data_flags) {
spec.base_priority
} else {
spec.base_priority + EMPTY_PENALTY
},
})
.collect();
candidates.sort_by_key(|candidate| candidate.priority);
let candidate_kinds: Vec<_> = candidates.iter().map(|c| c.spec.kind).collect();
const MIN_SUMMARY: usize = 10;
const MAX_SUMMARY: usize = 70;
const MIN_MESSAGE: usize = 10;
const MAX_MESSAGE: usize = 100;
const SUMMARY_THRESHOLD_FOR_LOW_PRIORITY: usize = 50;
let mut pending: Vec<PendingColumn> = Vec::new();
let needs_spacing = |pending: &[PendingColumn]| -> bool {
if pending.is_empty() {
return false;
}
if pending.last().map(|c| c.spec.kind) == Some(ColumnKind::Gutter) {
return false;
}
true
};
for candidate in candidates {
let spec = candidate.spec;
if matches!(spec.kind, ColumnKind::Summary | ColumnKind::Message) {
let min_width = match spec.kind {
ColumnKind::Summary => MIN_SUMMARY,
_ => MIN_MESSAGE,
};
let spacing_cost = if needs_spacing(&pending) { spacing } else { 0 };
if remaining > spacing_cost {
let available = remaining - spacing_cost;
if available >= min_width {
remaining = remaining.saturating_sub(min_width + spacing_cost);
pending.push(PendingColumn {
spec,
priority: candidate.priority,
width: min_width,
format: ColumnFormat::Text,
});
}
}
continue;
}
let Some((ideal_width, format)) =
spec.kind
.ideal(&metadata.widths, max_path_width, commit_width)
else {
continue;
};
let is_first = !needs_spacing(&pending);
let min_width = if spec.shrinkable {
Some(spec.kind.header().width().max(1))
} else {
None
};
let allocated = try_allocate(&mut remaining, ideal_width, min_width, spacing, is_first);
if allocated > 0 {
pending.push(PendingColumn {
spec,
priority: candidate.priority,
width: allocated,
format,
});
}
}
let mut max_summary_len = 0;
if let Some(summary_col) = pending
.iter_mut()
.find(|col| col.spec.kind == ColumnKind::Summary)
{
if summary_col.width < MAX_SUMMARY && remaining > 0 {
let expansion = remaining.min(MAX_SUMMARY - summary_col.width);
summary_col.width += expansion;
remaining -= expansion;
}
max_summary_len = summary_col.width;
}
let summary_priority = ColumnKind::Summary.priority();
while max_summary_len > 0 && max_summary_len < MAX_SUMMARY {
let drop_pos = pending
.iter()
.enumerate()
.filter(|(_, col)| {
if col.spec.kind == ColumnKind::Summary || col.priority <= summary_priority {
return false;
}
let gap = col.priority - summary_priority;
let threshold = if gap <= 4 {
SUMMARY_THRESHOLD_FOR_LOW_PRIORITY
} else {
MAX_SUMMARY
};
max_summary_len < threshold
})
.max_by_key(|(_, col)| col.priority)
.map(|(i, _)| i);
let Some(pos) = drop_pos else { break };
let reclaimed = pending[pos].width + spacing;
pending.remove(pos);
remaining += reclaimed;
if let Some(summary_col) = pending
.iter_mut()
.find(|col| col.spec.kind == ColumnKind::Summary)
{
let expansion = remaining.min(MAX_SUMMARY - summary_col.width);
summary_col.width += expansion;
remaining -= expansion;
max_summary_len = summary_col.width;
}
}
let mut max_message_len = 0;
if let Some(message_col) = pending
.iter_mut()
.find(|col| col.spec.kind == ColumnKind::Message)
{
if message_col.width < MAX_MESSAGE && remaining > 0 {
let expansion = remaining.min(MAX_MESSAGE - message_col.width);
message_col.width += expansion;
}
max_message_len = message_col.width;
}
pending.sort_by_key(|col| column_display_index(col.spec.kind));
let gap = 2;
let mut position = 0;
let mut columns = Vec::new();
for col in pending {
let start = if columns.is_empty() {
0
} else {
let prev_was_gutter = columns
.last()
.map(|c: &ColumnLayout| c.kind == ColumnKind::Gutter)
.unwrap_or(false);
if prev_was_gutter {
position
} else {
position + gap
}
};
position = start + col.width;
columns.push(ColumnLayout {
kind: col.spec.kind,
header: col.spec.kind.header(),
start,
width: col.width,
format: col.format,
});
}
let allocated_kinds: std::collections::HashSet<_> =
columns.iter().map(|col| col.kind).collect();
let hidden_column_count = candidate_kinds
.iter()
.filter(|kind| !allocated_kinds.contains(kind))
.count();
LayoutConfig {
columns,
main_worktree_path,
max_message_len,
max_summary_len,
hidden_column_count,
status_position_mask: metadata.status_position_mask,
}
}
pub fn calculate_layout_from_basics(
items: &[super::model::ListItem],
skip_tasks: &HashSet<TaskKind>,
main_worktree_path: &Path,
url_template: Option<&str>,
) -> LayoutConfig {
calculate_layout_with_width(
items,
skip_tasks,
terminal_width(),
main_worktree_path,
url_template,
)
}
pub fn calculate_layout_with_width(
items: &[super::model::ListItem],
skip_tasks: &HashSet<TaskKind>,
terminal_width: usize,
main_worktree_path: &Path,
url_template: Option<&str>,
) -> LayoutConfig {
let longest_branch = items
.iter()
.filter_map(|item| item.branch.as_deref())
.max_by_key(|b| b.width());
let max_branch = longest_branch.map(|b| b.width()).unwrap_or(0);
let max_branch = fit_header(ColumnKind::Branch.header(), max_branch);
let path_data_width = items
.iter()
.filter_map(|item| item.worktree_path())
.map(|path| shorten_path(path.as_path(), main_worktree_path).width())
.max()
.unwrap_or(0);
let max_path_width = fit_header(ColumnKind::Path.header(), path_data_width);
let has_branch_worktree_mismatch = items
.iter()
.filter_map(|item| item.worktree_data())
.any(|data| data.branch_worktree_mismatch);
let url_width = estimate_url_width(url_template, supports_hyperlinks(Stream::Stdout));
let metadata = build_estimated_widths(
max_branch,
skip_tasks,
has_branch_worktree_mismatch,
url_width,
);
let commit_width = fit_header(ColumnKind::Commit.header(), COMMIT_HASH_WIDTH);
allocate_columns_with_priority(
&metadata,
skip_tasks,
max_path_width,
commit_width,
terminal_width,
main_worktree_path.to_path_buf(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use worktrunk::git::LineDiff;
#[test]
fn test_fit_header() {
assert_eq!(fit_header("Age", 10), 10);
assert_eq!(fit_header("Branch", 3), 6);
assert_eq!(fit_header("Status", 0), 6);
assert_eq!(fit_header("Path", 4), 4);
}
#[test]
fn test_try_allocate() {
let mut remaining = 100;
let allocated = try_allocate(&mut remaining, 20, None, 2, true);
assert_eq!(allocated, 20);
assert_eq!(remaining, 80);
let allocated = try_allocate(&mut remaining, 15, None, 2, false);
assert_eq!(allocated, 15);
assert_eq!(remaining, 63);
let mut remaining = 50;
assert_eq!(try_allocate(&mut remaining, 0, None, 2, false), 0);
assert_eq!(remaining, 50);
let mut remaining = 10;
assert_eq!(try_allocate(&mut remaining, 20, None, 2, false), 0);
assert_eq!(remaining, 10);
}
#[test]
fn test_try_allocate_with_min_width() {
let mut remaining = 30;
let allocated = try_allocate(&mut remaining, 20, Some(6), 2, false);
assert_eq!(allocated, 20);
assert_eq!(remaining, 8);
let mut remaining = 15;
let allocated = try_allocate(&mut remaining, 20, Some(6), 2, false);
assert_eq!(allocated, 13); assert_eq!(remaining, 0);
let mut remaining = 5;
let allocated = try_allocate(&mut remaining, 20, Some(6), 2, false);
assert_eq!(allocated, 0);
assert_eq!(remaining, 5);
let mut remaining = 10;
let allocated = try_allocate(&mut remaining, 20, Some(6), 2, true);
assert_eq!(allocated, 10); assert_eq!(remaining, 0);
}
#[test]
fn test_column_kind_has_data() {
let all_true = ColumnDataFlags {
status: true,
working_diff: true,
ahead_behind: true,
branch_diff: true,
upstream: true,
url: true,
ci_status: true,
path: true,
};
let all_false = ColumnDataFlags {
status: false,
working_diff: false,
ahead_behind: false,
branch_diff: false,
upstream: false,
url: false,
ci_status: false,
path: false,
};
assert!(ColumnKind::Gutter.has_data(&all_false));
assert!(ColumnKind::Branch.has_data(&all_false));
assert!(ColumnKind::Time.has_data(&all_false));
assert!(ColumnKind::Commit.has_data(&all_false));
assert!(ColumnKind::Message.has_data(&all_false));
assert!(ColumnKind::Status.has_data(&all_true));
assert!(!ColumnKind::Status.has_data(&all_false));
assert!(ColumnKind::WorkingDiff.has_data(&all_true));
assert!(!ColumnKind::WorkingDiff.has_data(&all_false));
assert!(ColumnKind::AheadBehind.has_data(&all_true));
assert!(!ColumnKind::AheadBehind.has_data(&all_false));
assert!(ColumnKind::BranchDiff.has_data(&all_true));
assert!(!ColumnKind::BranchDiff.has_data(&all_false));
assert!(ColumnKind::Upstream.has_data(&all_true));
assert!(!ColumnKind::Upstream.has_data(&all_false));
assert!(ColumnKind::Url.has_data(&all_true));
assert!(!ColumnKind::Url.has_data(&all_false));
assert!(ColumnKind::CiStatus.has_data(&all_true));
assert!(!ColumnKind::CiStatus.has_data(&all_false));
assert!(ColumnKind::Path.has_data(&all_true));
assert!(!ColumnKind::Path.has_data(&all_false));
}
#[test]
fn test_column_kind_diff_display_config() {
assert!(ColumnKind::WorkingDiff.diff_display_config().is_some());
assert!(ColumnKind::BranchDiff.diff_display_config().is_some());
assert!(ColumnKind::AheadBehind.diff_display_config().is_some());
assert!(ColumnKind::Upstream.diff_display_config().is_some());
assert!(ColumnKind::Branch.diff_display_config().is_none());
assert!(ColumnKind::Status.diff_display_config().is_none());
assert!(ColumnKind::Path.diff_display_config().is_none());
assert!(ColumnKind::Time.diff_display_config().is_none());
assert!(ColumnKind::Message.diff_display_config().is_none());
assert!(ColumnKind::Commit.diff_display_config().is_none());
assert!(ColumnKind::CiStatus.diff_display_config().is_none());
let working = ColumnKind::WorkingDiff.diff_display_config().unwrap();
assert!(matches!(working.variant, DiffVariant::Signs));
let ahead = ColumnKind::AheadBehind.diff_display_config().unwrap();
assert!(matches!(ahead.variant, DiffVariant::Arrows));
let upstream = ColumnKind::Upstream.diff_display_config().unwrap();
assert!(matches!(upstream.variant, DiffVariant::UpstreamArrows));
}
#[test]
fn test_column_kind_ideal() {
let widths = ColumnWidths {
branch: 15,
status: 8,
time: 4,
url: 0,
ci_status: 2,
ahead_behind: DiffWidths {
total: 7,
positive_digits: 2,
negative_digits: 2,
},
working_diff: DiffWidths {
total: 9,
positive_digits: 3,
negative_digits: 3,
},
branch_diff: DiffWidths {
total: 9,
positive_digits: 3,
negative_digits: 3,
},
upstream: DiffWidths {
total: 7,
positive_digits: 2,
negative_digits: 2,
},
};
let (w, fmt) = ColumnKind::Gutter.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 2);
assert!(matches!(fmt, ColumnFormat::Text));
let (w, fmt) = ColumnKind::Branch.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 15);
assert!(matches!(fmt, ColumnFormat::Text));
let (w, fmt) = ColumnKind::Status.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 8);
assert!(matches!(fmt, ColumnFormat::Text));
let (w, fmt) = ColumnKind::Path.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 20);
assert!(matches!(fmt, ColumnFormat::Text));
let (w, fmt) = ColumnKind::Time.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 4);
assert!(matches!(fmt, ColumnFormat::Text));
let (w, fmt) = ColumnKind::Commit.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 8);
assert!(matches!(fmt, ColumnFormat::Text));
assert!(ColumnKind::Summary.ideal(&widths, 20, 8).is_none());
assert!(ColumnKind::Message.ideal(&widths, 20, 8).is_none());
let (w, fmt) = ColumnKind::WorkingDiff.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 9);
assert!(matches!(fmt, ColumnFormat::Diff(_)));
let (w, fmt) = ColumnKind::AheadBehind.ideal(&widths, 20, 8).unwrap();
assert_eq!(w, 7);
assert!(matches!(fmt, ColumnFormat::Diff(_)));
let zero_widths = ColumnWidths {
branch: 0,
status: 0,
time: 0,
url: 0,
ci_status: 0,
ahead_behind: DiffWidths {
total: 0,
positive_digits: 0,
negative_digits: 0,
},
working_diff: DiffWidths {
total: 0,
positive_digits: 0,
negative_digits: 0,
},
branch_diff: DiffWidths {
total: 0,
positive_digits: 0,
negative_digits: 0,
},
upstream: DiffWidths {
total: 0,
positive_digits: 0,
negative_digits: 0,
},
};
assert!(ColumnKind::Branch.ideal(&zero_widths, 0, 0).is_none());
assert!(ColumnKind::WorkingDiff.ideal(&zero_widths, 0, 0).is_none());
}
#[test]
fn test_pre_allocated_width_estimates() {
let metadata = build_estimated_widths(20, &HashSet::new(), true, 0);
let widths = metadata.widths;
assert_eq!(
widths.working_diff.total, 9,
"Working diff should pre-allocate for '+999 -999' (9 chars)"
);
assert_eq!(
widths.working_diff.positive_digits, 3,
"Pre-allocated for 3-digit positive count"
);
assert_eq!(
widths.working_diff.negative_digits, 3,
"Pre-allocated for 3-digit negative count"
);
assert_eq!(
widths.branch_diff.total, 9,
"Branch diff should pre-allocate for '+999 -999' (9 chars)"
);
assert_eq!(
widths.branch_diff.positive_digits, 3,
"Pre-allocated for 3-digit positive count"
);
assert_eq!(
widths.branch_diff.negative_digits, 3,
"Pre-allocated for 3-digit negative count"
);
assert_eq!(
widths.ahead_behind.total, 7,
"Ahead/behind should pre-allocate for '↑99 ↓99' (7 chars)"
);
assert_eq!(
widths.ahead_behind.positive_digits, 2,
"Pre-allocated for 2-digit positive count (uses compact notation)"
);
assert_eq!(
widths.ahead_behind.negative_digits, 2,
"Pre-allocated for 2-digit negative count (uses compact notation)"
);
assert_eq!(
widths.upstream.total, 7,
"Upstream should pre-allocate for '↑99 ↓99' (7 chars)"
);
assert_eq!(
widths.upstream.positive_digits, 2,
"Pre-allocated for 2-digit positive count"
);
assert_eq!(
widths.upstream.negative_digits, 2,
"Pre-allocated for 2-digit negative count"
);
}
#[test]
fn test_visible_columns_follow_gap_rule() {
use crate::commands::list::model::{
ActiveGitOperation, AheadBehind, BranchDiffTotals, CommitDetails, DisplayFields,
ItemKind, ListItem, StatusSymbols, UpstreamStatus, WorktreeData,
};
let item = ListItem {
head: "abc12345".to_string(),
branch: Some("feature".to_string()),
commit: Some(CommitDetails {
timestamp: 1234567890,
commit_message: "Test commit message".to_string(),
}),
counts: Some(AheadBehind {
ahead: 5,
behind: 10,
}),
branch_diff: Some(BranchDiffTotals {
diff: LineDiff::from((200, 30)),
}),
committed_trees_match: Some(false),
has_file_changes: Some(true),
would_merge_add: None,
is_patch_id_match: None,
is_ancestor: None,
is_orphan: None,
upstream: Some(UpstreamStatus {
remote: Some("origin".to_string()),
ahead: 4,
behind: 2,
}),
pr_status: None,
url: None,
url_active: None,
summary: None,
has_merge_tree_conflicts: None,
user_marker: None,
status_symbols: StatusSymbols::default(),
display: DisplayFields::default(),
kind: ItemKind::Worktree(Box::new(WorktreeData {
path: PathBuf::from("/test/path"),
detached: false,
locked: None,
prunable: None,
working_tree_diff: Some(LineDiff::from((100, 50))),
working_tree_status: None,
has_conflicts: None,
has_working_tree_conflicts: None,
git_operation: Some(ActiveGitOperation::None),
is_main: false,
is_current: false,
is_previous: false,
branch_worktree_mismatch: false,
working_diff_display: None,
})),
};
let items = vec![item];
let skip_tasks: HashSet<TaskKind> = [TaskKind::BranchDiff, TaskKind::CiStatus]
.into_iter()
.collect();
let main_worktree_path = PathBuf::from("/test");
let layout = calculate_layout_from_basics(&items, &skip_tasks, &main_worktree_path, None);
assert!(
!layout.columns.is_empty(),
"At least one column should be visible"
);
let mut columns_iter = layout.columns.iter();
let first = columns_iter.next().expect("gutter column should exist");
assert_eq!(
first.kind,
ColumnKind::Gutter,
"Gutter column should be first"
);
assert_eq!(first.start, 0, "Gutter should begin at position 0");
let mut previous_end = first.start + first.width;
let mut prev_kind = first.kind;
for column in columns_iter {
let expected_gap = if prev_kind == ColumnKind::Gutter {
0
} else {
2
};
assert_eq!(
column.start,
previous_end + expected_gap,
"Columns should be separated by expected gap (0 after gutter, 2 otherwise)"
);
previous_end = column.start + column.width;
prev_kind = column.kind;
}
if let Some(path_column) = layout
.columns
.iter()
.find(|col| col.kind == ColumnKind::Path)
{
assert!(path_column.width > 0, "Path column must have width > 0");
}
}
#[test]
fn test_column_positions_with_empty_columns() {
use crate::commands::list::model::{
ActiveGitOperation, AheadBehind, BranchDiffTotals, CommitDetails, DisplayFields,
ItemKind, ListItem, StatusSymbols, UpstreamStatus, WorktreeData,
};
let item = ListItem {
head: "abc12345".to_string(),
branch: Some("main".to_string()),
commit: Some(CommitDetails {
timestamp: 1234567890,
commit_message: "Test".to_string(),
}),
counts: Some(AheadBehind {
ahead: 0,
behind: 0,
}),
branch_diff: Some(BranchDiffTotals {
diff: LineDiff::default(),
}),
committed_trees_match: Some(false),
has_file_changes: Some(true),
would_merge_add: None,
is_patch_id_match: None,
is_ancestor: None,
is_orphan: None,
upstream: Some(UpstreamStatus::default()),
pr_status: None,
url: None,
url_active: None,
summary: None,
has_merge_tree_conflicts: None,
user_marker: None,
status_symbols: StatusSymbols::default(),
display: DisplayFields::default(),
kind: ItemKind::Worktree(Box::new(WorktreeData {
path: PathBuf::from("/test"),
detached: false,
locked: None,
prunable: None,
working_tree_diff: Some(LineDiff::default()),
working_tree_status: None,
has_conflicts: None,
has_working_tree_conflicts: None,
git_operation: Some(ActiveGitOperation::None),
is_main: true, is_current: false,
is_previous: false,
branch_worktree_mismatch: false,
working_diff_display: None,
})),
};
let items = vec![item];
let skip_tasks: HashSet<TaskKind> = [TaskKind::BranchDiff, TaskKind::CiStatus]
.into_iter()
.collect();
let main_worktree_path = PathBuf::from("/home/user/project");
let layout = calculate_layout_from_basics(&items, &skip_tasks, &main_worktree_path, None);
assert!(
layout
.columns
.first()
.map(|col| col.kind == ColumnKind::Gutter && col.start == 0)
.unwrap_or(false),
"Gutter column should start at position 0"
);
}
#[test]
fn test_estimate_url_width_no_template() {
assert_eq!(estimate_url_width(None, false), 0);
assert_eq!(estimate_url_width(None, true), 0);
}
#[test]
fn test_estimate_url_width_with_hash_port() {
let template = "http://localhost:{{ branch | hash_port }}";
assert_eq!(estimate_url_width(Some(template), false), 22);
assert_eq!(estimate_url_width(Some(template), true), 6);
}
#[test]
fn test_estimate_url_width_with_branch_variable() {
let template = "http://localhost/{{ branch }}";
assert_eq!(estimate_url_width(Some(template), false), 27);
assert_eq!(estimate_url_width(Some(template), true), 27);
}
#[test]
fn test_estimate_url_width_static_template() {
let template = "http://localhost:3000";
assert_eq!(estimate_url_width(Some(template), false), 21);
assert_eq!(estimate_url_width(Some(template), true), 6);
}
#[test]
fn test_estimate_url_width_port_pattern() {
let template = "http://localhost:{{ port }}";
assert_eq!(estimate_url_width(Some(template), false), template.len());
assert_eq!(estimate_url_width(Some(template), true), 6);
}
fn make_test_item(branch: &str) -> super::super::model::ListItem {
use crate::commands::list::model::{
ActiveGitOperation, DisplayFields, ItemKind, StatusSymbols, WorktreeData,
};
super::super::model::ListItem {
head: "abc12345".to_string(),
branch: Some(branch.to_string()),
commit: None,
counts: None,
branch_diff: None,
committed_trees_match: None,
has_file_changes: None,
would_merge_add: None,
is_patch_id_match: None,
is_ancestor: None,
is_orphan: None,
upstream: None,
pr_status: None,
url: None,
url_active: None,
summary: None,
has_merge_tree_conflicts: None,
user_marker: None,
status_symbols: StatusSymbols::default(),
display: DisplayFields::default(),
kind: ItemKind::Worktree(Box::new(WorktreeData {
path: PathBuf::from("/test/wt"),
detached: false,
locked: None,
prunable: None,
working_tree_diff: None,
working_tree_status: None,
has_conflicts: None,
has_working_tree_conflicts: None,
git_operation: Some(ActiveGitOperation::None),
is_main: false,
is_current: false,
is_previous: false,
branch_worktree_mismatch: false,
working_diff_display: None,
})),
}
}
fn layout_at_width(width: usize, skip_tasks: &HashSet<TaskKind>) -> LayoutConfig {
let items = vec![make_test_item("feature-branch")];
calculate_layout_with_width(&items, skip_tasks, width, Path::new("/test"), None)
}
fn non_full_skip_tasks() -> HashSet<TaskKind> {
[
TaskKind::BranchDiff,
TaskKind::CiStatus,
TaskKind::SummaryGenerate,
]
.into_iter()
.collect()
}
fn full_skip_tasks() -> HashSet<TaskKind> {
HashSet::new()
}
fn find_column(layout: &LayoutConfig, kind: ColumnKind) -> Option<&ColumnLayout> {
layout.columns.iter().find(|c| c.kind == kind)
}
#[test]
fn test_summary_absent_when_skipped() {
let layout = layout_at_width(200, &non_full_skip_tasks());
assert!(
find_column(&layout, ColumnKind::Summary).is_none(),
"Summary should not appear when SummaryGenerate is skipped"
);
assert_eq!(layout.max_summary_len, 0);
assert!(find_column(&layout, ColumnKind::Message).is_some());
assert!(layout.max_message_len > 0);
}
#[test]
fn test_summary_present_in_full_mode() {
let layout = layout_at_width(200, &full_skip_tasks());
assert!(
find_column(&layout, ColumnKind::Summary).is_some(),
"Summary should appear in full mode"
);
assert!(layout.max_summary_len > 0);
}
#[test]
fn test_summary_expands_before_message() {
let layout = layout_at_width(200, &full_skip_tasks());
let summary = find_column(&layout, ColumnKind::Summary);
let message = find_column(&layout, ColumnKind::Message);
assert!(summary.is_some(), "Summary should be allocated");
assert!(message.is_some(), "Message should be allocated");
let summary_width = summary.unwrap().width;
let message_width = message.unwrap().width;
assert!(
summary_width > 10,
"Summary should expand beyond minimum: got {summary_width}"
);
assert_eq!(layout.max_summary_len, summary_width);
assert_eq!(layout.max_message_len, message_width);
}
#[test]
fn test_summary_capped_at_max() {
let layout = layout_at_width(500, &full_skip_tasks());
let summary = find_column(&layout, ColumnKind::Summary).unwrap();
assert_eq!(summary.width, 70, "Summary should cap at MAX_SUMMARY (70)");
assert_eq!(layout.max_summary_len, 70);
}
#[test]
fn test_message_capped_at_max() {
let layout = layout_at_width(500, &full_skip_tasks());
let message = find_column(&layout, ColumnKind::Message).unwrap();
assert_eq!(
message.width, 100,
"Message should cap at MAX_MESSAGE (100)"
);
assert_eq!(layout.max_message_len, 100);
}
#[test]
fn test_message_gets_more_space_when_summary_skipped() {
let with_summary = layout_at_width(200, &full_skip_tasks());
let without_summary = layout_at_width(200, &non_full_skip_tasks());
let msg_with = find_column(&with_summary, ColumnKind::Message)
.unwrap()
.width;
let msg_without = find_column(&without_summary, ColumnKind::Message)
.unwrap()
.width;
assert!(
msg_without >= msg_with,
"Message should get at least as much space without Summary: \
with={msg_with}, without={msg_without}"
);
}
#[test]
fn test_summary_display_order() {
let layout = layout_at_width(500, &full_skip_tasks());
let kinds: Vec<ColumnKind> = layout.columns.iter().map(|c| c.kind).collect();
if let Some(summary_pos) = kinds.iter().position(|k| *k == ColumnKind::Summary) {
if let Some(branch_diff_pos) = kinds.iter().position(|k| *k == ColumnKind::BranchDiff) {
assert!(
summary_pos > branch_diff_pos,
"Summary should appear after BranchDiff"
);
}
if let Some(upstream_pos) = kinds.iter().position(|k| *k == ColumnKind::Upstream) {
assert!(
summary_pos < upstream_pos,
"Summary should appear before Upstream"
);
}
} else {
panic!("Summary column should be present at width 500");
}
}
#[test]
fn test_low_priority_columns_gated_on_summary_threshold() {
let mut found_below = false;
for width in 80..200 {
let l = layout_at_width(width, &full_skip_tasks());
if let Some(s) = find_column(&l, ColumnKind::Summary)
&& s.width < 50
{
found_below = true;
assert!(
find_column(&l, ColumnKind::Commit).is_none(),
"Commit present at width {width} with Summary {}",
s.width
);
assert!(
find_column(&l, ColumnKind::Time).is_none(),
"Time present at width {width} with Summary {}",
s.width
);
assert!(
find_column(&l, ColumnKind::Message).is_none(),
"Message present at width {width} with Summary {}",
s.width
);
}
}
assert!(found_below, "no width produced Summary < 50");
let l = layout_at_width(200, &full_skip_tasks());
assert!(find_column(&l, ColumnKind::Summary).unwrap().width >= 50);
assert!(find_column(&l, ColumnKind::Commit).is_some());
assert!(find_column(&l, ColumnKind::Time).is_some());
assert!(find_column(&l, ColumnKind::Message).is_some());
}
#[test]
fn test_narrow_terminal_drops_flexible_columns() {
let layout = layout_at_width(40, &full_skip_tasks());
assert!(
find_column(&layout, ColumnKind::Summary).is_none(),
"Summary should not fit at 40 chars"
);
assert!(
find_column(&layout, ColumnKind::Message).is_none(),
"Message should not fit at 40 chars"
);
}
fn make_test_item_at(branch: &str, path: &str) -> super::super::model::ListItem {
use crate::commands::list::model::{
ActiveGitOperation, DisplayFields, ItemKind, StatusSymbols, WorktreeData,
};
super::super::model::ListItem {
head: "abc12345".to_string(),
branch: Some(branch.to_string()),
commit: None,
counts: None,
branch_diff: None,
committed_trees_match: None,
has_file_changes: None,
would_merge_add: None,
is_patch_id_match: None,
is_ancestor: None,
is_orphan: None,
upstream: None,
pr_status: None,
url: None,
url_active: None,
summary: None,
has_merge_tree_conflicts: None,
user_marker: None,
status_symbols: StatusSymbols::default(),
display: DisplayFields::default(),
kind: ItemKind::Worktree(Box::new(WorktreeData {
path: PathBuf::from(path),
detached: false,
locked: None,
prunable: None,
working_tree_diff: None,
working_tree_status: None,
has_conflicts: None,
has_working_tree_conflicts: None,
git_operation: Some(ActiveGitOperation::None),
is_main: false,
is_current: false,
is_previous: false,
branch_worktree_mismatch: false,
working_diff_display: None,
})),
}
}
#[test]
fn test_path_yields_to_summary_when_no_mismatch() {
let items = vec![
{
let mut item = make_test_item_at("main", "/test/worktrunk");
if let super::super::model::ItemKind::Worktree(ref mut data) = item.kind {
data.is_main = true;
}
item
},
make_test_item_at("hourly-maintenance", "/test/agents.hourly-maintenance"),
make_test_item_at("lab-continue", "/test/agents.lab-continue"),
make_test_item_at("dry-run-pager", "/test/agents.dry-run-pager"),
];
let main_path = Path::new("/test/worktrunk");
let skip = full_skip_tasks();
let layout_wide = calculate_layout_with_width(&items, &skip, 300, main_path, None);
assert!(
find_column(&layout_wide, ColumnKind::Summary).is_some(),
"Summary should be present at 300"
);
assert!(
find_column(&layout_wide, ColumnKind::Path).is_some(),
"Path should be present at 300 (infinite space → show everything)"
);
let layout_170 = calculate_layout_with_width(&items, &skip, 170, main_path, None);
let summary_170 = find_column(&layout_170, ColumnKind::Summary)
.expect("Summary should be present at 170")
.width;
assert!(
summary_170 >= 50,
"Summary should reach at least 50 at width 170 when paths are consistent: got {summary_170}"
);
}
#[test]
fn test_snapshot_path_yields_to_summary() {
use crate::commands::list::model::{
ActiveGitOperation, AheadBehind, BranchDiffTotals, CommitDetails, DisplayFields,
ItemKind, StatusSymbols, UpstreamStatus, WorktreeData,
};
use worktrunk::git::LineDiff;
let ts = 1742500000;
let make_item = |branch: &str,
path: &str,
is_main: bool,
is_current: bool,
ahead: usize,
behind: usize,
diff: Option<(usize, usize)>,
summary: Option<&str>,
upstream: bool|
-> super::super::model::ListItem {
let counts = if is_main {
None
} else {
Some(AheadBehind { ahead, behind })
};
let branch_diff = diff.map(|(a, d)| BranchDiffTotals {
diff: LineDiff::from((a, d)),
});
let upstream_status = upstream.then(|| UpstreamStatus {
remote: Some("origin".to_string()),
ahead: 0,
behind: 0,
});
super::super::model::ListItem {
head: "a620bcfe".to_string(),
branch: Some(branch.to_string()),
commit: Some(CommitDetails {
timestamp: ts,
commit_message: "Some commit message".to_string(),
}),
counts,
branch_diff,
committed_trees_match: None,
has_file_changes: None,
would_merge_add: None,
is_patch_id_match: None,
is_ancestor: None,
is_orphan: None,
upstream: upstream_status,
pr_status: Some(None), url: None,
url_active: None,
summary: Some(summary.map(|s| s.to_string())),
has_merge_tree_conflicts: None,
user_marker: None,
status_symbols: StatusSymbols::default(),
display: DisplayFields::default(),
kind: ItemKind::Worktree(Box::new(WorktreeData {
path: PathBuf::from(path),
detached: false,
locked: None,
prunable: None,
working_tree_diff: Some(LineDiff::default()),
working_tree_status: None,
has_conflicts: None,
has_working_tree_conflicts: None,
git_operation: Some(ActiveGitOperation::None),
is_main,
is_current,
is_previous: false,
branch_worktree_mismatch: false,
working_diff_display: None,
})),
}
};
let items = vec![
make_item(
"main",
"/test/worktrunk",
true,
true,
0,
0,
None,
None,
true,
),
make_item(
"hourly-maintenance",
"/test/agents.hourly-maintenance",
false,
false,
2,
0,
None,
None,
false,
),
make_item(
"lab-continue",
"/test/agents.lab-continue",
false,
false,
1,
2,
Some((28, 1)),
Some("Add extend and block insert in Markdown parser"),
true,
),
make_item(
"dry-run-pager",
"/test/agents.dry-run-pager",
false,
false,
3,
1,
None,
None,
true,
),
];
let main_path = Path::new("/test/worktrunk");
let skip = full_skip_tasks();
let layout = calculate_layout_with_width(&items, &skip, 170, main_path, None);
let mut lines = Vec::new();
lines.push(layout.render_header_line().plain_text());
for item in &items {
lines.push(layout.render_list_item_line(item).plain_text());
}
let table = lines.join("\n");
insta::assert_snapshot!(table);
}
#[test]
fn test_branch_column_never_dropped() {
let items = vec![make_test_item(
"feature/very-long-branch-name-that-exceeds-available-space",
)];
let skip = non_full_skip_tasks();
let main_path = Path::new("/test");
let layout = calculate_layout_with_width(&items, &skip, 30, main_path, None);
let branch = find_column(&layout, ColumnKind::Branch);
assert!(
branch.is_some(),
"Branch column should never be dropped, even at 30 cols"
);
let branch_width = branch.unwrap().width;
assert!(
branch_width >= 6,
"Branch should be at least header width (6): got {branch_width}"
);
let layout = calculate_layout_with_width(&items, &skip, 80, main_path, None);
let branch = find_column(&layout, ColumnKind::Branch).unwrap();
assert!(
branch.width > 6,
"Branch should have more than header width at 80 cols"
);
}
#[test]
fn test_summary_skipped_preserves_other_full_columns() {
let mut skip_only_summary: HashSet<TaskKind> = HashSet::new();
skip_only_summary.insert(TaskKind::SummaryGenerate);
let layout = layout_at_width(300, &skip_only_summary);
assert!(
find_column(&layout, ColumnKind::Summary).is_none(),
"Summary should be skipped"
);
assert!(
find_column(&layout, ColumnKind::BranchDiff).is_some(),
"BranchDiff should still appear"
);
assert!(
find_column(&layout, ColumnKind::CiStatus).is_some(),
"CiStatus should still appear"
);
assert!(
find_column(&layout, ColumnKind::Message).is_some(),
"Message should still appear"
);
}
}