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