1use std::fmt::Write as _;
5
6use crate::model::{Column, Worktree};
7use crate::output::color::{ansi, paint};
8use crate::output::render::{RenderCtx, cell};
9
10const MIN_COMMIT_WIDTH: usize = 12;
12const SEPARATOR: &str = " ";
14
15fn display_width(text: &str) -> usize {
17 text.chars().count()
18}
19
20fn truncate(text: &str, width: usize) -> String {
22 if display_width(text) <= width {
23 return text.to_string();
24 }
25 if width == 0 {
26 return String::new();
27 }
28 let keep = width.saturating_sub(1);
29 let mut out: String = text.chars().take(keep).collect();
30 out.push('…');
31 out
32}
33
34fn pad_right(text: &str, width: usize) -> String {
36 let len = display_width(text);
37 if len >= width {
38 text.to_string()
39 } else {
40 format!("{text}{}", " ".repeat(width - len))
41 }
42}
43
44fn cell_color(column: Column, value: &str) -> Option<&'static str> {
47 let trimmed = value.trim();
48 match column {
49 Column::Status => match trimmed {
50 "*" => Some(ansi::GREEN),
51 "!" => Some(ansi::RED),
52 "~" => Some(ansi::YELLOW),
53 _ => None,
54 },
55 Column::Dirty => match trimmed {
56 "M" => Some(ansi::RED),
57 "?" => Some(ansi::YELLOW),
58 _ => None,
59 },
60 Column::Pr => match () {
61 _ if trimmed.contains("(open)") => Some(ansi::GREEN),
62 _ if trimmed.contains("(merged)") => Some(ansi::MAGENTA),
63 _ if trimmed.contains("(closed)") => Some(ansi::RED),
64 _ if trimmed.contains("(draft)") => Some(ansi::DIM),
65 _ => None,
66 },
67 _ => None,
68 }
69}
70
71pub fn render_table(
74 worktrees: &[Worktree],
75 columns: &[Column],
76 ctx: &RenderCtx,
77 width: usize,
78 color: bool,
79) -> String {
80 if worktrees.is_empty() || columns.is_empty() {
81 return String::new();
82 }
83 let rows: Vec<Vec<String>> = worktrees
84 .iter()
85 .map(|w| columns.iter().map(|&c| cell(w, c, ctx)).collect())
86 .collect();
87
88 let mut widths: Vec<usize> = (0..columns.len())
89 .map(|ci| {
90 rows.iter()
91 .map(|r| display_width(&r[ci]))
92 .max()
93 .unwrap_or(0)
94 })
95 .collect();
96
97 if let Some(ci) = columns.iter().position(|c| *c == Column::Commit) {
99 let others: usize = widths
100 .iter()
101 .enumerate()
102 .filter(|(i, _)| *i != ci)
103 .map(|(_, w)| *w)
104 .sum();
105 let seps = SEPARATOR.len() * columns.len().saturating_sub(1);
106 let budget = width.saturating_sub(others + seps).max(MIN_COMMIT_WIDTH);
107 widths[ci] = widths[ci].min(budget);
108 }
109
110 let mut out = String::new();
111 let last = columns.len() - 1;
112 for row in &rows {
113 let mut line = String::new();
114 for (ci, value) in row.iter().enumerate() {
115 let truncated = truncate(value, widths[ci]);
116 let content = if ci == last {
118 truncated
119 } else {
120 pad_right(&truncated, widths[ci])
121 };
122 let painted = match cell_color(columns[ci], &content) {
123 Some(code) => paint(&content, code, color),
124 None => content,
125 };
126 line.push_str(&painted);
127 if ci != last {
128 line.push_str(SEPARATOR);
129 }
130 }
131 let _ = writeln!(out, "{}", line.trim_end());
132 }
133 out
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::model::{Commit, Worktree};
140 use std::path::{Path, PathBuf};
141
142 fn ctx() -> RenderCtx<'static> {
143 RenderCtx {
144 show_untracked: true,
145 now: 0,
146 repo_root: Path::new("/repo"),
147 }
148 }
149
150 fn wt(path: &str, branch: &str, current: bool) -> Worktree {
151 let mut w = Worktree::new(PathBuf::from(path));
152 w.branch = Some(branch.into());
153 w.slug = Some(branch.replace('/', "-"));
154 w.is_current = current;
155 w.dirty = Some(false);
156 w.has_untracked = Some(false);
157 w
158 }
159
160 #[test]
161 fn truncate_and_pad_helpers() {
162 assert_eq!(truncate("hello", 10), "hello");
163 assert_eq!(truncate("hello world", 5), "hell…");
164 assert_eq!(truncate("x", 0), "");
165 assert_eq!(pad_right("ab", 5), "ab ");
166 assert_eq!(pad_right("abcdef", 3), "abcdef");
167 }
168
169 #[test]
170 fn renders_aligned_rows() {
171 let worktrees = vec![
172 wt("/repo", "main", true),
173 wt("/repo/.worktrees/feature-x", "feature/x", false),
174 ];
175 let table = render_table(&worktrees, &Column::ALL, &ctx(), 120, false);
176 let lines: Vec<&str> = table.lines().collect();
177 assert_eq!(lines.len(), 2);
178 assert!(lines[0].starts_with('*'));
180 assert!(lines[0].contains("main"));
181 assert!(lines[1].contains("feature/x"));
182 }
183
184 #[test]
185 fn commit_column_truncates_in_narrow_terminal() {
186 let mut w = wt("/repo", "main", true);
187 w.commit = Some(Commit {
188 hash: "abc1234".into(),
189 subject: "A very long commit subject that should be truncated to fit".into(),
190 author: "A".into(),
191 timestamp: "2024-01-15T10:30:00Z".into(),
192 });
193 let table = render_table(&[w], &[Column::Branch, Column::Commit], &ctx(), 40, false);
194 assert!(table.contains('…'));
195 for line in table.lines() {
197 assert!(display_width(line) <= 40, "line too wide: {line:?}");
198 }
199 }
200
201 #[test]
202 fn empty_input_renders_nothing() {
203 assert_eq!(render_table(&[], &Column::ALL, &ctx(), 80, false), "");
204 }
205
206 #[test]
207 fn respects_column_subset() {
208 let worktrees = vec![wt("/repo", "main", false)];
209 let table = render_table(&worktrees, &[Column::Branch], &ctx(), 80, false);
210 assert_eq!(table.trim_end(), "main");
211 }
212
213 #[test]
214 fn color_wraps_markers_without_changing_visible_width() {
215 let current = [wt("/repo", "main", true)]; let plain = render_table(¤t, &Column::ALL, &ctx(), 120, false);
217 let colored = render_table(¤t, &Column::ALL, &ctx(), 120, true);
218 assert!(colored.contains("\x1b["));
219 assert!(!plain.contains("\x1b["));
220 let strip = |s: &str| {
222 let mut out = String::new();
223 let mut chars = s.chars();
224 while let Some(c) = chars.next() {
225 if c == '\x1b' {
226 for n in chars.by_ref() {
227 if n == 'm' {
228 break;
229 }
230 }
231 } else {
232 out.push(c);
233 }
234 }
235 out
236 };
237 assert_eq!(strip(&colored), plain);
238 }
239}