Skip to main content

dev_prune/tui/
status_view.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Terminal UI for the `dev-prune status` command.
5//
6// Renders a full interactive, scrollable table of all registered repositories
7// showing: status, reason skipped, last activity, last pruned, bloat size,
8// and adapters. Users can also select candidates and trigger a prune pass
9// directly from this view.
10
11use std::io;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
17use ratatui::prelude::*;
18use ratatui::widgets::*;
19
20use crate::constants;
21use crate::engine::{RepoStatusEntry, SkipReason};
22use crate::output::format_bytes;
23use crate::tui::Tui;
24
25/// Mode the status view is in.
26enum ViewMode {
27    /// Browsing the table — 'p' enters PruneSelect mode.
28    Browse,
29    /// User is selecting candidates to prune.
30    PruneSelect,
31}
32
33struct StatusApp<'a> {
34    repos: &'a [RepoStatusEntry],
35    table_state: TableState,
36    /// Which rows are checked for prune (indexed to `repos`).
37    selected: Vec<bool>,
38    mode: ViewMode,
39    /// If `Some`, the user confirmed and we return these indices.
40    confirmed_indices: Option<Vec<usize>>,
41    /// Set to true when the user toggles ignore config in .devprune.json or presence of ignore.devprune.json so caller can reload.
42    pub should_reload: bool,
43}
44
45impl<'a> StatusApp<'a> {
46    fn new(repos: &'a [RepoStatusEntry]) -> Self {
47        let selected = vec![false; repos.len()];
48        let mut table_state = TableState::default();
49        table_state.select(Some(0));
50        Self {
51            repos,
52            table_state,
53            selected,
54            mode: ViewMode::Browse,
55            confirmed_indices: None,
56            should_reload: false,
57        }
58    }
59
60    fn move_up(&mut self) {
61        let i = match self.table_state.selected() {
62            Some(i) => {
63                if i == 0 {
64                    self.repos.len().saturating_sub(1)
65                } else {
66                    i - 1
67                }
68            }
69            None => 0,
70        };
71        self.table_state.select(Some(i));
72    }
73
74    fn move_down(&mut self) {
75        let i = match self.table_state.selected() {
76            Some(i) => {
77                if i >= self.repos.len().saturating_sub(1) {
78                    0
79                } else {
80                    i + 1
81                }
82            }
83            None => 0,
84        };
85        self.table_state.select(Some(i));
86    }
87
88    fn toggle_current(&mut self) {
89        if let Some(i) = self.table_state.selected() {
90            // Only allow toggling candidate repos for pruning
91            if matches!(self.repos[i].reason, SkipReason::Candidate) {
92                self.selected[i] = !self.selected[i];
93            }
94        }
95    }
96
97    fn toggle_all_candidates(&mut self) {
98        let any_candidate_selected = self
99            .repos
100            .iter()
101            .enumerate()
102            .any(|(i, r)| matches!(r.reason, SkipReason::Candidate) && self.selected[i]);
103
104        for (i, repo) in self.repos.iter().enumerate() {
105            if matches!(repo.reason, SkipReason::Candidate) {
106                self.selected[i] = !any_candidate_selected;
107            }
108        }
109    }
110
111    fn confirm_prune(&mut self) {
112        let indices: Vec<usize> = self
113            .selected
114            .iter()
115            .enumerate()
116            .filter(|&(_, s)| *s)
117            .map(|(i, _)| i)
118            .collect();
119        self.confirmed_indices = Some(indices);
120    }
121
122    fn selected_bytes(&self) -> u64 {
123        self.repos
124            .iter()
125            .enumerate()
126            .filter(|(i, _)| self.selected[*i])
127            .map(|(_, r)| r.reclaimable_bytes)
128            .sum()
129    }
130
131    fn selected_count(&self) -> usize {
132        self.selected.iter().filter(|&&s| s).count()
133    }
134
135    fn candidate_count(&self) -> usize {
136        self.repos
137            .iter()
138            .filter(|r| matches!(r.reason, SkipReason::Candidate))
139            .count()
140    }
141}
142
143/// Render the full interactive status view.
144///
145/// Returns `Some` with the selected repositories' paths if the user confirmed a
146/// prune, or `None` if they just quit. Paths, not indices: every `i` toggle
147/// reloads the list, and an ignored repository entering or leaving it renumbers
148/// everything — indices handed to the caller would address the list it loaded
149/// before any of that happened.
150///
151/// Re-runs automatically when the user toggles ignore config in `devprune.json` so the
152/// status reflects the change immediately.
153pub fn render_status_tui(
154    repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
155) -> Result<Option<Vec<PathBuf>>> {
156    loop {
157        let repos = repos_loader();
158
159        if repos.is_empty() {
160            return Ok(None);
161        }
162
163        let mut app = StatusApp::new(&repos);
164        {
165            // Scoped so the terminal is restored before anything below prints, and on
166            // every exit path from the loop — return, error, or panic.
167            let mut tui = Tui::new()?;
168            tui.drain_stale_input(Duration::from_millis(100));
169            run_status_loop(&mut tui.terminal, &mut app)?;
170        }
171
172        if app.should_reload {
173            // User toggled ignore — reload and re-render
174            continue;
175        }
176
177        // Resolved against `repos` — the list this iteration actually displayed —
178        // while it is still in scope, so a reload can never desynchronise them.
179        return Ok(app
180            .confirmed_indices
181            .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
182    }
183}
184
185fn run_status_loop(
186    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
187    app: &mut StatusApp,
188) -> Result<()> {
189    loop {
190        terminal.draw(|frame| render_ui(frame, app))?;
191
192        if event::poll(Duration::from_millis(100))?
193            && let Event::Key(key) = event::read()?
194        {
195            if key.kind == KeyEventKind::Release {
196                continue;
197            }
198            // Raw mode delivers Ctrl-C as a key event rather than a signal, so
199            // without this the one key everybody reaches for to escape does nothing.
200            if key.modifiers.contains(KeyModifiers::CONTROL)
201                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
202            {
203                return Ok(());
204            }
205            match key.code {
206                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
207                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
208                KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
209                KeyCode::End | KeyCode::Char('G') => {
210                    app.table_state
211                        .select(Some(app.repos.len().saturating_sub(1)));
212                }
213                KeyCode::PageUp => {
214                    let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
215                    app.table_state.select(Some(i));
216                }
217                KeyCode::PageDown => {
218                    let i = (app.table_state.selected().unwrap_or(0) + 10)
219                        .min(app.repos.len().saturating_sub(1));
220                    app.table_state.select(Some(i));
221                }
222                KeyCode::Char(' ') => {
223                    if matches!(app.mode, ViewMode::PruneSelect) {
224                        app.toggle_current();
225                    }
226                }
227                KeyCode::Char('a') | KeyCode::Char('A') => {
228                    if matches!(app.mode, ViewMode::PruneSelect) {
229                        app.toggle_all_candidates();
230                    }
231                }
232                KeyCode::Char('p') | KeyCode::Char('P') => {
233                    app.mode = ViewMode::PruneSelect;
234                    // Auto-select all candidates
235                    for (i, repo) in app.repos.iter().enumerate() {
236                        if matches!(repo.reason, SkipReason::Candidate) {
237                            app.selected[i] = true;
238                        }
239                    }
240                }
241                KeyCode::Char('i') | KeyCode::Char('I') => {
242                    // Toggle ignore in .devprune.json on the current repo
243                    if let Some(idx) = app.table_state.selected() {
244                        let repo = &app.repos[idx];
245                        // A missing repository has no directory to write the config
246                        // into; propagating that write error would tear the whole TUI
247                        // down over a row that says "Path Missing" right on it.
248                        if matches!(repo.reason, SkipReason::PathMissing) {
249                            continue;
250                        }
251                        // Refuses a config that does not parse. Starting from the
252                        // defaults would have written a fresh file over the broken
253                        // one, discarding every other override it held — and the
254                        // dashboard already shows such a repo as `config_error`.
255                        let mut per_repo =
256                            crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
257                                .map_err(|e| anyhow::anyhow!(e))
258                                .with_context(|| {
259                                    format!(
260                                        "Could not toggle ignore for {}",
261                                        crate::output::clean_path(&repo.path)
262                                    )
263                                })?
264                                .unwrap_or_default();
265                        per_repo.ignore = !per_repo.ignore;
266                        // Both writes are propagated rather than swallowed. A silent
267                        // failure here redraws the table unchanged, which reads as a
268                        // dead key; worse, a repository the user just un-ignored would
269                        // still be pruned on the next pass.
270                        per_repo.save_to_repo(&repo.path).with_context(|| {
271                            format!(
272                                "Could not write the config for {}",
273                                crate::output::clean_path(&repo.path)
274                            )
275                        })?;
276
277                        // The legacy marker file still counts as "ignored", so it has
278                        // to go for the toggle to mean anything.
279                        let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
280                        if legacy_ignore.exists() {
281                            std::fs::remove_file(&legacy_ignore).with_context(|| {
282                                format!(
283                                    "Could not remove {}",
284                                    crate::output::clean_path(&legacy_ignore)
285                                )
286                            })?;
287                        }
288
289                        // Signal caller to reload status
290                        app.should_reload = true;
291                        return Ok(());
292                    }
293                }
294                KeyCode::Enter => {
295                    if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
296                        app.confirm_prune();
297                        return Ok(());
298                    }
299                }
300                KeyCode::Esc => {
301                    if matches!(app.mode, ViewMode::PruneSelect) {
302                        // Exit prune-select mode, go back to browse
303                        app.mode = ViewMode::Browse;
304                        app.selected.fill(false);
305                    } else {
306                        return Ok(());
307                    }
308                }
309                KeyCode::Char('q') => return Ok(()),
310                _ => {}
311            }
312        }
313    }
314}
315
316/// Three tiers, not six colours: green is actionable, red and yellow are broken, and
317/// everything that is merely normal is the terminal's own colour or grey. A distinct hue
318/// per variant looks like a legend the reader has to learn, and it spends the loud
319/// colours on rows that need nothing.
320fn reason_color(reason: &SkipReason) -> Color {
321    match reason {
322        SkipReason::Candidate => Color::Green,
323        SkipReason::Active => Color::Reset,
324        SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
325        SkipReason::PathMissing => Color::Red,
326        // Actionable rather than broken: the repo is fine, its config file is not.
327        SkipReason::ConfigError(_) => Color::Yellow,
328    }
329}
330
331fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
332    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
333
334    let outer = Layout::default()
335        .direction(Direction::Vertical)
336        .constraints([
337            Constraint::Length(3), // header
338            Constraint::Min(5),    // table
339            // Four content lines plus the border: the keybindings, the mode-specific
340            // line, and the credit line that closes both footers.
341            Constraint::Length(6), // footer
342        ])
343        .split(frame.area());
344
345    // ── Header ───────────────────────────────────────────────────────────────
346    let mode_label = if is_prune_mode {
347        Span::styled(
348            " PRUNE-SELECT MODE ",
349            Style::default()
350                .bg(Color::Yellow)
351                .fg(Color::Black)
352                .add_modifier(Modifier::BOLD),
353        )
354    } else {
355        Span::styled(
356            " BROWSE MODE ",
357            Style::default()
358                .bg(Color::Cyan)
359                .fg(Color::Black)
360                .add_modifier(Modifier::BOLD),
361        )
362    };
363
364    let header_line = Line::from(vec![
365        Span::styled(
366            " dev-prune ",
367            Style::default()
368                .fg(Color::Black)
369                .bg(Color::Green)
370                .add_modifier(Modifier::BOLD),
371        ),
372        Span::raw(" "),
373        mode_label,
374        Span::styled(
375            format!(
376                "  {} repos  |  {} candidates  |  {} reclaimable",
377                app.repos.len(),
378                app.candidate_count(),
379                format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
380            ),
381            Style::default().fg(Color::DarkGray),
382        ),
383    ]);
384
385    let header_widget =
386        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
387            Style::default().fg(if is_prune_mode {
388                Color::Yellow
389            } else {
390                Color::Cyan
391            }),
392        ));
393    frame.render_widget(header_widget, outer[0]);
394
395    // ── Table ─────────────────────────────────────────────────────────────────
396    let col_headers = Row::new(vec![
397        Cell::from(if is_prune_mode { "Sel" } else { "#" })
398            .style(Style::default().add_modifier(Modifier::BOLD)),
399        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
400        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
401        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
402        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
403        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
404        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
405    ])
406    .height(1)
407    .bottom_margin(1)
408    // The bar's background is a fixed dark navy, so the foreground must be fixed
409    // too: on a light-theme terminal the default foreground is near-black and
410    // vanishes into it.
411    .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
412
413    // Rows sitting on one of the fixed dark backgrounds (selection green, highlight
414    // blue) need explicitly light text; everywhere else the terminal's own default
415    // foreground is the only colour guaranteed readable on both light and dark themes.
416    let highlighted_row = app.table_state.selected();
417
418    // Once per frame, not once per row — display names are relative to each other,
419    // so the row closure below needs the full list, but rebuilding it n times made
420    // every redraw O(n²) in path clones.
421    let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
422
423    let rows: Vec<Row> = app
424        .repos
425        .iter()
426        .enumerate()
427        .map(|(i, repo)| {
428            let is_selected = app.selected[i];
429            let color = reason_color(&repo.reason);
430
431            let sel_cell = if is_prune_mode {
432                if matches!(repo.reason, SkipReason::Candidate) {
433                    if is_selected {
434                        Cell::from("[x]").style(
435                            Style::default()
436                                .fg(Color::Green)
437                                .add_modifier(Modifier::BOLD),
438                        )
439                    } else {
440                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
441                    }
442                } else {
443                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
444                }
445            } else {
446                Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
447            };
448
449            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
450
451            let reason_str = repo.reason.to_string();
452            let adapters_str = if repo.adapters.is_empty() {
453                "—".to_string()
454            } else {
455                repo.adapters.join(", ")
456            };
457            let bloat_str = if repo.reclaimable_bytes > 0 {
458                format_bytes(repo.reclaimable_bytes)
459            } else {
460                "—".to_string()
461            };
462            // Green means "you can have these bytes back" everywhere in this tool, so a
463            // dash — nothing to reclaim — must not borrow the same colour.
464            let bloat_color = if repo.reclaimable_bytes > 0 {
465                Color::Green
466            } else {
467                Color::DarkGray
468            };
469            let activity_str = repo
470                .last_activity
471                .map(|d| d.format("%Y-%m-%d").to_string())
472                .unwrap_or_else(|| "—".to_string());
473            let pruned_str = repo
474                .entry
475                .last_pruned_at
476                .map(|d| d.format("%Y-%m-%d").to_string())
477                .unwrap_or_else(|| "Never".to_string());
478
479            let row_style = if is_selected {
480                Style::default().bg(Color::Rgb(20, 50, 30))
481            } else {
482                Style::default()
483            };
484
485            let on_dark_bg = is_selected || highlighted_row == Some(i);
486            let path_style = if on_dark_bg {
487                Style::default().fg(Color::White)
488            } else {
489                Style::default()
490            };
491            let date_color = if on_dark_bg {
492                Color::Gray
493            } else {
494                Color::DarkGray
495            };
496
497            Row::new(vec![
498                sel_cell,
499                Cell::from(path_str).style(path_style),
500                Cell::from(reason_str).style(Style::default().fg(color)),
501                Cell::from(adapters_str),
502                Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
503                Cell::from(activity_str).style(Style::default().fg(date_color)),
504                Cell::from(pruned_str).style(Style::default().fg(date_color)),
505            ])
506            .style(row_style)
507        })
508        .collect();
509
510    let table = Table::new(
511        rows,
512        [
513            Constraint::Length(4),  // sel/#
514            Constraint::Min(24),    // path
515            Constraint::Length(22), // reason
516            Constraint::Length(16), // adapters
517            Constraint::Length(11), // bloat
518            Constraint::Length(13), // last activity
519            Constraint::Length(13), // last pruned
520        ],
521    )
522    .header(col_headers)
523    .block(
524        Block::default()
525            .title(" Registered Repositories ")
526            .borders(Borders::ALL)
527            .border_style(Style::default().fg(Color::DarkGray)),
528    )
529    .row_highlight_style(
530        Style::default()
531            .bg(Color::Rgb(30, 40, 70))
532            .add_modifier(Modifier::BOLD),
533    )
534    .highlight_symbol("▶ ");
535
536    // The real state, not a clone: ratatui stores the computed scroll offset back into
537    // it, and rendering into a throwaway copy pins the viewport to the top — the
538    // moment the selection moved below the visible rows it simply left the screen.
539    frame.render_stateful_widget(table, outer[1], &mut app.table_state);
540
541    // ── Footer ────────────────────────────────────────────────────────────────
542    let mut footer_lines = if is_prune_mode {
543        vec![
544            Line::from(vec![
545                Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
546                // Bold carries the count; green carries the bytes, the same as it does
547                // in every other view. The whole line used to be yellow, which is the
548                // colour this screen already uses for "you are in the destructive mode"
549                // — saying it twice made neither reading land.
550                Span::styled(
551                    format!(
552                        "{} of {} candidates  ",
553                        app.selected_count(),
554                        app.candidate_count()
555                    ),
556                    Style::default().add_modifier(Modifier::BOLD),
557                ),
558                Span::styled(
559                    format!("({})", format_bytes(app.selected_bytes())),
560                    Style::default()
561                        .fg(Color::Green)
562                        .add_modifier(Modifier::BOLD),
563                ),
564            ]),
565            Line::from(vec![
566                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
567                Span::raw(" Navigate  "),
568                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
569                Span::raw(" Jump  "),
570                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
571                Span::raw(" Toggle  "),
572                Span::styled("[a]", Style::default().fg(Color::Cyan)),
573                Span::raw(" Toggle All  "),
574                Span::styled(
575                    "[Enter]",
576                    Style::default()
577                        .fg(Color::Green)
578                        .add_modifier(Modifier::BOLD),
579                ),
580                Span::raw(" Prune Selected  "),
581                Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
582                Span::raw(" Back to Browse  "),
583                Span::styled("[q]", Style::default().fg(Color::Cyan)),
584                Span::raw(" Quit"),
585            ]),
586            Line::from(vec![]),
587        ]
588    } else {
589        vec![
590            Line::from(vec![
591                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
592                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
593                Span::raw("  "),
594                Span::styled("■ Active", Style::default().fg(Color::Reset)),
595                Span::raw("  "),
596                // One swatch, because these two share a colour: neither needs anything
597                // from you, and a legend with two identical squares is worse than one.
598                Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
599                Span::raw("  "),
600                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
601            ]),
602            Line::from(vec![
603                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
604                Span::raw(" Navigate  "),
605                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
606                Span::raw(" Jump  "),
607                // Every key is cyan; the two that change what the view does are also
608                // bold. They used to be yellow and magenta, which said "warning" and
609                // "nothing" respectively, and red is needed for real failures rather
610                // than for the key that closes a screen.
611                Span::styled(
612                    "[p]",
613                    Style::default()
614                        .fg(Color::Cyan)
615                        .add_modifier(Modifier::BOLD),
616                ),
617                Span::raw(" Prune-Select Mode  "),
618                Span::styled(
619                    "[i]",
620                    Style::default()
621                        .fg(Color::Cyan)
622                        .add_modifier(Modifier::BOLD),
623                ),
624                Span::raw(" Toggle Ignore  "),
625                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
626                Span::raw(" Quit"),
627            ]),
628            Line::from(vec![
629                Span::styled(
630                    "[i] ",
631                    Style::default()
632                        .fg(Color::Cyan)
633                        .add_modifier(Modifier::BOLD),
634                ),
635                Span::styled(
636                    "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
637                    Style::default().fg(Color::DarkGray),
638                ),
639            ]),
640        ]
641    };
642
643    // The credit, on both footers, in the dimmest colour the theme has. It is one
644    // constant and one push — a fork that does not want it deletes these two lines and
645    // nothing else in the binary cares.
646    footer_lines.push(Line::from(Span::styled(
647        constants::ATTRIBUTION_LINE,
648        Style::default().fg(Color::DarkGray),
649    )));
650
651    let footer =
652        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
653            Style::default().fg(if is_prune_mode {
654                Color::Yellow
655            } else {
656                Color::Green
657            }),
658        ));
659    frame.render_widget(footer, outer[2]);
660}
661
662// ── Plain-text fallback ──────────────────────────────────────────────────────
663
664/// Plain text fallback rendering for non-TUI environments.
665pub fn render_status_plain(repos: &[RepoStatusEntry]) {
666    use crate::output;
667    use colored::Colorize;
668
669    output::print_header("dev-prune status");
670    // The repository column is padded in terminal columns, not `char`s: it is the only
671    // column whose contents a user names, so it is the only one that can hold wide
672    // characters. The rule spans the whole row — 3+35+22+12+12+13+13 plus six 2-space
673    // gaps — which is 122, not the 118 it used to draw.
674    println!(
675        "\n  {:>3}  {}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
676        "#",
677        output::pad_display("Repository", 35),
678        "Status / Reason",
679        "Adapters",
680        "Bloat",
681        "Last Activity",
682        "Last Pruned"
683    );
684    println!("  {}", "─".repeat(122));
685
686    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
687    for (i, repo) in repos.iter().enumerate() {
688        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
689        let reason = repo.reason.to_string();
690        let adapters = if repo.adapters.is_empty() {
691            "—".to_string()
692        } else {
693            repo.adapters.join("+")
694        };
695        let bloat = if repo.reclaimable_bytes > 0 {
696            format_bytes(repo.reclaimable_bytes)
697        } else {
698            "—".to_string()
699        };
700        let activity = repo
701            .last_activity
702            .map(|d| d.format("%Y-%m-%d").to_string())
703            .unwrap_or_else(|| "—".to_string());
704        let pruned = repo
705            .entry
706            .last_pruned_at
707            .map(|d| d.format("%Y-%m-%d").to_string())
708            .unwrap_or_else(|| "Never".to_string());
709
710        // Padded *before* colouring: ANSI escapes count toward `{:<22}`-style format
711        // widths, so colouring inside the format string would shear every column that
712        // follows. `colored` emits nothing when stdout is a pipe, so the plain widths
713        // survive redirection untouched.
714        //
715        // `Colorize::` spelled out, not `cell.green()`: this module imports
716        // `ratatui::prelude::*`, which brings `ratatui::style::Stylize` into scope with a
717        // `green()` of its own that takes `self` by value. It wins the method probe over
718        // `colored`'s, which needs an autoref, so `cell.green()` silently built a ratatui
719        // `Span` and `.to_string()` handed back the text with no escapes at all. This
720        // table printed in plain white for every release up to 1.3.0 and nothing failed.
721        // Three tiers, not six colours. Green marks the rows you can act on, red and
722        // yellow the two that are actually broken, and everything that is merely normal
723        // — in use, nothing to reclaim, deliberately ignored — is default or dim. A
724        // colour per enum variant reads as decoration and leaves nothing louder to say
725        // when a repository really is misconfigured.
726        let reason_cell = format!("{reason:<22}");
727        let reason_cell = match &repo.reason {
728            SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
729            SkipReason::Active => reason_cell,
730            SkipReason::Ignored | SkipReason::NoBloat => {
731                Colorize::dimmed(reason_cell.as_str()).to_string()
732            }
733            SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
734            SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
735        };
736        // Green, not bold green: this figure repeats on every row, and bolding all of
737        // them means none of them stands out. The bold copy is the grand total below.
738        let bloat_cell = if repo.reclaimable_bytes > 0 {
739            Colorize::green(format!("{bloat:<12}").as_str()).to_string()
740        } else {
741            format!("{bloat:<12}")
742        };
743        println!(
744            "  {:>3}  {}  {}  {:<12}  {}  {:<13}  {:<13}",
745            i + 1,
746            output::pad_display(&path_str, 35),
747            reason_cell,
748            adapters,
749            bloat_cell,
750            activity,
751            pruned
752        );
753    }
754    println!("  {}", "─".repeat(122));
755
756    let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
757    let candidates = repos
758        .iter()
759        .filter(|r| matches!(r.reason, SkipReason::Candidate))
760        .count();
761    // The one bold figure on the screen. Each row's own bloat is plain green; bolding
762    // the grand total is what makes it the line the eye lands on.
763    output::print_info(&format!(
764        "Total: {} repos  |  {} candidates  |  {} reclaimable",
765        repos.len(),
766        candidates,
767        output::format_bytes_styled(total)
768    ));
769
770    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
771    // the folder's apparent size — the rest is hardlinked into the manager's store.
772    // Said once here rather than per row so the table stays scannable.
773    let shared: u64 = repos
774        .iter()
775        .flat_map(|r| &r.bloat_dirs)
776        .map(|b| b.shared_bytes)
777        .sum();
778    if shared > 0 {
779        output::print_info(&format!(
780            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
781             node_modules does not free those bytes, the store keeps them.",
782            format_bytes(shared)
783        ));
784    }
785}
786
787// ── Tests ─────────────────────────────────────────────────────────────────────
788
789#[cfg(test)]
790mod tests {
791    use ratatui::style::Color;
792
793    use crate::engine::SkipReason;
794
795    use super::reason_color;
796
797    #[test]
798    fn test_row_style_logic() {
799        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
800        assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
801        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
802        assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
803        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
804        assert_eq!(
805            reason_color(&SkipReason::ConfigError(String::new())),
806            Color::Yellow
807        );
808    }
809}