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
33/// Which column the table is ordered by.
34///
35/// `Default` is the order [`crate::engine::get_full_status`] produced — actionable
36/// first, then everything merely present, then everything gone. It is first in the
37/// cycle and it is where the view starts, because that ordering is the one answer to
38/// "what should I look at" and no sort the user picks should be hard to get back to.
39#[derive(Clone, Copy, PartialEq, Eq)]
40enum SortKey {
41    Default,
42    /// Largest reclaim first.
43    Size,
44    /// Least recently touched first — the repositories nobody has opened in months.
45    Activity,
46    /// By path, A to Z.
47    Name,
48}
49
50impl SortKey {
51    fn next(self) -> Self {
52        match self {
53            SortKey::Default => SortKey::Size,
54            SortKey::Size => SortKey::Activity,
55            SortKey::Activity => SortKey::Name,
56            SortKey::Name => SortKey::Default,
57        }
58    }
59
60    fn label(self) -> &'static str {
61        match self {
62            SortKey::Default => "relevance",
63            SortKey::Size => "size ↓",
64            SortKey::Activity => "idle longest ↓",
65            SortKey::Name => "name ↑",
66        }
67    }
68}
69
70/// Which rows the table shows.
71#[derive(Clone, Copy, PartialEq, Eq)]
72enum Filter {
73    All,
74    /// Only what a prune would act on.
75    Candidates,
76    /// Anything holding reclaimable bytes, candidate or not — including the repositories
77    /// still active, which is the list you want before deciding to wait.
78    WithBloat,
79    /// The rows that need a decision rather than a prune: a path that is gone, a config
80    /// file that does not parse.
81    Problems,
82}
83
84impl Filter {
85    fn next(self) -> Self {
86        match self {
87            Filter::All => Filter::Candidates,
88            Filter::Candidates => Filter::WithBloat,
89            Filter::WithBloat => Filter::Problems,
90            Filter::Problems => Filter::All,
91        }
92    }
93
94    fn label(self) -> &'static str {
95        match self {
96            Filter::All => "all",
97            Filter::Candidates => "candidates",
98            Filter::WithBloat => "has bloat",
99            Filter::Problems => "problems",
100        }
101    }
102
103    fn accepts(self, repo: &RepoStatusEntry) -> bool {
104        match self {
105            Filter::All => true,
106            Filter::Candidates => matches!(repo.reason, SkipReason::Candidate),
107            Filter::WithBloat => repo.reclaimable_bytes > 0,
108            Filter::Problems => matches!(
109                repo.reason,
110                SkipReason::PathMissing | SkipReason::ConfigError(_)
111            ),
112        }
113    }
114}
115
116struct StatusApp<'a> {
117    repos: &'a [RepoStatusEntry],
118    /// Indices into `repos`, filtered and sorted — the rows actually on screen.
119    ///
120    /// The table addresses this, everything else addresses `repos`. Keeping the two
121    /// apart is what lets a filter hide a row without disturbing which repositories are
122    /// checked for pruning, and what stops the confirmed selection from meaning
123    /// something different depending on how the table happened to be sorted.
124    view: Vec<usize>,
125    table_state: TableState,
126    /// Which rows are checked for prune (indexed to `repos`).
127    selected: Vec<bool>,
128    mode: ViewMode,
129    sort: SortKey,
130    filter: Filter,
131    /// Case-insensitive substring matched against the path and the adapter names.
132    search: String,
133    /// True while keystrokes are going into `search` rather than being commands.
134    searching: bool,
135    /// If `Some`, the user confirmed and we return these indices.
136    confirmed_indices: Option<Vec<usize>>,
137    /// Set to true when the user toggles ignore config in .devprune.json or presence of ignore.devprune.json so caller can reload.
138    pub should_reload: bool,
139}
140
141impl<'a> StatusApp<'a> {
142    fn new(repos: &'a [RepoStatusEntry]) -> Self {
143        let selected = vec![false; repos.len()];
144        let mut table_state = TableState::default();
145        table_state.select(Some(0));
146        let mut app = Self {
147            repos,
148            view: Vec::new(),
149            table_state,
150            selected,
151            mode: ViewMode::Browse,
152            sort: SortKey::Default,
153            filter: Filter::All,
154            search: String::new(),
155            searching: false,
156            confirmed_indices: None,
157            should_reload: false,
158        };
159        app.rebuild_view();
160        app
161    }
162
163    /// Recompute the visible rows after a change to the sort, the filter or the query.
164    ///
165    /// Keeps the cursor on the same *repository* rather than the same row number. A
166    /// filter that removes four rows above the cursor would otherwise slide the
167    /// selection onto a different repository under the user's hands — and `i` and the
168    /// prune toggle both act on whatever is selected.
169    fn rebuild_view(&mut self) {
170        let anchor = self.cursor_repo();
171        let needle = self.search.to_lowercase();
172
173        let mut view: Vec<usize> = (0..self.repos.len())
174            .filter(|&i| {
175                let repo = &self.repos[i];
176                if !self.filter.accepts(repo) {
177                    return false;
178                }
179                if needle.is_empty() {
180                    return true;
181                }
182                // Adapters as well as the path, so `/uv` finds every Python project
183                // without anyone having to remember where they live.
184                repo.path.to_string_lossy().to_lowercase().contains(&needle)
185                    || repo
186                        .adapters
187                        .iter()
188                        .any(|a| a.to_lowercase().contains(&needle))
189            })
190            .collect();
191
192        match self.sort {
193            // Already in this order: `get_full_status` sorted the slice before it got
194            // here, and `view` was built by walking it forwards.
195            SortKey::Default => {}
196            SortKey::Size => view.sort_by(|&a, &b| {
197                self.repos[b]
198                    .reclaimable_bytes
199                    .cmp(&self.repos[a].reclaimable_bytes)
200                    .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
201            }),
202            // `None` is a repository whose activity could not be read at all, which is
203            // not evidence of being idle — so it sorts last rather than first.
204            SortKey::Activity => view.sort_by(|&a, &b| {
205                let key = |i: usize| {
206                    self.repos[i]
207                        .last_activity
208                        .map(|t| (0, t))
209                        .unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
210                };
211                key(a)
212                    .cmp(&key(b))
213                    .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
214            }),
215            SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
216        }
217
218        self.view = view;
219        let row = anchor
220            .and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
221            .unwrap_or(0);
222        // `then`, not `then_some`: the argument to `then_some` is evaluated whether the
223        // condition holds or not, and on an empty view that subtraction underflows.
224        self.table_state
225            .select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
226    }
227
228    /// Index into `repos` of the highlighted row, or `None` when the view is empty.
229    fn cursor_repo(&self) -> Option<usize> {
230        self.view.get(self.table_state.selected()?).copied()
231    }
232
233    fn move_up(&mut self) {
234        let i = match self.table_state.selected() {
235            Some(i) => {
236                if i == 0 {
237                    self.view.len().saturating_sub(1)
238                } else {
239                    i - 1
240                }
241            }
242            None => 0,
243        };
244        self.table_state.select(Some(i));
245    }
246
247    fn move_down(&mut self) {
248        let i = match self.table_state.selected() {
249            Some(i) => {
250                if i >= self.view.len().saturating_sub(1) {
251                    0
252                } else {
253                    i + 1
254                }
255            }
256            None => 0,
257        };
258        self.table_state.select(Some(i));
259    }
260
261    fn toggle_current(&mut self) {
262        if let Some(i) = self.cursor_repo() {
263            // Only allow toggling candidate repos for pruning
264            if matches!(self.repos[i].reason, SkipReason::Candidate) {
265                self.selected[i] = !self.selected[i];
266            }
267        }
268    }
269
270    /// Check, or uncheck, every candidate currently on screen.
271    ///
272    /// Deliberately scoped to the view: with a filter or a search active, "all" has to
273    /// mean the rows the user can see. Selecting thirty repositories they filtered out
274    /// on a keypress labelled *Toggle All* is how a prune becomes a surprise.
275    fn toggle_all_candidates(&mut self) {
276        let visible: Vec<usize> = self
277            .view
278            .iter()
279            .copied()
280            .filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
281            .collect();
282        let any_selected = visible.iter().any(|&i| self.selected[i]);
283        for i in visible {
284            self.selected[i] = !any_selected;
285        }
286    }
287
288    fn confirm_prune(&mut self) {
289        let indices: Vec<usize> = self
290            .selected
291            .iter()
292            .enumerate()
293            .filter(|&(_, s)| *s)
294            .map(|(i, _)| i)
295            .collect();
296        self.confirmed_indices = Some(indices);
297    }
298
299    fn selected_bytes(&self) -> u64 {
300        self.repos
301            .iter()
302            .enumerate()
303            .filter(|(i, _)| self.selected[*i])
304            .map(|(_, r)| r.reclaimable_bytes)
305            .sum()
306    }
307
308    fn selected_count(&self) -> usize {
309        self.selected.iter().filter(|&&s| s).count()
310    }
311
312    fn candidate_count(&self) -> usize {
313        self.repos
314            .iter()
315            .filter(|r| matches!(r.reason, SkipReason::Candidate))
316            .count()
317    }
318}
319
320/// Split the reclaimable total into what a prune would free *now* and everything.
321///
322/// Two very different numbers that were being shown as one. The grand total counts the
323/// dependency directories in every registered repository, including the ones worked in
324/// this morning — a figure nothing is going to act on. What people are reading the
325/// dashboard for is the other one: the repositories that have gone idle long enough to
326/// be candidates, whose bytes `devp run` would reclaim on its next pass.
327///
328/// Always over the whole registry, never over the filtered view. A header that shrank
329/// when a filter was applied would make the machine look tidier than it is.
330fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
331    let ready = repos
332        .iter()
333        .filter(|r| matches!(r.reason, SkipReason::Candidate))
334        .map(|r| r.reclaimable_bytes)
335        .sum();
336    let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
337    (ready, total)
338}
339
340/// Render the full interactive status view.
341///
342/// Returns `Some` with the selected repositories' paths if the user confirmed a
343/// prune, or `None` if they just quit. Paths, not indices: every `i` toggle
344/// reloads the list, and an ignored repository entering or leaving it renumbers
345/// everything — indices handed to the caller would address the list it loaded
346/// before any of that happened.
347///
348/// Re-runs automatically when the user toggles ignore config in `devprune.json` so the
349/// status reflects the change immediately.
350pub fn render_status_tui(
351    repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
352) -> Result<Option<Vec<PathBuf>>> {
353    loop {
354        let repos = repos_loader();
355
356        if repos.is_empty() {
357            return Ok(None);
358        }
359
360        let mut app = StatusApp::new(&repos);
361        {
362            // Scoped so the terminal is restored before anything below prints, and on
363            // every exit path from the loop — return, error, or panic.
364            let mut tui = Tui::new()?;
365            tui.drain_stale_input(Duration::from_millis(100));
366            run_status_loop(&mut tui.terminal, &mut app)?;
367        }
368
369        if app.should_reload {
370            // User toggled ignore — reload and re-render
371            continue;
372        }
373
374        // Resolved against `repos` — the list this iteration actually displayed —
375        // while it is still in scope, so a reload can never desynchronise them.
376        return Ok(app
377            .confirmed_indices
378            .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
379    }
380}
381
382fn run_status_loop(
383    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
384    app: &mut StatusApp,
385) -> Result<()> {
386    loop {
387        terminal.draw(|frame| render_ui(frame, app))?;
388
389        if event::poll(Duration::from_millis(100))?
390            && let Event::Key(key) = event::read()?
391        {
392            if key.kind == KeyEventKind::Release {
393                continue;
394            }
395            // Raw mode delivers Ctrl-C as a key event rather than a signal, so
396            // without this the one key everybody reaches for to escape does nothing.
397            if key.modifiers.contains(KeyModifiers::CONTROL)
398                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
399            {
400                return Ok(());
401            }
402            // While the query line is open every printable key is text, not a command.
403            // Checked before the command table below, because otherwise typing `q` in a
404            // search would quit and typing `p` would arm a prune.
405            if app.searching {
406                match key.code {
407                    KeyCode::Char(c) => {
408                        app.search.push(c);
409                        app.rebuild_view();
410                    }
411                    KeyCode::Backspace => {
412                        app.search.pop();
413                        app.rebuild_view();
414                    }
415                    // Enter keeps the filter and hands the keyboard back; Esc abandons
416                    // it. A query you cannot undo in one key is a query people restart
417                    // the whole command to escape.
418                    KeyCode::Enter => app.searching = false,
419                    KeyCode::Esc => {
420                        app.searching = false;
421                        app.search.clear();
422                        app.rebuild_view();
423                    }
424                    _ => {}
425                }
426                continue;
427            }
428            match key.code {
429                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
430                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
431                KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
432                KeyCode::End | KeyCode::Char('G') => {
433                    app.table_state
434                        .select(Some(app.view.len().saturating_sub(1)));
435                }
436                KeyCode::PageUp => {
437                    let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
438                    app.table_state.select(Some(i));
439                }
440                KeyCode::PageDown => {
441                    let i = (app.table_state.selected().unwrap_or(0) + 10)
442                        .min(app.view.len().saturating_sub(1));
443                    app.table_state.select(Some(i));
444                }
445                KeyCode::Char('s') | KeyCode::Char('S') => {
446                    app.sort = app.sort.next();
447                    app.rebuild_view();
448                }
449                KeyCode::Char('f') | KeyCode::Char('F') => {
450                    app.filter = app.filter.next();
451                    app.rebuild_view();
452                }
453                KeyCode::Char('/') => app.searching = true,
454                KeyCode::Char(' ') => {
455                    if matches!(app.mode, ViewMode::PruneSelect) {
456                        app.toggle_current();
457                    }
458                }
459                KeyCode::Char('a') | KeyCode::Char('A') => {
460                    if matches!(app.mode, ViewMode::PruneSelect) {
461                        app.toggle_all_candidates();
462                    }
463                }
464                KeyCode::Char('p') | KeyCode::Char('P') => {
465                    app.mode = ViewMode::PruneSelect;
466                    // Auto-select the candidates on screen — not every candidate in the
467                    // registry. Someone who filtered or searched their way down to four
468                    // repositories asked for those four.
469                    for i in app.view.clone() {
470                        if matches!(app.repos[i].reason, SkipReason::Candidate) {
471                            app.selected[i] = true;
472                        }
473                    }
474                }
475                KeyCode::Char('i') | KeyCode::Char('I') => {
476                    // Toggle ignore in .devprune.json on the current repo
477                    if let Some(idx) = app.cursor_repo() {
478                        let repo = &app.repos[idx];
479                        // A missing repository has no directory to write the config
480                        // into; propagating that write error would tear the whole TUI
481                        // down over a row that says "Path Missing" right on it.
482                        if matches!(repo.reason, SkipReason::PathMissing) {
483                            continue;
484                        }
485                        // Refuses a config that does not parse. Starting from the
486                        // defaults would have written a fresh file over the broken
487                        // one, discarding every other override it held — and the
488                        // dashboard already shows such a repo as `config_error`.
489                        let layers = crate::config::RepoConfigLayers::load(&repo.path)
490                            .map_err(|e| anyhow::anyhow!(e))
491                            .with_context(|| {
492                                format!(
493                                    "Could not toggle ignore for {}",
494                                    crate::output::clean_path(&repo.path)
495                                )
496                            })?;
497                        // A committed `project.devprune.json` that names `ignore` wins over
498                        // anything this key could write, so writing it anyway would leave
499                        // a personal file disagreeing with the row it did not change. Skip
500                        // it the same way a missing path is skipped; the help pane says
501                        // which file to edit instead.
502                        if layers.source_of("ignore") == crate::config::ConfigSource::Project {
503                            continue;
504                        }
505                        let mut per_repo = layers.personal_config().cloned().unwrap_or_default();
506                        per_repo.ignore = !per_repo.ignore;
507                        // Both writes are propagated rather than swallowed. A silent
508                        // failure here redraws the table unchanged, which reads as a
509                        // dead key; worse, a repository the user just un-ignored would
510                        // still be pruned on the next pass.
511                        per_repo.save_to_repo(&repo.path).with_context(|| {
512                            format!(
513                                "Could not write the config for {}",
514                                crate::output::clean_path(&repo.path)
515                            )
516                        })?;
517
518                        // The legacy marker file still counts as "ignored", so it has
519                        // to go for the toggle to mean anything.
520                        let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
521                        if legacy_ignore.exists() {
522                            std::fs::remove_file(&legacy_ignore).with_context(|| {
523                                format!(
524                                    "Could not remove {}",
525                                    crate::output::clean_path(&legacy_ignore)
526                                )
527                            })?;
528                        }
529
530                        // Signal caller to reload status
531                        app.should_reload = true;
532                        return Ok(());
533                    }
534                }
535                KeyCode::Enter => {
536                    if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
537                        app.confirm_prune();
538                        return Ok(());
539                    }
540                }
541                KeyCode::Esc => {
542                    if matches!(app.mode, ViewMode::PruneSelect) {
543                        // Exit prune-select mode, go back to browse
544                        app.mode = ViewMode::Browse;
545                        app.selected.fill(false);
546                    } else {
547                        return Ok(());
548                    }
549                }
550                KeyCode::Char('q') => return Ok(()),
551                _ => {}
552            }
553        }
554    }
555}
556
557/// Three tiers, not six colours: green is actionable, red and yellow are broken, and
558/// everything that is merely normal is the terminal's own colour or grey. A distinct hue
559/// per variant looks like a legend the reader has to learn, and it spends the loud
560/// colours on rows that need nothing.
561fn reason_color(reason: &SkipReason) -> Color {
562    match reason {
563        SkipReason::Candidate => Color::Green,
564        SkipReason::Active => Color::Reset,
565        SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
566        SkipReason::PathMissing => Color::Red,
567        // Actionable rather than broken: the repo is fine, its config file is not.
568        SkipReason::ConfigError(_) => Color::Yellow,
569    }
570}
571
572fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
573    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
574
575    let outer = Layout::default()
576        .direction(Direction::Vertical)
577        .constraints([
578            Constraint::Length(3), // header
579            Constraint::Min(5),    // table
580            // Four content lines plus the border: the keybindings, the mode-specific
581            // line, and the credit line that closes both footers.
582            // Five content lines plus the border: the sort/filter/search line, the
583            // keybindings, the mode-specific line, and the credit line.
584            Constraint::Length(7), // footer
585        ])
586        .split(frame.area());
587
588    // ── Header ───────────────────────────────────────────────────────────────
589    let mode_label = if is_prune_mode {
590        Span::styled(
591            " PRUNE-SELECT MODE ",
592            Style::default()
593                .bg(Color::Yellow)
594                .fg(Color::Black)
595                .add_modifier(Modifier::BOLD),
596        )
597    } else {
598        Span::styled(
599            " BROWSE MODE ",
600            Style::default()
601                .bg(Color::Cyan)
602                .fg(Color::Black)
603                .add_modifier(Modifier::BOLD),
604        )
605    };
606
607    let (ready, total) = reclaimable_split(app.repos);
608    let header_line = Line::from(vec![
609        Span::styled(
610            " dev-prune ",
611            Style::default()
612                .fg(Color::Black)
613                .bg(Color::Green)
614                .add_modifier(Modifier::BOLD),
615        ),
616        Span::raw(" "),
617        mode_label,
618        Span::styled(
619            format!(
620                "  {}  |  {} candidates  |  ",
621                // "showing 4 of 80" only when they differ — on an unfiltered view the
622                // qualifier is noise, and it is the filtered view that needs to say
623                // plainly that the numbers beside it are not the whole machine.
624                if app.view.len() == app.repos.len() {
625                    format!("{} repos", app.repos.len())
626                } else {
627                    format!("showing {} of {} repos", app.view.len(), app.repos.len())
628                },
629                app.candidate_count(),
630            ),
631            Style::default().fg(Color::DarkGray),
632        ),
633        // The actionable figure, and the only coloured one in the header: this is what
634        // pressing `p` right now would free.
635        Span::styled(
636            format!("{} ready now", format_bytes(ready)),
637            Style::default()
638                .fg(Color::Green)
639                .add_modifier(Modifier::BOLD),
640        ),
641        Span::styled(
642            format!("  |  {} reclaimable in all", format_bytes(total)),
643            Style::default().fg(Color::DarkGray),
644        ),
645    ]);
646
647    let header_widget =
648        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
649            Style::default().fg(if is_prune_mode {
650                Color::Yellow
651            } else {
652                Color::Cyan
653            }),
654        ));
655    frame.render_widget(header_widget, outer[0]);
656
657    // ── Table ─────────────────────────────────────────────────────────────────
658    let col_headers = Row::new(vec![
659        Cell::from(if is_prune_mode { "Sel" } else { "#" })
660            .style(Style::default().add_modifier(Modifier::BOLD)),
661        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
662        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
663        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
664        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
665        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
666        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
667    ])
668    .height(1)
669    .bottom_margin(1)
670    // The bar's background is a fixed dark navy, so the foreground must be fixed
671    // too: on a light-theme terminal the default foreground is near-black and
672    // vanishes into it.
673    .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
674
675    // Rows sitting on one of the fixed dark backgrounds (selection green, highlight
676    // blue) need explicitly light text; everywhere else the terminal's own default
677    // foreground is the only colour guaranteed readable on both light and dark themes.
678    let highlighted_row = app.table_state.selected();
679
680    // Once per frame, not once per row — display names are relative to each other,
681    // so the row closure below needs the full list, but rebuilding it n times made
682    // every redraw O(n²) in path clones.
683    let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
684
685    let rows: Vec<Row> = app
686        .view
687        .iter()
688        .enumerate()
689        .map(|(row, &i)| {
690            let repo = &app.repos[i];
691            let is_selected = app.selected[i];
692            let color = reason_color(&repo.reason);
693
694            let sel_cell = if is_prune_mode {
695                if matches!(repo.reason, SkipReason::Candidate) {
696                    if is_selected {
697                        Cell::from("[x]").style(
698                            Style::default()
699                                .fg(Color::Green)
700                                .add_modifier(Modifier::BOLD),
701                        )
702                    } else {
703                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
704                    }
705                } else {
706                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
707                }
708            } else {
709                Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
710            };
711
712            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
713
714            let reason_str = repo.reason.to_string();
715            let adapters_str = if repo.adapters.is_empty() {
716                "—".to_string()
717            } else {
718                repo.adapters.join(", ")
719            };
720            let bloat_str = if repo.reclaimable_bytes > 0 {
721                format_bytes(repo.reclaimable_bytes)
722            } else {
723                "—".to_string()
724            };
725            // Green means "you can have these bytes back" everywhere in this tool, so a
726            // dash — nothing to reclaim — must not borrow the same colour.
727            let bloat_color = if repo.reclaimable_bytes > 0 {
728                Color::Green
729            } else {
730                Color::DarkGray
731            };
732            let activity_str = repo
733                .last_activity
734                .map(|d| d.format("%Y-%m-%d").to_string())
735                .unwrap_or_else(|| "—".to_string());
736            let pruned_str = repo
737                .entry
738                .last_pruned_at
739                .map(|d| d.format("%Y-%m-%d").to_string())
740                .unwrap_or_else(|| "Never".to_string());
741
742            let row_style = if is_selected {
743                Style::default().bg(Color::Rgb(20, 50, 30))
744            } else {
745                Style::default()
746            };
747
748            let on_dark_bg = is_selected || highlighted_row == Some(i);
749            let path_style = if on_dark_bg {
750                Style::default().fg(Color::White)
751            } else {
752                Style::default()
753            };
754            let date_color = if on_dark_bg {
755                Color::Gray
756            } else {
757                Color::DarkGray
758            };
759
760            Row::new(vec![
761                sel_cell,
762                Cell::from(path_str).style(path_style),
763                Cell::from(reason_str).style(Style::default().fg(color)),
764                Cell::from(adapters_str),
765                Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
766                Cell::from(activity_str).style(Style::default().fg(date_color)),
767                Cell::from(pruned_str).style(Style::default().fg(date_color)),
768            ])
769            .style(row_style)
770        })
771        .collect();
772
773    let table = Table::new(
774        rows,
775        [
776            Constraint::Length(4),  // sel/#
777            Constraint::Min(24),    // path
778            Constraint::Length(22), // reason
779            Constraint::Length(16), // adapters
780            Constraint::Length(11), // bloat
781            Constraint::Length(13), // last activity
782            Constraint::Length(13), // last pruned
783        ],
784    )
785    .header(col_headers)
786    .block(
787        Block::default()
788            .title(" Registered Repositories ")
789            .borders(Borders::ALL)
790            .border_style(Style::default().fg(Color::DarkGray)),
791    )
792    .row_highlight_style(
793        Style::default()
794            .bg(Color::Rgb(30, 40, 70))
795            .add_modifier(Modifier::BOLD),
796    )
797    .highlight_symbol("▶ ");
798
799    // The real state, not a clone: ratatui stores the computed scroll offset back into
800    // it, and rendering into a throwaway copy pins the viewport to the top — the
801    // moment the selection moved below the visible rows it simply left the screen.
802    frame.render_stateful_widget(table, outer[1], &mut app.table_state);
803
804    // ── Footer ────────────────────────────────────────────────────────────────
805    let mut footer_lines = if is_prune_mode {
806        vec![
807            Line::from(vec![
808                Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
809                // Bold carries the count; green carries the bytes, the same as it does
810                // in every other view. The whole line used to be yellow, which is the
811                // colour this screen already uses for "you are in the destructive mode"
812                // — saying it twice made neither reading land.
813                Span::styled(
814                    format!(
815                        "{} of {} candidates  ",
816                        app.selected_count(),
817                        app.candidate_count()
818                    ),
819                    Style::default().add_modifier(Modifier::BOLD),
820                ),
821                Span::styled(
822                    format!("({})", format_bytes(app.selected_bytes())),
823                    Style::default()
824                        .fg(Color::Green)
825                        .add_modifier(Modifier::BOLD),
826                ),
827            ]),
828            Line::from(vec![
829                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
830                Span::raw(" Navigate  "),
831                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
832                Span::raw(" Jump  "),
833                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
834                Span::raw(" Toggle  "),
835                Span::styled("[a]", Style::default().fg(Color::Cyan)),
836                Span::raw(" Toggle All  "),
837                Span::styled(
838                    "[Enter]",
839                    Style::default()
840                        .fg(Color::Green)
841                        .add_modifier(Modifier::BOLD),
842                ),
843                Span::raw(" Prune Selected  "),
844                Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
845                Span::raw(" Back to Browse  "),
846                Span::styled("[q]", Style::default().fg(Color::Cyan)),
847                Span::raw(" Quit"),
848            ]),
849            Line::from(vec![]),
850        ]
851    } else {
852        vec![
853            Line::from(vec![
854                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
855                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
856                Span::raw("  "),
857                Span::styled("■ Active", Style::default().fg(Color::Reset)),
858                Span::raw("  "),
859                // One swatch, because these two share a colour: neither needs anything
860                // from you, and a legend with two identical squares is worse than one.
861                Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
862                Span::raw("  "),
863                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
864            ]),
865            Line::from(vec![
866                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
867                Span::raw(" Navigate  "),
868                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
869                Span::raw(" Jump  "),
870                // Every key is cyan; the two that change what the view does are also
871                // bold. They used to be yellow and magenta, which said "warning" and
872                // "nothing" respectively, and red is needed for real failures rather
873                // than for the key that closes a screen.
874                Span::styled(
875                    "[p]",
876                    Style::default()
877                        .fg(Color::Cyan)
878                        .add_modifier(Modifier::BOLD),
879                ),
880                Span::raw(" Prune-Select Mode  "),
881                Span::styled(
882                    "[i]",
883                    Style::default()
884                        .fg(Color::Cyan)
885                        .add_modifier(Modifier::BOLD),
886                ),
887                Span::raw(" Toggle Ignore  "),
888                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
889                Span::raw(" Quit"),
890            ]),
891            Line::from(vec![
892                Span::styled(
893                    "[i] ",
894                    Style::default()
895                        .fg(Color::Cyan)
896                        .add_modifier(Modifier::BOLD),
897                ),
898                Span::styled(
899                    "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly. Inert where a committed `project.devprune.json` sets `ignore`; edit that file instead.",
900                    Style::default().fg(Color::DarkGray),
901                ),
902            ]),
903        ]
904    };
905
906    // The state of the view, on both footers. Without it a filtered or searched table is
907    // indistinguishable from a machine that simply has fewer repositories on it — and
908    // the count in the header would then read as a fact about the machine.
909    let mut state_line = vec![
910        Span::styled(
911            "[s]",
912            Style::default()
913                .fg(Color::Cyan)
914                .add_modifier(Modifier::BOLD),
915        ),
916        Span::raw(" Sort: "),
917        Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
918        Span::raw("   "),
919        Span::styled(
920            "[f]",
921            Style::default()
922                .fg(Color::Cyan)
923                .add_modifier(Modifier::BOLD),
924        ),
925        Span::raw(" Filter: "),
926        Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
927        Span::raw("   "),
928        Span::styled(
929            "[/]",
930            Style::default()
931                .fg(Color::Cyan)
932                .add_modifier(Modifier::BOLD),
933        ),
934        Span::raw(" Search: "),
935    ];
936    if app.searching {
937        // A block for the caret, because raw mode gives the terminal's own cursor
938        // nowhere useful to sit — the table has it.
939        state_line.push(Span::styled(
940            format!("{}█", app.search),
941            Style::default()
942                .fg(Color::Yellow)
943                .add_modifier(Modifier::BOLD),
944        ));
945        state_line.push(Span::styled(
946            "  (Enter to keep, Esc to clear)",
947            Style::default().fg(Color::DarkGray),
948        ));
949    } else if app.search.is_empty() {
950        state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
951    } else {
952        state_line.push(Span::styled(
953            app.search.clone(),
954            Style::default().fg(Color::Yellow),
955        ));
956    }
957    if app.view.is_empty() {
958        // The one state where the table itself says nothing at all. Silence here reads
959        // as a crash, so the footer has to explain what hid the rows.
960        state_line.push(Span::styled(
961            "   no repositories match",
962            Style::default().fg(Color::Red),
963        ));
964    }
965    footer_lines.push(Line::from(state_line));
966
967    // The credit, on both footers, in the dimmest colour the theme has. It is one
968    // constant and one push — a fork that does not want it deletes these two lines and
969    // nothing else in the binary cares.
970    footer_lines.push(Line::from(Span::styled(
971        constants::ATTRIBUTION_LINE,
972        Style::default().fg(Color::DarkGray),
973    )));
974
975    let footer =
976        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
977            Style::default().fg(if is_prune_mode {
978                Color::Yellow
979            } else {
980                Color::Green
981            }),
982        ));
983    frame.render_widget(footer, outer[2]);
984}
985
986// ── Plain-text fallback ──────────────────────────────────────────────────────
987
988/// Plain text fallback rendering for non-TUI environments.
989pub fn render_status_plain(repos: &[RepoStatusEntry]) {
990    use crate::output;
991    use colored::Colorize;
992
993    output::print_header("dev-prune status");
994    // The repository column is padded in terminal columns, not `char`s: it is the only
995    // column whose contents a user names, so it is the only one that can hold wide
996    // characters. The rule spans the whole row — 3+35+22+12+12+13+13 plus six 2-space
997    // gaps — which is 122, not the 118 it used to draw.
998    println!(
999        "\n  {:>3}  {}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
1000        "#",
1001        output::pad_display("Repository", 35),
1002        "Status / Reason",
1003        "Adapters",
1004        "Bloat",
1005        "Last Activity",
1006        "Last Pruned"
1007    );
1008    println!("  {}", "─".repeat(122));
1009
1010    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1011    for (i, repo) in repos.iter().enumerate() {
1012        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1013        let reason = repo.reason.to_string();
1014        let adapters = if repo.adapters.is_empty() {
1015            "—".to_string()
1016        } else {
1017            repo.adapters.join("+")
1018        };
1019        let bloat = if repo.reclaimable_bytes > 0 {
1020            format_bytes(repo.reclaimable_bytes)
1021        } else {
1022            "—".to_string()
1023        };
1024        let activity = repo
1025            .last_activity
1026            .map(|d| d.format("%Y-%m-%d").to_string())
1027            .unwrap_or_else(|| "—".to_string());
1028        let pruned = repo
1029            .entry
1030            .last_pruned_at
1031            .map(|d| d.format("%Y-%m-%d").to_string())
1032            .unwrap_or_else(|| "Never".to_string());
1033
1034        // Padded *before* colouring: ANSI escapes count toward `{:<22}`-style format
1035        // widths, so colouring inside the format string would shear every column that
1036        // follows. `colored` emits nothing when stdout is a pipe, so the plain widths
1037        // survive redirection untouched.
1038        //
1039        // `Colorize::` spelled out, not `cell.green()`: this module imports
1040        // `ratatui::prelude::*`, which brings `ratatui::style::Stylize` into scope with a
1041        // `green()` of its own that takes `self` by value. It wins the method probe over
1042        // `colored`'s, which needs an autoref, so `cell.green()` silently built a ratatui
1043        // `Span` and `.to_string()` handed back the text with no escapes at all. This
1044        // table printed in plain white for every release up to 1.3.0 and nothing failed.
1045        // Three tiers, not six colours. Green marks the rows you can act on, red and
1046        // yellow the two that are actually broken, and everything that is merely normal
1047        // — in use, nothing to reclaim, deliberately ignored — is default or dim. A
1048        // colour per enum variant reads as decoration and leaves nothing louder to say
1049        // when a repository really is misconfigured.
1050        let reason_cell = format!("{reason:<22}");
1051        let reason_cell = match &repo.reason {
1052            SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1053            SkipReason::Active => reason_cell,
1054            SkipReason::Ignored | SkipReason::NoBloat => {
1055                Colorize::dimmed(reason_cell.as_str()).to_string()
1056            }
1057            SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1058            SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1059        };
1060        // Green, not bold green: this figure repeats on every row, and bolding all of
1061        // them means none of them stands out. The bold copy is the grand total below.
1062        let bloat_cell = if repo.reclaimable_bytes > 0 {
1063            Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1064        } else {
1065            format!("{bloat:<12}")
1066        };
1067        println!(
1068            "  {:>3}  {}  {}  {:<12}  {}  {:<13}  {:<13}",
1069            i + 1,
1070            output::pad_display(&path_str, 35),
1071            reason_cell,
1072            adapters,
1073            bloat_cell,
1074            activity,
1075            pruned
1076        );
1077    }
1078    println!("  {}", "─".repeat(122));
1079
1080    let (ready, total) = reclaimable_split(repos);
1081    let candidates = repos
1082        .iter()
1083        .filter(|r| matches!(r.reason, SkipReason::Candidate))
1084        .count();
1085    // The one bold figure on the screen — and it is the *actionable* one, not the grand
1086    // total. Each row's own bloat is plain green; bolding what a prune would free right
1087    // now is what makes it the line the eye lands on.
1088    output::print_info(&format!(
1089        "Total: {} repos  |  {} candidates  |  {} ready now  |  {} reclaimable in all",
1090        repos.len(),
1091        candidates,
1092        output::format_bytes_styled(ready),
1093        output::format_bytes(total)
1094    ));
1095
1096    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
1097    // the folder's apparent size — the rest is hardlinked into the manager's store.
1098    // Said once here rather than per row so the table stays scannable.
1099    let shared: u64 = repos
1100        .iter()
1101        .flat_map(|r| &r.bloat_dirs)
1102        .map(|b| b.shared_bytes)
1103        .sum();
1104    if shared > 0 {
1105        output::print_info(&format!(
1106            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1107             node_modules does not free those bytes, the store keeps them.",
1108            format_bytes(shared)
1109        ));
1110    }
1111}
1112
1113// ── Tests ─────────────────────────────────────────────────────────────────────
1114
1115#[cfg(test)]
1116mod tests {
1117    use ratatui::style::Color;
1118
1119    use crate::engine::SkipReason;
1120
1121    use super::*;
1122    use crate::engine::RepoStatusEntry;
1123
1124    #[test]
1125    fn test_row_style_logic() {
1126        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1127        assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1128        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
1129        assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1130        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1131        assert_eq!(
1132            reason_color(&SkipReason::ConfigError(String::new())),
1133            Color::Yellow
1134        );
1135    }
1136
1137    /// One entry, with only the fields the view actually orders and filters on.
1138    fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1139        RepoStatusEntry {
1140            path: std::path::PathBuf::from(path),
1141            entry: crate::config::RepoEntry::new(),
1142            reason,
1143            adapters: vec!["uv".to_string()],
1144            bloat_dirs: Vec::new(),
1145            reclaimable_bytes: bytes,
1146            reclaimable_by_adapter: Vec::new(),
1147            last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1148            idle_days: 30,
1149        }
1150    }
1151
1152    fn sample() -> Vec<RepoStatusEntry> {
1153        vec![
1154            entry("/code/alpha", SkipReason::Candidate, 500, 90),
1155            entry("/code/beta", SkipReason::Active, 9_000, 1),
1156            entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1157            entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1158        ]
1159    }
1160
1161    #[test]
1162    fn the_default_view_is_every_row_in_the_order_it_arrived() {
1163        let repos = sample();
1164        let app = StatusApp::new(&repos);
1165        assert_eq!(app.view, vec![0, 1, 2, 3]);
1166    }
1167
1168    #[test]
1169    fn sorting_by_size_puts_the_biggest_reclaim_first() {
1170        let repos = sample();
1171        let mut app = StatusApp::new(&repos);
1172        app.sort = SortKey::Size;
1173        app.rebuild_view();
1174        assert_eq!(app.view, vec![1, 3, 0, 2]);
1175    }
1176
1177    #[test]
1178    fn sorting_by_activity_puts_the_longest_untouched_first() {
1179        let repos = sample();
1180        let mut app = StatusApp::new(&repos);
1181        app.sort = SortKey::Activity;
1182        app.rebuild_view();
1183        assert_eq!(app.view, vec![2, 0, 3, 1]);
1184    }
1185
1186    #[test]
1187    fn filters_narrow_the_view_without_disturbing_the_selection() {
1188        let repos = sample();
1189        let mut app = StatusApp::new(&repos);
1190        app.mode = ViewMode::PruneSelect;
1191        app.selected[3] = true;
1192
1193        app.filter = Filter::Candidates;
1194        app.rebuild_view();
1195        assert_eq!(app.view, vec![0, 3]);
1196
1197        app.filter = Filter::Problems;
1198        app.rebuild_view();
1199        assert_eq!(app.view, vec![2]);
1200
1201        // The checked repository is filtered off screen, and stays checked: a filter is
1202        // a way to look, not a way to silently un-arm a prune.
1203        assert!(app.selected[3]);
1204        assert_eq!(app.selected_count(), 1);
1205    }
1206
1207    #[test]
1208    fn a_search_matches_the_path_and_the_adapters() {
1209        let repos = sample();
1210        let mut app = StatusApp::new(&repos);
1211
1212        app.search = "ELT".to_string();
1213        app.rebuild_view();
1214        assert_eq!(app.view, vec![3], "the match is case-insensitive");
1215
1216        app.search = "uv".to_string();
1217        app.rebuild_view();
1218        assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1219
1220        app.search = "nothing-matches-this".to_string();
1221        app.rebuild_view();
1222        assert!(app.view.is_empty());
1223        // Nothing on screen means nothing highlighted — the `i` and prune keys read the
1224        // cursor, and a cursor pointing into an empty view would index out of bounds.
1225        assert_eq!(app.cursor_repo(), None);
1226    }
1227
1228    #[test]
1229    fn the_cursor_follows_its_repository_through_a_filter() {
1230        let repos = sample();
1231        let mut app = StatusApp::new(&repos);
1232        app.table_state.select(Some(3)); // `/code/delta`
1233        assert_eq!(app.cursor_repo(), Some(3));
1234
1235        app.filter = Filter::Candidates;
1236        app.rebuild_view();
1237        // Row 1 of the two-row view — a different row number, the same repository.
1238        assert_eq!(app.cursor_repo(), Some(3));
1239    }
1240
1241    #[test]
1242    fn toggle_all_is_scoped_to_what_is_on_screen() {
1243        let repos = sample();
1244        let mut app = StatusApp::new(&repos);
1245        app.search = "alpha".to_string();
1246        app.rebuild_view();
1247        app.toggle_all_candidates();
1248
1249        assert!(app.selected[0]);
1250        assert!(
1251            !app.selected[3],
1252            "delta was filtered out and must not be armed"
1253        );
1254    }
1255
1256    #[test]
1257    fn the_header_separates_what_is_prunable_now_from_everything() {
1258        // The whole point of the split: `beta` is 9 KB of dependencies in a repository
1259        // somebody worked in yesterday. It counts towards the machine's footprint and
1260        // towards nothing a prune would do today.
1261        let repos = sample();
1262        let (ready, total) = reclaimable_split(&repos);
1263        assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1264        assert_eq!(total, 11_500, "every registered repository");
1265    }
1266
1267    #[test]
1268    fn a_filter_never_changes_the_header_totals() {
1269        // A dashboard that shrank its own totals when a filter was applied would make
1270        // the machine look tidier than it is.
1271        let repos = sample();
1272        let mut app = StatusApp::new(&repos);
1273        let before = reclaimable_split(app.repos);
1274
1275        app.filter = Filter::Problems;
1276        app.rebuild_view();
1277        assert_eq!(app.view.len(), 1);
1278        assert_eq!(reclaimable_split(app.repos), before);
1279    }
1280}