Skip to main content

wt/output/
table.rs

1//! Compact table layout for `wt list` (spec §7). Columns are padded to their
2//! natural width; the Commit column flexes and truncates to fit the terminal.
3
4use std::fmt::Write as _;
5
6use crate::model::{Column, Worktree};
7use crate::output::color::{ansi, paint};
8use crate::output::render::{RenderCtx, cell};
9
10/// The minimum width allotted to the (flexible) Commit column.
11const MIN_COMMIT_WIDTH: usize = 12;
12/// The column separator.
13const SEPARATOR: &str = "  ";
14
15/// Approximate display width (one column per character).
16fn display_width(text: &str) -> usize {
17    text.chars().count()
18}
19
20/// Truncates `text` to `width` display columns, appending `…` if shortened.
21fn 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
34/// Pads `text` on the right with spaces to `width` columns.
35fn 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
44/// Colorizes a cell's content based on its column and value (ANSI is zero-width,
45/// so this is applied after layout). Returns `None` for uncolored columns.
46fn 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
71/// Renders the worktrees as an aligned table for the given columns and terminal
72/// width. ANSI color is applied to status/dirty/PR cells when `color` is set.
73pub 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    // Flex the Commit column to fit the terminal.
98    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            // Pad the plain text first (ANSI is zero-width); color afterward.
117            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        // Current marker on the first row, branch names present.
179        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        // No line exceeds a reasonable width given truncation.
196        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)]; // status '*'
216        let plain = render_table(&current, &Column::ALL, &ctx(), 120, false);
217        let colored = render_table(&current, &Column::ALL, &ctx(), 120, true);
218        assert!(colored.contains("\x1b["));
219        assert!(!plain.contains("\x1b["));
220        // Stripping ANSI from the colored output recovers the plain output.
221        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}