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
316fn reason_color(reason: &SkipReason) -> Color {
317    match reason {
318        SkipReason::Candidate => Color::Green,
319        SkipReason::Active => Color::Cyan,
320        SkipReason::Ignored => Color::DarkGray,
321        SkipReason::NoBloat => Color::Blue,
322        SkipReason::PathMissing => Color::Red,
323        // Actionable rather than broken: the repo is fine, its config file is not.
324        SkipReason::ConfigError(_) => Color::Yellow,
325    }
326}
327
328fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
329    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
330
331    let outer = Layout::default()
332        .direction(Direction::Vertical)
333        .constraints([
334            Constraint::Length(3), // header
335            Constraint::Min(5),    // table
336            // Four content lines plus the border: the keybindings, the mode-specific
337            // line, and the credit line that closes both footers.
338            Constraint::Length(6), // footer
339        ])
340        .split(frame.area());
341
342    // ── Header ───────────────────────────────────────────────────────────────
343    let mode_label = if is_prune_mode {
344        Span::styled(
345            " PRUNE-SELECT MODE ",
346            Style::default()
347                .bg(Color::Yellow)
348                .fg(Color::Black)
349                .add_modifier(Modifier::BOLD),
350        )
351    } else {
352        Span::styled(
353            " BROWSE MODE ",
354            Style::default()
355                .bg(Color::Cyan)
356                .fg(Color::Black)
357                .add_modifier(Modifier::BOLD),
358        )
359    };
360
361    let header_line = Line::from(vec![
362        Span::styled(
363            " dev-prune ",
364            Style::default()
365                .fg(Color::Black)
366                .bg(Color::Green)
367                .add_modifier(Modifier::BOLD),
368        ),
369        Span::raw(" "),
370        mode_label,
371        Span::styled(
372            format!(
373                "  {} repos  |  {} candidates  |  {} reclaimable",
374                app.repos.len(),
375                app.candidate_count(),
376                format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
377            ),
378            Style::default().fg(Color::DarkGray),
379        ),
380    ]);
381
382    let header_widget =
383        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
384            Style::default().fg(if is_prune_mode {
385                Color::Yellow
386            } else {
387                Color::Cyan
388            }),
389        ));
390    frame.render_widget(header_widget, outer[0]);
391
392    // ── Table ─────────────────────────────────────────────────────────────────
393    let col_headers = Row::new(vec![
394        Cell::from(if is_prune_mode { "Sel" } else { "#" })
395            .style(Style::default().add_modifier(Modifier::BOLD)),
396        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
397        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
398        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
399        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
400        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
401        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
402    ])
403    .height(1)
404    .bottom_margin(1)
405    // The bar's background is a fixed dark navy, so the foreground must be fixed
406    // too: on a light-theme terminal the default foreground is near-black and
407    // vanishes into it.
408    .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
409
410    // Rows sitting on one of the fixed dark backgrounds (selection green, highlight
411    // blue) need explicitly light text; everywhere else the terminal's own default
412    // foreground is the only colour guaranteed readable on both light and dark themes.
413    let highlighted_row = app.table_state.selected();
414
415    // Once per frame, not once per row — display names are relative to each other,
416    // so the row closure below needs the full list, but rebuilding it n times made
417    // every redraw O(n²) in path clones.
418    let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
419
420    let rows: Vec<Row> = app
421        .repos
422        .iter()
423        .enumerate()
424        .map(|(i, repo)| {
425            let is_selected = app.selected[i];
426            let color = reason_color(&repo.reason);
427
428            let sel_cell = if is_prune_mode {
429                if matches!(repo.reason, SkipReason::Candidate) {
430                    if is_selected {
431                        Cell::from("[x]").style(
432                            Style::default()
433                                .fg(Color::Green)
434                                .add_modifier(Modifier::BOLD),
435                        )
436                    } else {
437                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
438                    }
439                } else {
440                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
441                }
442            } else {
443                Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
444            };
445
446            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
447
448            let reason_str = repo.reason.to_string();
449            let adapters_str = if repo.adapters.is_empty() {
450                "—".to_string()
451            } else {
452                repo.adapters.join(", ")
453            };
454            let bloat_str = if repo.reclaimable_bytes > 0 {
455                format_bytes(repo.reclaimable_bytes)
456            } else {
457                "—".to_string()
458            };
459            let activity_str = repo
460                .last_activity
461                .map(|d| d.format("%Y-%m-%d").to_string())
462                .unwrap_or_else(|| "—".to_string());
463            let pruned_str = repo
464                .entry
465                .last_pruned_at
466                .map(|d| d.format("%Y-%m-%d").to_string())
467                .unwrap_or_else(|| "Never".to_string());
468
469            let row_style = if is_selected {
470                Style::default().bg(Color::Rgb(20, 50, 30))
471            } else {
472                Style::default()
473            };
474
475            let on_dark_bg = is_selected || highlighted_row == Some(i);
476            let path_style = if on_dark_bg {
477                Style::default().fg(Color::White)
478            } else {
479                Style::default()
480            };
481            let date_color = if on_dark_bg {
482                Color::Gray
483            } else {
484                Color::DarkGray
485            };
486
487            Row::new(vec![
488                sel_cell,
489                Cell::from(path_str).style(path_style),
490                Cell::from(reason_str).style(Style::default().fg(color)),
491                Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
492                Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
493                Cell::from(activity_str).style(Style::default().fg(date_color)),
494                Cell::from(pruned_str).style(Style::default().fg(date_color)),
495            ])
496            .style(row_style)
497        })
498        .collect();
499
500    let table = Table::new(
501        rows,
502        [
503            Constraint::Length(4),  // sel/#
504            Constraint::Min(24),    // path
505            Constraint::Length(22), // reason
506            Constraint::Length(16), // adapters
507            Constraint::Length(11), // bloat
508            Constraint::Length(13), // last activity
509            Constraint::Length(13), // last pruned
510        ],
511    )
512    .header(col_headers)
513    .block(
514        Block::default()
515            .title(" Registered Repositories ")
516            .borders(Borders::ALL)
517            .border_style(Style::default().fg(Color::DarkGray)),
518    )
519    .row_highlight_style(
520        Style::default()
521            .bg(Color::Rgb(30, 40, 70))
522            .add_modifier(Modifier::BOLD),
523    )
524    .highlight_symbol("▶ ");
525
526    // The real state, not a clone: ratatui stores the computed scroll offset back into
527    // it, and rendering into a throwaway copy pins the viewport to the top — the
528    // moment the selection moved below the visible rows it simply left the screen.
529    frame.render_stateful_widget(table, outer[1], &mut app.table_state);
530
531    // ── Footer ────────────────────────────────────────────────────────────────
532    let mut footer_lines = if is_prune_mode {
533        vec![
534            Line::from(vec![
535                Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
536                Span::styled(
537                    format!(
538                        "{} of {} candidates  ({})",
539                        app.selected_count(),
540                        app.candidate_count(),
541                        format_bytes(app.selected_bytes())
542                    ),
543                    Style::default()
544                        .fg(Color::Yellow)
545                        .add_modifier(Modifier::BOLD),
546                ),
547            ]),
548            Line::from(vec![
549                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
550                Span::raw(" Navigate  "),
551                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
552                Span::raw(" Jump  "),
553                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
554                Span::raw(" Toggle  "),
555                Span::styled("[a]", Style::default().fg(Color::Cyan)),
556                Span::raw(" Toggle All  "),
557                Span::styled(
558                    "[Enter]",
559                    Style::default()
560                        .fg(Color::Green)
561                        .add_modifier(Modifier::BOLD),
562                ),
563                Span::raw(" Prune Selected  "),
564                Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
565                Span::raw(" Back to Browse  "),
566                Span::styled("[q]", Style::default().fg(Color::Red)),
567                Span::raw(" Quit"),
568            ]),
569            Line::from(vec![]),
570        ]
571    } else {
572        vec![
573            Line::from(vec![
574                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
575                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
576                Span::raw("  "),
577                Span::styled("■ Active", Style::default().fg(Color::Cyan)),
578                Span::raw("  "),
579                Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
580                Span::raw("  "),
581                Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
582                Span::raw("  "),
583                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
584            ]),
585            Line::from(vec![
586                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
587                Span::raw(" Navigate  "),
588                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
589                Span::raw(" Jump  "),
590                Span::styled(
591                    "[p]",
592                    Style::default()
593                        .fg(Color::Yellow)
594                        .add_modifier(Modifier::BOLD),
595                ),
596                Span::raw(" Prune-Select Mode  "),
597                Span::styled(
598                    "[i]",
599                    Style::default()
600                        .fg(Color::Magenta)
601                        .add_modifier(Modifier::BOLD),
602                ),
603                Span::raw(" Toggle Ignore  "),
604                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
605                Span::raw(" Quit"),
606            ]),
607            Line::from(vec![
608                Span::styled(
609                    "[i] ",
610                    Style::default()
611                        .fg(Color::Magenta)
612                        .add_modifier(Modifier::BOLD),
613                ),
614                Span::styled(
615                    "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
616                    Style::default().fg(Color::DarkGray),
617                ),
618            ]),
619        ]
620    };
621
622    // The credit, on both footers, in the dimmest colour the theme has. It is one
623    // constant and one push — a fork that does not want it deletes these two lines and
624    // nothing else in the binary cares.
625    footer_lines.push(Line::from(Span::styled(
626        constants::ATTRIBUTION_LINE,
627        Style::default().fg(Color::DarkGray),
628    )));
629
630    let footer =
631        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
632            Style::default().fg(if is_prune_mode {
633                Color::Yellow
634            } else {
635                Color::Green
636            }),
637        ));
638    frame.render_widget(footer, outer[2]);
639}
640
641// ── Plain-text fallback ──────────────────────────────────────────────────────
642
643/// Plain text fallback rendering for non-TUI environments.
644pub fn render_status_plain(repos: &[RepoStatusEntry]) {
645    use crate::output;
646    use colored::Colorize;
647
648    output::print_header("dev-prune status");
649    // The repository column is padded in terminal columns, not `char`s: it is the only
650    // column whose contents a user names, so it is the only one that can hold wide
651    // characters. The rule spans the whole row — 3+35+22+12+12+13+13 plus six 2-space
652    // gaps — which is 122, not the 118 it used to draw.
653    println!(
654        "\n  {:>3}  {}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
655        "#",
656        output::pad_display("Repository", 35),
657        "Status / Reason",
658        "Adapters",
659        "Bloat",
660        "Last Activity",
661        "Last Pruned"
662    );
663    println!("  {}", "─".repeat(122));
664
665    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
666    for (i, repo) in repos.iter().enumerate() {
667        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
668        let reason = repo.reason.to_string();
669        let adapters = if repo.adapters.is_empty() {
670            "—".to_string()
671        } else {
672            repo.adapters.join("+")
673        };
674        let bloat = if repo.reclaimable_bytes > 0 {
675            format_bytes(repo.reclaimable_bytes)
676        } else {
677            "—".to_string()
678        };
679        let activity = repo
680            .last_activity
681            .map(|d| d.format("%Y-%m-%d").to_string())
682            .unwrap_or_else(|| "—".to_string());
683        let pruned = repo
684            .entry
685            .last_pruned_at
686            .map(|d| d.format("%Y-%m-%d").to_string())
687            .unwrap_or_else(|| "Never".to_string());
688
689        // Padded *before* colouring: ANSI escapes count toward `{:<22}`-style format
690        // widths, so colouring inside the format string would shear every column that
691        // follows. `colored` emits nothing when stdout is a pipe, so the plain widths
692        // survive redirection untouched.
693        let reason_cell = format!("{reason:<22}");
694        let reason_cell = match &repo.reason {
695            SkipReason::Candidate => reason_cell.green().to_string(),
696            SkipReason::Active => reason_cell.cyan().to_string(),
697            SkipReason::Ignored => reason_cell.dimmed().to_string(),
698            SkipReason::NoBloat => reason_cell.blue().to_string(),
699            SkipReason::PathMissing => reason_cell.red().to_string(),
700            SkipReason::ConfigError(_) => reason_cell.yellow().to_string(),
701        };
702        let bloat_cell = if repo.reclaimable_bytes > 0 {
703            format!("{bloat:<12}").green().bold().to_string()
704        } else {
705            format!("{bloat:<12}")
706        };
707        println!(
708            "  {:>3}  {}  {}  {}  {}  {:<13}  {:<13}",
709            i + 1,
710            output::pad_display(&path_str, 35),
711            reason_cell,
712            format!("{adapters:<12}").magenta(),
713            bloat_cell,
714            activity,
715            pruned
716        );
717    }
718    println!("  {}", "─".repeat(122));
719
720    let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
721    let candidates = repos
722        .iter()
723        .filter(|r| matches!(r.reason, SkipReason::Candidate))
724        .count();
725    output::print_info(&format!(
726        "Total: {} repos  |  {} candidates  |  {} reclaimable",
727        repos.len(),
728        candidates,
729        format_bytes(total)
730    ));
731
732    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
733    // the folder's apparent size — the rest is hardlinked into the manager's store.
734    // Said once here rather than per row so the table stays scannable.
735    let shared: u64 = repos
736        .iter()
737        .flat_map(|r| &r.bloat_dirs)
738        .map(|b| b.shared_bytes)
739        .sum();
740    if shared > 0 {
741        output::print_info(&format!(
742            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
743             node_modules does not free those bytes, the store keeps them.",
744            format_bytes(shared)
745        ));
746    }
747}
748
749// ── Tests ─────────────────────────────────────────────────────────────────────
750
751#[cfg(test)]
752mod tests {
753    use ratatui::style::Color;
754
755    use crate::engine::SkipReason;
756
757    use super::reason_color;
758
759    #[test]
760    fn test_row_style_logic() {
761        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
762        assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
763        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
764        assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
765        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
766    }
767}