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::collections::HashMap;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc;
15use std::time::Duration;
16
17use anyhow::{Context, Result};
18use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
19use ratatui::prelude::*;
20use ratatui::widgets::*;
21
22use crate::constants;
23use crate::engine::{RepoStatusEntry, SkipReason};
24use crate::output::format_bytes;
25use crate::tui::Tui;
26
27/// Mode the status view is in.
28enum ViewMode {
29    /// Browsing the table — 'p' enters PruneSelect mode.
30    Browse,
31    /// User is selecting candidates to prune.
32    PruneSelect,
33}
34
35/// Which column the table is ordered by.
36///
37/// `Default` is the order [`crate::engine::get_full_status`] produced — actionable
38/// first, then everything merely present, then everything gone. It is first in the
39/// cycle and it is where the view starts, because that ordering is the one answer to
40/// "what should I look at" and no sort the user picks should be hard to get back to.
41#[derive(Clone, Copy, PartialEq, Eq)]
42enum SortKey {
43    Default,
44    /// Largest reclaim first.
45    Size,
46    /// Least recently touched first — the repositories nobody has opened in months.
47    Activity,
48    /// By path, A to Z.
49    Name,
50}
51
52impl SortKey {
53    fn next(self) -> Self {
54        match self {
55            SortKey::Default => SortKey::Size,
56            SortKey::Size => SortKey::Activity,
57            SortKey::Activity => SortKey::Name,
58            SortKey::Name => SortKey::Default,
59        }
60    }
61
62    fn label(self) -> &'static str {
63        match self {
64            SortKey::Default => "relevance",
65            SortKey::Size => "size ↓",
66            SortKey::Activity => "idle longest ↓",
67            SortKey::Name => "name ↑",
68        }
69    }
70}
71
72/// Which rows the table shows.
73#[derive(Clone, Copy, PartialEq, Eq)]
74enum Filter {
75    All,
76    /// Only what a prune would act on.
77    Candidates,
78    /// Anything holding reclaimable bytes, candidate or not — including the repositories
79    /// still active, which is the list you want before deciding to wait.
80    WithBloat,
81    /// The rows that need a decision rather than a prune: a path that is gone, a config
82    /// file that does not parse.
83    Problems,
84}
85
86impl Filter {
87    fn next(self) -> Self {
88        match self {
89            Filter::All => Filter::Candidates,
90            Filter::Candidates => Filter::WithBloat,
91            Filter::WithBloat => Filter::Problems,
92            Filter::Problems => Filter::All,
93        }
94    }
95
96    fn label(self) -> &'static str {
97        match self {
98            Filter::All => "all",
99            Filter::Candidates => "candidates",
100            Filter::WithBloat => "has bloat",
101            Filter::Problems => "problems",
102        }
103    }
104
105    fn accepts(self, repo: &RepoStatusEntry) -> bool {
106        match self {
107            Filter::All => true,
108            Filter::Candidates => matches!(repo.reason, SkipReason::Candidate),
109            Filter::WithBloat => repo.reclaimable_bytes > 0,
110            Filter::Problems => matches!(
111                repo.reason,
112                SkipReason::PathMissing | SkipReason::ConfigError(_)
113            ),
114        }
115    }
116}
117
118struct StatusApp<'a> {
119    repos: &'a [RepoStatusEntry],
120    /// Indices into `repos`, filtered and sorted — the rows actually on screen.
121    ///
122    /// The table addresses this, everything else addresses `repos`. Keeping the two
123    /// apart is what lets a filter hide a row without disturbing which repositories are
124    /// checked for pruning, and what stops the confirmed selection from meaning
125    /// something different depending on how the table happened to be sorted.
126    view: Vec<usize>,
127    table_state: TableState,
128    /// Which rows are checked for prune (indexed to `repos`).
129    selected: Vec<bool>,
130    mode: ViewMode,
131    sort: SortKey,
132    filter: Filter,
133    /// Case-insensitive substring matched against the path and the adapter names.
134    search: String,
135    /// True while keystrokes are going into `search` rather than being commands.
136    searching: bool,
137    /// If `Some`, the user confirmed and we return these indices.
138    confirmed_indices: Option<Vec<usize>>,
139    /// One line explaining the last refused keypress, shown in the prune-mode footer.
140    notice: Option<String>,
141    /// Set to true when the user toggles ignore config in .devprune.json or presence of ignore.devprune.json so caller can reload.
142    pub should_reload: bool,
143    /// On by default because the table only has room for one number per repository, and
144    /// "why is this one so big" needs the breakdown. Tab hides it for anyone who wants the
145    /// full-width table back.
146    pub show_inspector: bool,
147    /// Whether the last frame had room to draw the inspector. `show_inspector` is only
148    /// the wish; on a small terminal nobody would see what the worker collected.
149    inspector_drawn: bool,
150    /// What the inspector shows beyond the status scan, per index into `repos`. Filled
151    /// lazily by a worker thread, because a commit histogram and a walk of every bloat
152    /// directory cost far too much to collect for eighty repositories up front.
153    details: HashMap<usize, InspectorDetail>,
154    /// The repository whose details a worker is collecting now. One at a time: holding
155    /// the arrow key down the list would otherwise start a disk walk per row passed.
156    inflight: Option<usize>,
157    detail_tx: mpsc::Sender<(usize, InspectorDetail)>,
158    detail_rx: mpsc::Receiver<(usize, InspectorDetail)>,
159    /// A pending `i`: the repository and whether the keypress would ignore it (`true`) or
160    /// stop ignoring it. `i` changes what every later pass is allowed to delete, and it
161    /// sits one key away from `j` and `k`, so it waits for a `y`.
162    pending_ignore: Option<(usize, bool)>,
163}
164
165/// How many weeks of commits the inspector's histogram covers: half a year shows whether
166/// a repository was abandoned or is just quiet this month.
167const HISTOGRAM_WEEKS: usize = 26;
168
169/// How many of the largest entries inside the bloat directories the inspector lists.
170const LARGEST_ENTRIES: usize = 5;
171
172/// The inspector's figures that the status scan does not collect.
173struct InspectorDetail {
174    /// Commits per week, oldest first. `None` when git would not read the history.
175    weekly_commits: Option<Vec<u32>>,
176    /// The largest top-level entries inside the bloat directories, largest first, each
177    /// named `<bloat dir>/<entry>`.
178    largest: Vec<(String, u64)>,
179}
180
181/// Why this repository cannot be checked for pruning, or `None` if it can be.
182///
183/// Selectable is deliberately wider than `SkipReason::Candidate`. An *active*
184/// repository is one the engine would skip on the idle threshold alone, and the
185/// dashboard's own executor already runs with `force: true` — so offering it here
186/// promises nothing the prune cannot deliver. On a machine whose repositories are all
187/// worked in daily, candidate-only selection left the mode with nothing to select at
188/// all, which is indistinguishable from a dead feature.
189///
190/// Everything else stays refused because pruning it genuinely would not work:
191/// verification still applies to every row this function accepts.
192fn refusal(repo: &RepoStatusEntry) -> Option<String> {
193    match &repo.reason {
194        SkipReason::Candidate | SkipReason::Active if repo.reclaimable_bytes > 0 => None,
195        SkipReason::Candidate | SkipReason::Active | SkipReason::NoBloat => {
196            Some("nothing to reclaim here".to_string())
197        }
198        SkipReason::Ignored => Some("ignored — press [i] to un-ignore it".to_string()),
199        SkipReason::PathMissing => {
200            Some("path is missing — `devp unlink --missing` clears it".to_string())
201        }
202        SkipReason::ConfigError(e) => Some(format!("config error: {e}")),
203    }
204}
205
206impl<'a> StatusApp<'a> {
207    fn new(repos: &'a [RepoStatusEntry]) -> Self {
208        let selected = vec![false; repos.len()];
209        let mut table_state = TableState::default();
210        table_state.select(Some(0));
211        let (detail_tx, detail_rx) = mpsc::channel();
212        let mut app = Self {
213            repos,
214            view: Vec::new(),
215            table_state,
216            selected,
217            mode: ViewMode::Browse,
218            sort: SortKey::Default,
219            filter: Filter::All,
220            search: String::new(),
221            searching: false,
222            confirmed_indices: None,
223            notice: None,
224            should_reload: false,
225            show_inspector: true,
226            inspector_drawn: false,
227            details: HashMap::new(),
228            inflight: None,
229            detail_tx,
230            detail_rx,
231            pending_ignore: None,
232        };
233        app.rebuild_view();
234        app
235    }
236
237    /// Recompute the visible rows after a change to the sort, the filter or the query.
238    ///
239    /// Keeps the cursor on the same *repository* rather than the same row number. A
240    /// filter that removes four rows above the cursor would otherwise slide the
241    /// selection onto a different repository under the user's hands — and `i` and the
242    /// prune toggle both act on whatever is selected.
243    fn rebuild_view(&mut self) {
244        let anchor = self.cursor_repo();
245        let needle = self.search.to_lowercase();
246
247        let mut view: Vec<usize> = (0..self.repos.len())
248            .filter(|&i| {
249                let repo = &self.repos[i];
250                if !self.filter.accepts(repo) {
251                    return false;
252                }
253                if needle.is_empty() {
254                    return true;
255                }
256                // Adapters as well as the path, so `/uv` finds every Python project
257                // without anyone having to remember where they live.
258                repo.path.to_string_lossy().to_lowercase().contains(&needle)
259                    || repo
260                        .adapters
261                        .iter()
262                        .any(|a| a.to_lowercase().contains(&needle))
263            })
264            .collect();
265
266        match self.sort {
267            // Already in this order: `get_full_status` sorted the slice before it got
268            // here, and `view` was built by walking it forwards.
269            SortKey::Default => {}
270            SortKey::Size => view.sort_by(|&a, &b| {
271                self.repos[b]
272                    .reclaimable_bytes
273                    .cmp(&self.repos[a].reclaimable_bytes)
274                    .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
275            }),
276            // `None` is a repository whose activity could not be read at all, which is
277            // not evidence of being idle — so it sorts last rather than first.
278            SortKey::Activity => view.sort_by(|&a, &b| {
279                let key = |i: usize| {
280                    self.repos[i]
281                        .last_activity
282                        .map(|t| (0, t))
283                        .unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
284                };
285                key(a)
286                    .cmp(&key(b))
287                    .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
288            }),
289            SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
290        }
291
292        self.view = view;
293        let row = anchor
294            .and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
295            .unwrap_or(0);
296        // `then`, not `then_some`: the argument to `then_some` is evaluated whether the
297        // condition holds or not, and on an empty view that subtraction underflows.
298        self.table_state
299            .select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
300    }
301
302    /// Index into `repos` of the highlighted row, or `None` when the view is empty.
303    fn cursor_repo(&self) -> Option<usize> {
304        self.view.get(self.table_state.selected()?).copied()
305    }
306
307    fn move_up(&mut self) {
308        let i = match self.table_state.selected() {
309            Some(i) => {
310                if i == 0 {
311                    self.view.len().saturating_sub(1)
312                } else {
313                    i - 1
314                }
315            }
316            None => 0,
317        };
318        self.table_state.select(Some(i));
319    }
320
321    fn move_down(&mut self) {
322        let i = match self.table_state.selected() {
323            Some(i) => {
324                if i >= self.view.len().saturating_sub(1) {
325                    0
326                } else {
327                    i + 1
328                }
329            }
330            None => 0,
331        };
332        self.table_state.select(Some(i));
333    }
334
335    fn toggle_current(&mut self) {
336        let Some(i) = self.cursor_repo() else {
337            return;
338        };
339        match refusal(&self.repos[i]) {
340            // Silently refusing every keypress is what made this mode look broken: a
341            // machine whose repositories are all worked-in daily has no candidates, so
342            // Space did nothing on every row and there was no way to find out why.
343            Some(why) => self.notice = Some(why),
344            None => {
345                self.selected[i] = !self.selected[i];
346                self.notice = None;
347            }
348        }
349    }
350
351    /// Check, or uncheck, every candidate currently on screen.
352    ///
353    /// Deliberately scoped to the view: with a filter or a search active, "all" has to
354    /// mean the rows the user can see. Selecting thirty repositories they filtered out
355    /// on a keypress labelled *Toggle All* is how a prune becomes a surprise.
356    ///
357    /// Candidates only, even though Space can now check an active repository too.
358    /// Enter prunes immediately — there is no second confirmation — so one stray `a`
359    /// must never arm every repository that merely has something to reclaim, including
360    /// the one the user is working in right now. Actives are opted into a row at a time.
361    fn toggle_all_candidates(&mut self) {
362        let visible: Vec<usize> = self
363            .view
364            .iter()
365            .copied()
366            .filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
367            .collect();
368        let any_selected = visible.iter().any(|&i| self.selected[i]);
369        for i in visible {
370            self.selected[i] = !any_selected;
371        }
372    }
373
374    fn confirm_prune(&mut self) {
375        let indices: Vec<usize> = self
376            .selected
377            .iter()
378            .enumerate()
379            .filter(|&(_, s)| *s)
380            .map(|(i, _)| i)
381            .collect();
382        self.confirmed_indices = Some(indices);
383    }
384
385    fn selected_bytes(&self) -> u64 {
386        self.repos
387            .iter()
388            .enumerate()
389            .filter(|(i, _)| self.selected[*i])
390            .map(|(_, r)| r.reclaimable_bytes)
391            .sum()
392    }
393
394    fn selected_count(&self) -> usize {
395        self.selected.iter().filter(|&&s| s).count()
396    }
397
398    fn candidate_count(&self) -> usize {
399        self.repos
400            .iter()
401            .filter(|r| matches!(r.reason, SkipReason::Candidate))
402            .count()
403    }
404
405    /// How many rows Space would accept — the honest denominator for the footer.
406    fn selectable_count(&self) -> usize {
407        self.repos.iter().filter(|r| refusal(r).is_none()).count()
408    }
409
410    /// Store whatever the worker finished, and start one for the highlighted repository
411    /// if nothing is running and its details are not known yet. Called once per loop
412    /// turn, so the inspector fills in on the next redraw without blocking a keypress.
413    fn pump_details(&mut self) {
414        while let Ok((idx, detail)) = self.detail_rx.try_recv() {
415            self.details.insert(idx, detail);
416            if self.inflight == Some(idx) {
417                self.inflight = None;
418            }
419        }
420        if !self.inspector_drawn || self.inflight.is_some() {
421            return;
422        }
423        let Some(idx) = self.cursor_repo() else {
424            return;
425        };
426        let repo = &self.repos[idx];
427        if self.details.contains_key(&idx) || matches!(repo.reason, SkipReason::PathMissing) {
428            return;
429        }
430        let path = repo.path.clone();
431        let dirs: Vec<(String, PathBuf)> = repo
432            .bloat_dirs
433            .iter()
434            .map(|b| (b.name.clone(), b.path.clone()))
435            .collect();
436        let tx = self.detail_tx.clone();
437        self.inflight = Some(idx);
438        std::thread::spawn(move || {
439            let detail = InspectorDetail {
440                weekly_commits: weekly_commits(&path),
441                largest: largest_entries(&dirs, LARGEST_ENTRIES),
442            };
443            // The receiver is gone once the user quits or the view reloads; the
444            // answer is simply no longer wanted.
445            let _ = tx.send((idx, detail));
446        });
447    }
448}
449
450/// Commits per week over the last [`HISTOGRAM_WEEKS`] weeks, oldest first, or `None` when
451/// git will not read the repository.
452fn weekly_commits(repo: &Path) -> Option<Vec<u32>> {
453    let output = crate::scanner::git::git_in(repo)
454        .args([
455            "log",
456            &format!("--since={HISTOGRAM_WEEKS}.weeks"),
457            "--format=%ct",
458        ])
459        .output()
460        .ok()?;
461    if !output.status.success() {
462        return None;
463    }
464    let stamps: Vec<i64> = String::from_utf8_lossy(&output.stdout)
465        .lines()
466        .filter_map(|l| l.trim().parse().ok())
467        .collect();
468    Some(bucket_by_week(
469        &stamps,
470        chrono::Utc::now().timestamp(),
471        HISTOGRAM_WEEKS,
472    ))
473}
474
475/// Count `stamps` (Unix seconds) into `weeks` week-long buckets ending at `now`, oldest
476/// bucket first. Anything outside the window is dropped, including a commit dated in the
477/// future by a skewed clock.
478fn bucket_by_week(stamps: &[i64], now: i64, weeks: usize) -> Vec<u32> {
479    const WEEK: i64 = 7 * 24 * 60 * 60;
480    let mut buckets = vec![0u32; weeks];
481    for &t in stamps {
482        if t > now {
483            continue;
484        }
485        let age = ((now - t) / WEEK) as usize;
486        if age < weeks {
487            buckets[weeks - 1 - age] += 1;
488        }
489    }
490    buckets
491}
492
493/// One character per bucket, scaled to the busiest one. An empty week is a dot rather
494/// than the lowest bar, so a quiet week cannot be mistaken for a week with one commit.
495fn sparkline(counts: &[u32]) -> String {
496    const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
497    let max = counts.iter().copied().max().unwrap_or(0);
498    counts
499        .iter()
500        .map(|&c| {
501            if c == 0 {
502                '·'
503            } else {
504                let step = ((c as u64 * (BARS.len() as u64 - 1)) / max as u64) as usize;
505                BARS[step]
506            }
507        })
508        .collect()
509}
510
511/// The `n` largest top-level entries across `dirs`, largest first. Top level only: inside
512/// `node_modules` that is one line per package, which is the level a person can act on.
513/// Symlinked entries are not followed, the same as every other size in this tool.
514fn largest_entries(dirs: &[(String, PathBuf)], n: usize) -> Vec<(String, u64)> {
515    let mut entries: Vec<(String, u64)> = Vec::new();
516    for (name, dir) in dirs {
517        let Ok(read) = std::fs::read_dir(dir) else {
518            continue;
519        };
520        for entry in read.flatten() {
521            let size = match entry.file_type() {
522                Ok(t) if t.is_dir() => crate::adapters::dir_size(&entry.path()),
523                Ok(t) if t.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0),
524                _ => 0,
525            };
526            entries.push((
527                format!("{name}/{}", entry.file_name().to_string_lossy()),
528                size,
529            ));
530        }
531    }
532    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
533    entries.truncate(n);
534    entries
535}
536
537/// Split the reclaimable total into what a prune would free *now* and everything.
538///
539/// Two very different numbers that were being shown as one. The grand total counts the
540/// dependency directories in every registered repository, including the ones worked in
541/// this morning — a figure nothing is going to act on. What people are reading the
542/// dashboard for is the other one: the repositories that have gone idle long enough to
543/// be candidates, whose bytes `devp run` would reclaim on its next pass.
544///
545/// Always over the whole registry, never over the filtered view. A header that shrank
546/// when a filter was applied would make the machine look tidier than it is.
547fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
548    let ready = repos
549        .iter()
550        .filter(|r| matches!(r.reason, SkipReason::Candidate))
551        .map(|r| r.reclaimable_bytes)
552        .sum();
553    let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
554    (ready, total)
555}
556
557/// Total bytes and repository count per tool name (graphify, understand-anything),
558/// across every repository passed in, in the order the tools were first seen. Report-
559/// only figures: never added to [`reclaimable_split`], never a prune candidate.
560fn tools_summary(repos: &[RepoStatusEntry]) -> Vec<(&'static str, u64, usize)> {
561    let mut totals: Vec<(&'static str, u64, usize)> = Vec::new();
562    for repo in repos {
563        for tool in &repo.tools {
564            match totals.iter_mut().find(|(name, _, _)| *name == tool.name) {
565                Some((_, bytes, count)) => {
566                    *bytes += tool.size_bytes;
567                    *count += 1;
568                }
569                None => totals.push((tool.name, tool.size_bytes, 1)),
570            }
571        }
572    }
573    totals
574}
575
576/// Render the full interactive status view.
577///
578/// Returns `Some` with the selected repositories' paths if the user confirmed a
579/// prune, or `None` if they just quit. Paths, not indices: every `i` toggle
580/// reloads the list, and an ignored repository entering or leaving it renumbers
581/// everything — indices handed to the caller would address the list it loaded
582/// before any of that happened.
583///
584/// Re-runs automatically when the user toggles ignore config in `devprune.json` so the
585/// status reflects the change immediately.
586pub fn render_status_tui(
587    repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
588) -> Result<Option<Vec<PathBuf>>> {
589    loop {
590        let repos = repos_loader();
591
592        if repos.is_empty() {
593            return Ok(None);
594        }
595
596        let mut app = StatusApp::new(&repos);
597        {
598            // Scoped so the terminal is restored before anything below prints, and on
599            // every exit path from the loop — return, error, or panic.
600            let mut tui = Tui::new()?;
601            tui.drain_stale_input(Duration::from_millis(100));
602            run_status_loop(&mut tui.terminal, &mut app)?;
603        }
604
605        if app.should_reload {
606            // User toggled ignore — reload and re-render
607            continue;
608        }
609
610        // Resolved against `repos` — the list this iteration actually displayed —
611        // while it is still in scope, so a reload can never desynchronise them.
612        return Ok(app
613            .confirmed_indices
614            .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
615    }
616}
617
618fn run_status_loop(
619    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
620    app: &mut StatusApp,
621) -> Result<()> {
622    loop {
623        app.pump_details();
624        terminal.draw(|frame| render_ui(frame, app))?;
625
626        if event::poll(Duration::from_millis(100))?
627            && let Event::Key(key) = event::read()?
628        {
629            if key.kind == KeyEventKind::Release {
630                continue;
631            }
632            // Raw mode delivers Ctrl-C as a key event rather than a signal, so
633            // without this the one key everybody reaches for to escape does nothing.
634            if key.modifiers.contains(KeyModifiers::CONTROL)
635                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
636            {
637                return Ok(());
638            }
639            // Only `y` or Enter confirms. Every other key cancels, so a stray keypress can
640            // never be the one that changes what a pass may delete.
641            if let Some((idx, would_ignore)) = app.pending_ignore.take() {
642                if matches!(
643                    key.code,
644                    KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
645                ) {
646                    apply_ignore_toggle(&app.repos[idx], would_ignore)?;
647                    app.should_reload = true;
648                    return Ok(());
649                }
650                continue;
651            }
652            // While the query line is open every printable key is text, not a command.
653            // Checked before the command table below, because otherwise typing `q` in a
654            // search would quit and typing `p` would arm a prune.
655            if app.searching {
656                match key.code {
657                    KeyCode::Char(c) => {
658                        app.search.push(c);
659                        app.rebuild_view();
660                    }
661                    KeyCode::Backspace => {
662                        app.search.pop();
663                        app.rebuild_view();
664                    }
665                    // Enter keeps the filter and hands the keyboard back; Esc abandons
666                    // it. A query you cannot undo in one key is a query people restart
667                    // the whole command to escape.
668                    KeyCode::Enter => app.searching = false,
669                    KeyCode::Esc => {
670                        app.searching = false;
671                        app.search.clear();
672                        app.rebuild_view();
673                    }
674                    _ => {}
675                }
676                continue;
677            }
678            match key.code {
679                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
680                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
681                KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
682                KeyCode::End | KeyCode::Char('G') => {
683                    app.table_state
684                        .select(Some(app.view.len().saturating_sub(1)));
685                }
686                KeyCode::PageUp => {
687                    let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
688                    app.table_state.select(Some(i));
689                }
690                KeyCode::PageDown => {
691                    let i = (app.table_state.selected().unwrap_or(0) + 10)
692                        .min(app.view.len().saturating_sub(1));
693                    app.table_state.select(Some(i));
694                }
695                KeyCode::Char('s') | KeyCode::Char('S') => {
696                    app.sort = app.sort.next();
697                    app.rebuild_view();
698                }
699                KeyCode::Char('f') | KeyCode::Char('F') => {
700                    app.filter = app.filter.next();
701                    app.rebuild_view();
702                }
703                KeyCode::Char('/') => app.searching = true,
704                KeyCode::Char(' ') => {
705                    if matches!(app.mode, ViewMode::PruneSelect) {
706                        app.toggle_current();
707                    }
708                }
709                KeyCode::Char('a') | KeyCode::Char('A') => {
710                    if matches!(app.mode, ViewMode::PruneSelect) {
711                        app.toggle_all_candidates();
712                    }
713                }
714                KeyCode::Char('p') | KeyCode::Char('P') => {
715                    app.mode = ViewMode::PruneSelect;
716                    // Auto-select the candidates on screen — not every candidate in the
717                    // registry. Someone who filtered or searched their way down to four
718                    // repositories asked for those four.
719                    //
720                    // Candidates only: an active repository is selectable but never
721                    // pre-checked, because entering a mode must not arm a repository
722                    // the user is still working in.
723                    let mut armed = 0;
724                    for i in app.view.clone() {
725                        if matches!(app.repos[i].reason, SkipReason::Candidate) {
726                            app.selected[i] = true;
727                            armed += 1;
728                        }
729                    }
730                    // Arming nothing used to be silent, and looked exactly like a mode
731                    // that had failed to open. Say which it is.
732                    app.notice = (armed == 0).then(|| {
733                        if app.view.iter().any(|&i| refusal(&app.repos[i]).is_none()) {
734                            "No repository is past the idle threshold — press [Space] \
735                             to select one you are still working in."
736                                .to_string()
737                        } else {
738                            "Nothing here can be pruned — press [f] to widen the filter."
739                                .to_string()
740                        }
741                    });
742                }
743                KeyCode::Char('i') | KeyCode::Char('I') => {
744                    // Asks first; the write happens on `y`, in the branch above.
745                    if let Some(idx) = app.cursor_repo()
746                        && let Some(would_ignore) = ignore_toggle_for(&app.repos[idx])?
747                    {
748                        app.pending_ignore = Some((idx, would_ignore));
749                    }
750                }
751                KeyCode::Enter => {
752                    if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
753                        app.confirm_prune();
754                        return Ok(());
755                    }
756                }
757                KeyCode::Esc => {
758                    if matches!(app.mode, ViewMode::PruneSelect) {
759                        // Exit prune-select mode, go back to browse
760                        app.mode = ViewMode::Browse;
761                        app.selected.fill(false);
762                        app.notice = None;
763                    } else {
764                        return Ok(());
765                    }
766                }
767                KeyCode::Tab => {
768                    app.show_inspector = !app.show_inspector;
769                }
770                KeyCode::Char('q') => return Ok(()),
771                _ => {}
772            }
773        }
774    }
775}
776
777/// What `i` would do to `repo`: `Some(true)` to ignore it, `Some(false)` to stop ignoring
778/// it, or `None` when the key has nothing it can honestly change.
779fn ignore_toggle_for(repo: &RepoStatusEntry) -> Result<Option<bool>> {
780    // A missing repository has no directory to write the config into; propagating that
781    // write error would tear the whole TUI down over a row that says "Path Missing"
782    // right on it.
783    if matches!(repo.reason, SkipReason::PathMissing) {
784        return Ok(None);
785    }
786    let (layers, per_repo) = load_personal_config(repo)?;
787    // A committed `project.devprune.json` that names `ignore` wins over anything this key
788    // could write, so writing it anyway would leave a personal file disagreeing with the
789    // row it did not change. The help pane says which file to edit instead.
790    if layers.source_of("ignore") == crate::config::ConfigSource::Project {
791        return Ok(None);
792    }
793    // The legacy marker file still counts as "ignored". Reading only the config key made
794    // `i` on such a row write `ignore: true` and delete the marker, leaving it ignored.
795    let legacy = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
796    Ok(Some(!(per_repo.ignore || legacy.exists())))
797}
798
799/// Write the answer to a confirmed `i`.
800fn apply_ignore_toggle(repo: &RepoStatusEntry, ignore: bool) -> Result<()> {
801    let (_, mut per_repo) = load_personal_config(repo)?;
802    per_repo.ignore = ignore;
803    // Both writes are propagated rather than swallowed. A silent failure here redraws the
804    // table unchanged, which reads as a dead key; worse, a repository the user just
805    // un-ignored would still be pruned on the next pass.
806    per_repo.save_to_repo(&repo.path).with_context(|| {
807        format!(
808            "Could not write the config for {}",
809            crate::output::clean_path(&repo.path)
810        )
811    })?;
812    // The marker goes either way, so the config key is the one place the answer lives.
813    let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
814    if legacy_ignore.exists() {
815        std::fs::remove_file(&legacy_ignore).with_context(|| {
816            format!(
817                "Could not remove {}",
818                crate::output::clean_path(&legacy_ignore)
819            )
820        })?;
821    }
822    Ok(())
823}
824
825/// The repository's config layers and its personal layer, defaults when it has none.
826///
827/// Refuses a config that does not parse. Starting from the defaults would write a fresh
828/// file over the broken one, discarding every other override it held, and the dashboard
829/// already shows such a repository as `config_error`.
830fn load_personal_config(
831    repo: &RepoStatusEntry,
832) -> Result<(
833    crate::config::RepoConfigLayers,
834    crate::config::PerRepoConfig,
835)> {
836    let layers = crate::config::RepoConfigLayers::load(&repo.path)
837        .map_err(|e| anyhow::anyhow!(e))
838        .with_context(|| {
839            format!(
840                "Could not toggle ignore for {}",
841                crate::output::clean_path(&repo.path)
842            )
843        })?;
844    let per_repo = layers.personal_config().cloned().unwrap_or_default();
845    Ok((layers, per_repo))
846}
847
848/// Three tiers, not six colours: green is actionable, red and yellow are broken, and
849/// everything that is merely normal is the terminal's own colour or grey. A distinct hue
850/// per variant looks like a legend the reader has to learn, and it spends the loud
851/// colours on rows that need nothing.
852fn reason_color(reason: &SkipReason) -> Color {
853    match reason {
854        SkipReason::Candidate => Color::Green,
855        SkipReason::Active => Color::Reset,
856        SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
857        SkipReason::PathMissing => Color::Red,
858        // Actionable rather than broken: the repo is fine, its config file is not.
859        SkipReason::ConfigError(_) => Color::Yellow,
860    }
861}
862
863fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
864    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
865
866    let outer = Layout::default()
867        .direction(Direction::Vertical)
868        .constraints([
869            Constraint::Length(3), // header
870            Constraint::Min(5),    // table
871            // Five content lines plus the border: the sort/filter/search line, the
872            // keybindings, the mode-specific line, and the credit line.
873            Constraint::Length(7), // footer
874        ])
875        .split(frame.area());
876
877    // ── Header ───────────────────────────────────────────────────────────────
878    let mode_label = if is_prune_mode {
879        Span::styled(
880            " PRUNE-SELECT MODE ",
881            Style::default()
882                .bg(Color::Yellow)
883                .fg(Color::Black)
884                .add_modifier(Modifier::BOLD),
885        )
886    } else {
887        Span::styled(
888            " BROWSE MODE ",
889            Style::default()
890                .bg(Color::Cyan)
891                .fg(Color::Black)
892                .add_modifier(Modifier::BOLD),
893        )
894    };
895
896    let (ready, total) = reclaimable_split(app.repos);
897    let tools = tools_summary(app.repos);
898    let mut header_spans = vec![
899        Span::styled(
900            " dev-prune ",
901            Style::default()
902                .fg(Color::Black)
903                .bg(Color::Green)
904                .add_modifier(Modifier::BOLD),
905        ),
906        Span::raw(" "),
907        mode_label,
908        Span::styled(
909            format!(
910                "  {}  |  {} candidates  |  ",
911                // "showing 4 of 80" only when they differ — on an unfiltered view the
912                // qualifier is noise, and it is the filtered view that needs to say
913                // plainly that the numbers beside it are not the whole machine.
914                if app.view.len() == app.repos.len() {
915                    format!("{} repos", app.repos.len())
916                } else {
917                    format!("showing {} of {} repos", app.view.len(), app.repos.len())
918                },
919                app.candidate_count(),
920            ),
921            Style::default().fg(Color::DarkGray),
922        ),
923        // The actionable figure, and the only coloured one in the header: this is what
924        // pressing `p` right now would free.
925        Span::styled(
926            format!("{} ready now", format_bytes(ready)),
927            Style::default()
928                .fg(Color::Green)
929                .add_modifier(Modifier::BOLD),
930        ),
931        Span::styled(
932            format!("  |  {} reclaimable in all", format_bytes(total)),
933            Style::default().fg(Color::DarkGray),
934        ),
935    ];
936    // Report-only: never folded into `ready`/`total` above, and omitted entirely when
937    // no repository has a graphify or understand-anything output directory.
938    if !tools.is_empty() {
939        let summary = tools
940            .iter()
941            .map(|(name, bytes, count)| {
942                format!(
943                    "{} {} in {} repo{}",
944                    name,
945                    format_bytes(*bytes),
946                    count,
947                    if *count == 1 { "" } else { "s" }
948                )
949            })
950            .collect::<Vec<_>>()
951            .join(", ");
952        header_spans.push(Span::styled(
953            format!("  |  Tools: {summary}"),
954            Style::default().fg(Color::DarkGray),
955        ));
956    }
957    let header_line = Line::from(header_spans);
958
959    let header_widget =
960        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
961            Style::default().fg(if is_prune_mode {
962                Color::Yellow
963            } else {
964                Color::Cyan
965            }),
966        ));
967    frame.render_widget(header_widget, outer[0]);
968
969    // ── Table ─────────────────────────────────────────────────────────────────
970    let col_headers = Row::new(vec![
971        Cell::from(if is_prune_mode { "Sel" } else { "#" })
972            .style(Style::default().add_modifier(Modifier::BOLD)),
973        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
974        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
975        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
976        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
977        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
978        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
979    ])
980    .height(1)
981    .bottom_margin(1)
982    // The bar's background is a fixed dark navy, so the foreground must be fixed
983    // too: on a light-theme terminal the default foreground is near-black and
984    // vanishes into it.
985    .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
986
987    // Rows sitting on one of the fixed dark backgrounds (selection green, highlight
988    // blue) need explicitly light text; everywhere else the terminal's own default
989    // foreground is the only colour guaranteed readable on both light and dark themes.
990    let highlighted_row = app.table_state.selected();
991
992    // Once per frame, not once per row — display names are relative to each other,
993    // so the row closure below needs the full list, but rebuilding it n times made
994    // every redraw O(n²) in path clones.
995    let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
996
997    let rows: Vec<Row> = app
998        .view
999        .iter()
1000        .enumerate()
1001        .map(|(row, &i)| {
1002            let repo = &app.repos[i];
1003            let is_selected = app.selected[i];
1004            let color = reason_color(&repo.reason);
1005
1006            let sel_cell = if is_prune_mode {
1007                if matches!(repo.reason, SkipReason::Candidate) {
1008                    if is_selected {
1009                        Cell::from("[x]").style(
1010                            Style::default()
1011                                .fg(Color::Green)
1012                                .add_modifier(Modifier::BOLD),
1013                        )
1014                    } else {
1015                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
1016                    }
1017                } else {
1018                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
1019                }
1020            } else {
1021                Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
1022            };
1023
1024            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1025
1026            let reason_str = repo.reason.to_string();
1027            let adapters_str = if repo.adapters.is_empty() {
1028                "—".to_string()
1029            } else {
1030                repo.adapters.join(", ")
1031            };
1032            let bloat_str = if repo.reclaimable_bytes > 0 {
1033                format_bytes(repo.reclaimable_bytes)
1034            } else {
1035                "—".to_string()
1036            };
1037            // Green means "you can have these bytes back" everywhere in this tool, so a
1038            // dash — nothing to reclaim — must not borrow the same colour.
1039            let bloat_color = if repo.reclaimable_bytes > 0 {
1040                Color::Green
1041            } else {
1042                Color::DarkGray
1043            };
1044            let activity_str = repo
1045                .last_activity
1046                .map(|d| d.format("%Y-%m-%d").to_string())
1047                .unwrap_or_else(|| "—".to_string());
1048            let pruned_str = repo
1049                .entry
1050                .last_pruned_at
1051                .map(|d| d.format("%Y-%m-%d").to_string())
1052                .unwrap_or_else(|| "Never".to_string());
1053
1054            let row_style = if is_selected {
1055                Style::default().bg(Color::Rgb(20, 50, 30))
1056            } else {
1057                Style::default()
1058            };
1059
1060            // `row`, not `i`: the highlight is a position in the view, and comparing it to
1061            // a repository index lit the wrong row whenever a sort or filter was active.
1062            let on_dark_bg = is_selected || highlighted_row == Some(row);
1063            let path_style = if on_dark_bg {
1064                Style::default().fg(Color::White)
1065            } else {
1066                Style::default()
1067            };
1068            let date_color = if on_dark_bg {
1069                Color::Gray
1070            } else {
1071                Color::DarkGray
1072            };
1073
1074            Row::new(vec![
1075                sel_cell,
1076                Cell::from(path_str).style(path_style),
1077                Cell::from(reason_str).style(Style::default().fg(color)),
1078                Cell::from(adapters_str),
1079                Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
1080                Cell::from(activity_str).style(Style::default().fg(date_color)),
1081                Cell::from(pruned_str).style(Style::default().fg(date_color)),
1082            ])
1083            .style(row_style)
1084        })
1085        .collect();
1086
1087    // Beside the table when there is room for both, below it on a narrow but tall
1088    // terminal, and not at all when neither fits. Hiding it outright below 110 columns
1089    // meant a split pane or a laptop screen never saw it, with nothing saying why.
1090    let (table_area, inspector_area, compact) = if !app.show_inspector {
1091        (outer[1], None, false)
1092    } else if frame.area().width >= 110 {
1093        let horizontal = Layout::default()
1094            .direction(Direction::Horizontal)
1095            .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1096            .split(outer[1]);
1097        (horizontal[0], Some(horizontal[1]), true)
1098    } else if outer[1].height >= 24 {
1099        let vertical = Layout::default()
1100            .direction(Direction::Vertical)
1101            .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
1102            .split(outer[1]);
1103        (vertical[0], Some(vertical[1]), false)
1104    } else {
1105        (outer[1], None, false)
1106    };
1107    app.inspector_drawn = inspector_area.is_some();
1108
1109    let table_constraints = if compact {
1110        vec![
1111            Constraint::Length(4),  // sel/#
1112            Constraint::Min(16),    // path
1113            Constraint::Length(18), // reason
1114            Constraint::Length(12), // adapters
1115            Constraint::Length(10), // bloat
1116            Constraint::Length(0),  // last activity
1117            Constraint::Length(0),  // last pruned
1118        ]
1119    } else {
1120        vec![
1121            Constraint::Length(4),  // sel/#
1122            Constraint::Min(24),    // path
1123            Constraint::Length(22), // reason
1124            Constraint::Length(16), // adapters
1125            Constraint::Length(11), // bloat
1126            Constraint::Length(13), // last activity
1127            Constraint::Length(13), // last pruned
1128        ]
1129    };
1130
1131    let table = Table::new(rows, table_constraints)
1132        .header(col_headers)
1133        .block(
1134            Block::default()
1135                .title(" Registered Repositories ")
1136                .borders(Borders::ALL)
1137                .border_style(Style::default().fg(Color::DarkGray)),
1138        )
1139        .row_highlight_style(
1140            Style::default()
1141                .bg(Color::Rgb(30, 40, 70))
1142                .add_modifier(Modifier::BOLD),
1143        )
1144        .highlight_symbol("▶ ");
1145
1146    // The real state, not a clone: ratatui stores the computed scroll offset back into
1147    // it, and rendering into a throwaway copy pins the viewport to the top — the
1148    // moment the selection moved below the visible rows it simply left the screen.
1149    frame.render_stateful_widget(table, table_area, &mut app.table_state);
1150
1151    if let Some(insp_area) = inspector_area {
1152        render_inspector(frame, insp_area, app);
1153    }
1154
1155    // ── Footer ────────────────────────────────────────────────────────────────
1156    let mut footer_lines = if is_prune_mode {
1157        vec![
1158            Line::from(vec![
1159                Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
1160                // Bold carries the count; green carries the bytes, the same as it does
1161                // in every other view. The whole line used to be yellow, which is the
1162                // colour this screen already uses for "you are in the destructive mode"
1163                // — saying it twice made neither reading land.
1164                Span::styled(
1165                    // "of N candidates" undercounted the moment Space started accepting
1166                    // active repositories, and on a machine with no idle repository at
1167                    // all it read "0 of 0" while rows were plainly selectable.
1168                    format!(
1169                        "{} of {} selectable  ",
1170                        app.selected_count(),
1171                        app.selectable_count()
1172                    ),
1173                    Style::default().add_modifier(Modifier::BOLD),
1174                ),
1175                Span::styled(
1176                    format!("({})", format_bytes(app.selected_bytes())),
1177                    Style::default()
1178                        .fg(Color::Green)
1179                        .add_modifier(Modifier::BOLD),
1180                ),
1181            ]),
1182            Line::from(vec![
1183                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
1184                Span::raw(" Navigate  "),
1185                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
1186                Span::raw(" Jump  "),
1187                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
1188                Span::raw(" Toggle  "),
1189                Span::styled("[a]", Style::default().fg(Color::Cyan)),
1190                Span::raw(" Toggle All  "),
1191                Span::styled(
1192                    "[Enter]",
1193                    Style::default()
1194                        .fg(Color::Green)
1195                        .add_modifier(Modifier::BOLD),
1196                ),
1197                Span::raw(" Prune Selected  "),
1198                Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
1199                Span::raw(" Back to Browse  "),
1200                Span::styled("[q]", Style::default().fg(Color::Cyan)),
1201                Span::raw(" Quit"),
1202            ]),
1203            // Reuses the blank line the footer already reserved, so the notice costs no
1204            // height against the fixed footer constraint.
1205            Line::from(match &app.notice {
1206                Some(msg) => vec![Span::styled(
1207                    format!("⚠ {msg}"),
1208                    Style::default().fg(Color::Yellow),
1209                )],
1210                None => vec![],
1211            }),
1212        ]
1213    } else {
1214        vec![
1215            Line::from(vec![
1216                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
1217                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
1218                Span::raw("  "),
1219                Span::styled("■ Active", Style::default().fg(Color::Reset)),
1220                Span::raw("  "),
1221                // One swatch, because these two share a colour: neither needs anything
1222                // from you, and a legend with two identical squares is worse than one.
1223                Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
1224                Span::raw("  "),
1225                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
1226            ]),
1227            Line::from(vec![
1228                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
1229                Span::raw(" Navigate  "),
1230                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
1231                Span::raw(" Jump  "),
1232                // Every key is cyan; the two that change what the view does are also
1233                // bold. They used to be yellow and magenta, which said "warning" and
1234                // "nothing" respectively, and red is needed for real failures rather
1235                // than for the key that closes a screen.
1236                Span::styled(
1237                    "[p]",
1238                    Style::default()
1239                        .fg(Color::Cyan)
1240                        .add_modifier(Modifier::BOLD),
1241                ),
1242                Span::raw(" Prune-Select Mode  "),
1243                Span::styled(
1244                    "[Tab]",
1245                    Style::default()
1246                        .fg(Color::Cyan)
1247                        .add_modifier(Modifier::BOLD),
1248                ),
1249                Span::raw(" Inspector  "),
1250                Span::styled(
1251                    "[i]",
1252                    Style::default()
1253                        .fg(Color::Cyan)
1254                        .add_modifier(Modifier::BOLD),
1255                ),
1256                Span::raw(" Toggle Ignore  "),
1257                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
1258                Span::raw(" Quit"),
1259            ]),
1260            Line::from(vec![
1261                Span::styled(
1262                    "[i] ",
1263                    Style::default()
1264                        .fg(Color::Cyan)
1265                        .add_modifier(Modifier::BOLD),
1266                ),
1267                Span::styled(
1268                    "asks, then toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`). Inert where a committed `project.devprune.json` sets `ignore`; edit that file instead.",
1269                    Style::default().fg(Color::DarkGray),
1270                ),
1271            ]),
1272        ]
1273    };
1274
1275    // The state of the view, on both footers. Without it a filtered or searched table is
1276    // indistinguishable from a machine that simply has fewer repositories on it — and
1277    // the count in the header would then read as a fact about the machine.
1278    let mut state_line = vec![
1279        Span::styled(
1280            "[s]",
1281            Style::default()
1282                .fg(Color::Cyan)
1283                .add_modifier(Modifier::BOLD),
1284        ),
1285        Span::raw(" Sort: "),
1286        Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
1287        Span::raw("   "),
1288        Span::styled(
1289            "[f]",
1290            Style::default()
1291                .fg(Color::Cyan)
1292                .add_modifier(Modifier::BOLD),
1293        ),
1294        Span::raw(" Filter: "),
1295        Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
1296        Span::raw("   "),
1297        Span::styled(
1298            "[/]",
1299            Style::default()
1300                .fg(Color::Cyan)
1301                .add_modifier(Modifier::BOLD),
1302        ),
1303        Span::raw(" Search: "),
1304    ];
1305    if app.searching {
1306        // A block for the caret, because raw mode gives the terminal's own cursor
1307        // nowhere useful to sit — the table has it.
1308        state_line.push(Span::styled(
1309            format!("{}█", app.search),
1310            Style::default()
1311                .fg(Color::Yellow)
1312                .add_modifier(Modifier::BOLD),
1313        ));
1314        state_line.push(Span::styled(
1315            "  (Enter to keep, Esc to clear)",
1316            Style::default().fg(Color::DarkGray),
1317        ));
1318    } else if app.search.is_empty() {
1319        state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
1320    } else {
1321        state_line.push(Span::styled(
1322            app.search.clone(),
1323            Style::default().fg(Color::Yellow),
1324        ));
1325    }
1326    if app.view.is_empty() {
1327        // The one state where the table itself says nothing at all. Silence here reads
1328        // as a crash, so the footer has to explain what hid the rows.
1329        state_line.push(Span::styled(
1330            "   no repositories match",
1331            Style::default().fg(Color::Red),
1332        ));
1333    }
1334    footer_lines.push(Line::from(state_line));
1335
1336    // The credit, on both footers, in the dimmest colour the theme has. It is one
1337    // constant and one push — a fork that does not want it deletes these two lines and
1338    // nothing else in the binary cares.
1339    footer_lines.push(Line::from(Span::styled(
1340        constants::ATTRIBUTION_LINE,
1341        Style::default().fg(Color::DarkGray),
1342    )));
1343
1344    let footer =
1345        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
1346            Style::default().fg(if is_prune_mode {
1347                Color::Yellow
1348            } else {
1349                Color::Green
1350            }),
1351        ));
1352    frame.render_widget(footer, outer[2]);
1353
1354    if let Some((idx, would_ignore)) = app.pending_ignore {
1355        let repo = &app.repos[idx];
1356        let name = crate::engine::compute_display_name(&repo.path, &all_paths);
1357        let (title, question) = if would_ignore {
1358            (
1359                " Ignore repository ",
1360                format!("Ignore {name}? dev-prune leaves it alone until you un-ignore it."),
1361            )
1362        } else {
1363            (
1364                " Stop ignoring ",
1365                format!(
1366                    "Stop ignoring {name}? It can be pruned again once it has been idle {} days.",
1367                    repo.idle_days
1368                ),
1369            )
1370        };
1371        let text = vec![
1372            Line::raw(question),
1373            Line::raw(""),
1374            Line::from(vec![
1375                Span::styled(
1376                    "[y]",
1377                    Style::default()
1378                        .fg(Color::Cyan)
1379                        .add_modifier(Modifier::BOLD),
1380                ),
1381                Span::raw(if would_ignore {
1382                    " Ignore  "
1383                } else {
1384                    " Stop ignoring  "
1385                }),
1386                Span::styled("[n/Esc]", Style::default().fg(Color::Cyan)),
1387                Span::raw(" Cancel"),
1388            ]),
1389        ];
1390        let area = centered(frame.area(), 64, 7);
1391        frame.render_widget(Clear, area);
1392        frame.render_widget(
1393            Paragraph::new(text).wrap(Wrap { trim: false }).block(
1394                Block::default()
1395                    .title(title)
1396                    .borders(Borders::ALL)
1397                    .border_style(Style::default().fg(Color::Yellow)),
1398            ),
1399            area,
1400        );
1401    }
1402}
1403
1404/// A `width` by `height` rectangle in the middle of `area`, shrunk to fit it.
1405fn centered(area: Rect, width: u16, height: u16) -> Rect {
1406    let w = width.min(area.width);
1407    let h = height.min(area.height);
1408    Rect {
1409        x: area.x + (area.width - w) / 2,
1410        y: area.y + (area.height - h) / 2,
1411        width: w,
1412        height: h,
1413    }
1414}
1415
1416/// The highlighted repository in full: what the table's one number per row is made of,
1417/// and the history that says whether the repository is abandoned or only quiet. Those
1418/// two questions decide whether to prune, and neither fits in a table column.
1419fn render_inspector(frame: &mut Frame, area: Rect, app: &StatusApp) {
1420    let highlighted_row = app.table_state.selected();
1421    let selected_repo = highlighted_row
1422        .and_then(|row| app.view.get(row))
1423        .map(|&idx| (idx, &app.repos[idx]));
1424
1425    let Some((idx, repo)) = selected_repo else {
1426        let empty = Paragraph::new("  No repository selected.").block(
1427            Block::default()
1428                .title(" Repository Inspector [Tab] ")
1429                .borders(Borders::ALL)
1430                .border_style(Style::default().fg(Color::DarkGray)),
1431        );
1432        frame.render_widget(empty, area);
1433        return;
1434    };
1435    // `None` until the worker reports back; a missing path never gets a worker at all.
1436    let detail = app.details.get(&idx);
1437    let pending = detail.is_none() && !matches!(repo.reason, SkipReason::PathMissing);
1438
1439    let mut lines = Vec::new();
1440
1441    // ── Repository Header ──────────────────────────────────────────────
1442    lines.push(Line::from(vec![
1443        Span::styled(" Path: ", Style::default().fg(Color::DarkGray)),
1444        Span::styled(
1445            crate::output::clean_path(&repo.path),
1446            Style::default()
1447                .fg(Color::White)
1448                .add_modifier(Modifier::BOLD),
1449        ),
1450    ]));
1451
1452    if let Some(ref id) = repo.entry.identity {
1453        let short = if id.len() > 10 {
1454            &id[..10]
1455        } else {
1456            id.as_str()
1457        };
1458        lines.push(Line::from(vec![
1459            Span::styled(" Git Identity: ", Style::default().fg(Color::DarkGray)),
1460            Span::styled(short, Style::default().fg(Color::Cyan)),
1461        ]));
1462    }
1463
1464    lines.push(Line::raw(""));
1465
1466    // ── Status & Reason ────────────────────────────────────────────────
1467    let color = reason_color(&repo.reason);
1468    lines.push(Line::from(vec![
1469        Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
1470        Span::styled(
1471            repo.reason.to_string(),
1472            Style::default().fg(color).add_modifier(Modifier::BOLD),
1473        ),
1474    ]));
1475
1476    lines.push(Line::from(vec![
1477        Span::styled(" Idle Threshold: ", Style::default().fg(Color::DarkGray)),
1478        Span::styled(
1479            format!("{} days", repo.idle_days),
1480            Style::default().fg(Color::White),
1481        ),
1482    ]));
1483
1484    let activity_str = repo
1485        .last_activity
1486        .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
1487        .unwrap_or_else(|| "Never".to_string());
1488    lines.push(Line::from(vec![
1489        Span::styled(" Last Activity: ", Style::default().fg(Color::DarkGray)),
1490        Span::styled(activity_str, Style::default().fg(Color::White)),
1491    ]));
1492
1493    let pruned_str = repo
1494        .entry
1495        .last_pruned_at
1496        .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
1497        .unwrap_or_else(|| "Never".to_string());
1498    lines.push(Line::from(vec![
1499        Span::styled(" Last Pruned: ", Style::default().fg(Color::DarkGray)),
1500        Span::styled(pruned_str, Style::default().fg(Color::White)),
1501    ]));
1502
1503    if repo.entry.total_freed_bytes > 0 {
1504        lines.push(Line::from(vec![
1505            Span::styled(" Lifetime Saved: ", Style::default().fg(Color::DarkGray)),
1506            Span::styled(
1507                format_bytes(repo.entry.total_freed_bytes),
1508                Style::default()
1509                    .fg(Color::Green)
1510                    .add_modifier(Modifier::BOLD),
1511            ),
1512        ]));
1513    }
1514
1515    // Last Activity is one date; this shows whether that date was the tail of steady work
1516    // or a single commit in an otherwise dead repository.
1517    let commits: Vec<Span> = match detail.map(|d| &d.weekly_commits) {
1518        Some(Some(weeks)) => vec![
1519            Span::styled(sparkline(weeks), Style::default().fg(Color::Cyan)),
1520            Span::styled(
1521                format!("  {} commits", weeks.iter().sum::<u32>()),
1522                Style::default().fg(Color::DarkGray),
1523            ),
1524        ],
1525        Some(None) => vec![Span::styled(
1526            "git would not read this history",
1527            Style::default().fg(Color::DarkGray),
1528        )],
1529        None if pending => vec![Span::styled(
1530            "reading git log…",
1531            Style::default().fg(Color::DarkGray),
1532        )],
1533        None => vec![Span::styled("—", Style::default().fg(Color::DarkGray))],
1534    };
1535    lines.push(Line::styled(
1536        format!(" Commits, last {HISTOGRAM_WEEKS} weeks:"),
1537        Style::default().fg(Color::DarkGray),
1538    ));
1539    let mut commit_line = vec![Span::raw("   ")];
1540    commit_line.extend(commits);
1541    lines.push(Line::from(commit_line));
1542
1543    lines.push(Line::raw(""));
1544
1545    // ── Detected Adapters ──────────────────────────────────────────────
1546    let adapters_str = if repo.adapters.is_empty() {
1547        "—".to_string()
1548    } else {
1549        repo.adapters.join(", ")
1550    };
1551    lines.push(Line::from(vec![
1552        Span::styled(" Adapters: ", Style::default().fg(Color::DarkGray)),
1553        Span::styled(adapters_str, Style::default().fg(Color::Yellow)),
1554    ]));
1555
1556    lines.push(Line::raw(""));
1557
1558    // ── Bloat Breakdown ────────────────────────────────────────────────
1559    lines.push(Line::from(vec![Span::styled(
1560        " Bloat Breakdown: ",
1561        Style::default()
1562            .fg(Color::White)
1563            .add_modifier(Modifier::UNDERLINED),
1564    )]));
1565
1566    if repo.bloat_dirs.is_empty() {
1567        lines.push(Line::styled(
1568            "   (no bloat directories found)",
1569            Style::default().fg(Color::DarkGray),
1570        ));
1571    } else {
1572        for b in &repo.bloat_dirs {
1573            let shared_note = if b.shared_bytes > 0 {
1574                format!(" ({} store-shared)", format_bytes(b.shared_bytes))
1575            } else {
1576                String::new()
1577            };
1578            lines.push(Line::from(vec![
1579                Span::styled(
1580                    format!("   • {:<16} ", b.name),
1581                    Style::default().fg(Color::White),
1582                ),
1583                Span::styled(
1584                    format_bytes(b.size_bytes),
1585                    Style::default()
1586                        .fg(Color::Green)
1587                        .add_modifier(Modifier::BOLD),
1588                ),
1589                Span::styled(shared_note, Style::default().fg(Color::DarkGray)),
1590            ]));
1591        }
1592
1593        // One directory's total says how much; this says which package is to blame,
1594        // which is what decides between pruning and fixing a dependency.
1595        lines.push(Line::styled(
1596            " Largest inside:",
1597            Style::default().fg(Color::DarkGray),
1598        ));
1599        match detail {
1600            Some(d) if !d.largest.is_empty() => {
1601                for (name, size) in &d.largest {
1602                    lines.push(Line::from(vec![
1603                        Span::raw(format!("   {name}  ")),
1604                        Span::styled(format_bytes(*size), Style::default().fg(Color::Green)),
1605                    ]));
1606                }
1607            }
1608            Some(_) => lines.push(Line::styled(
1609                "   (empty)",
1610                Style::default().fg(Color::DarkGray),
1611            )),
1612            None => lines.push(Line::styled(
1613                "   measuring…",
1614                Style::default().fg(Color::DarkGray),
1615            )),
1616        }
1617    }
1618
1619    if !repo.tools.is_empty() {
1620        lines.push(Line::raw(""));
1621        lines.push(Line::from(vec![Span::styled(
1622            " Tool Footprints: ",
1623            Style::default()
1624                .fg(Color::Magenta)
1625                .add_modifier(Modifier::UNDERLINED),
1626        )]));
1627        for t in &repo.tools {
1628            lines.push(Line::from(vec![
1629                Span::styled(
1630                    format!("   • {:<16} ", t.name),
1631                    Style::default().fg(Color::White),
1632                ),
1633                Span::styled(
1634                    format_bytes(t.size_bytes),
1635                    Style::default().fg(Color::Magenta),
1636                ),
1637            ]));
1638        }
1639    }
1640
1641    let block = Block::default()
1642        .title(" Repository Inspector [Tab] ")
1643        .borders(Borders::ALL)
1644        .border_style(Style::default().fg(Color::Cyan));
1645
1646    // Wrapped, because below the table the inspector gets the terminal's full width but
1647    // beside it only 40%, and a long path or package name was simply cut off.
1648    let paragraph = Paragraph::new(lines)
1649        .wrap(Wrap { trim: false })
1650        .block(block);
1651    frame.render_widget(paragraph, area);
1652}
1653
1654// ── Plain-text fallback ──────────────────────────────────────────────────────
1655
1656/// Plain text fallback rendering for non-TUI environments.
1657pub fn render_status_plain(repos: &[RepoStatusEntry]) {
1658    use crate::output;
1659    use colored::Colorize;
1660
1661    output::print_header("dev-prune status");
1662    // The repository column is padded in terminal columns, not `char`s: it is the only
1663    // column whose contents a user names, so it is the only one that can hold wide
1664    // characters. The rule spans the whole row — 3+35+22+12+12+13+13 plus six 2-space
1665    // gaps — which is 122, not the 118 it used to draw.
1666    println!(
1667        "\n  {:>3}  {}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
1668        "#",
1669        output::pad_display("Repository", 35),
1670        "Status / Reason",
1671        "Adapters",
1672        "Bloat",
1673        "Last Activity",
1674        "Last Pruned"
1675    );
1676    println!("  {}", "─".repeat(122));
1677
1678    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1679    for (i, repo) in repos.iter().enumerate() {
1680        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1681        let reason = repo.reason.to_string();
1682        let adapters = if repo.adapters.is_empty() {
1683            "—".to_string()
1684        } else {
1685            repo.adapters.join("+")
1686        };
1687        let bloat = if repo.reclaimable_bytes > 0 {
1688            format_bytes(repo.reclaimable_bytes)
1689        } else {
1690            "—".to_string()
1691        };
1692        let activity = repo
1693            .last_activity
1694            .map(|d| d.format("%Y-%m-%d").to_string())
1695            .unwrap_or_else(|| "—".to_string());
1696        let pruned = repo
1697            .entry
1698            .last_pruned_at
1699            .map(|d| d.format("%Y-%m-%d").to_string())
1700            .unwrap_or_else(|| "Never".to_string());
1701
1702        // Padded *before* colouring: ANSI escapes count toward `{:<22}`-style format
1703        // widths, so colouring inside the format string would shear every column that
1704        // follows. `colored` emits nothing when stdout is a pipe, so the plain widths
1705        // survive redirection untouched.
1706        //
1707        // `Colorize::` spelled out, not `cell.green()`: this module imports
1708        // `ratatui::prelude::*`, which brings `ratatui::style::Stylize` into scope with a
1709        // `green()` of its own that takes `self` by value. It wins the method probe over
1710        // `colored`'s, which needs an autoref, so `cell.green()` silently built a ratatui
1711        // `Span` and `.to_string()` handed back the text with no escapes at all. This
1712        // table printed in plain white for every release up to 1.3.0 and nothing failed.
1713        // Three tiers, not six colours. Green marks the rows you can act on, red and
1714        // yellow the two that are actually broken, and everything that is merely normal
1715        // — in use, nothing to reclaim, deliberately ignored — is default or dim. A
1716        // colour per enum variant reads as decoration and leaves nothing louder to say
1717        // when a repository really is misconfigured.
1718        let reason_cell = format!("{reason:<22}");
1719        let reason_cell = match &repo.reason {
1720            SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1721            SkipReason::Active => reason_cell,
1722            SkipReason::Ignored | SkipReason::NoBloat => {
1723                Colorize::dimmed(reason_cell.as_str()).to_string()
1724            }
1725            SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1726            SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1727        };
1728        // Green, not bold green: this figure repeats on every row, and bolding all of
1729        // them means none of them stands out. The bold copy is the grand total below.
1730        let bloat_cell = if repo.reclaimable_bytes > 0 {
1731            Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1732        } else {
1733            format!("{bloat:<12}")
1734        };
1735        println!(
1736            "  {:>3}  {}  {}  {:<12}  {}  {:<13}  {:<13}",
1737            i + 1,
1738            output::pad_display(&path_str, 35),
1739            reason_cell,
1740            adapters,
1741            bloat_cell,
1742            activity,
1743            pruned
1744        );
1745    }
1746    println!("  {}", "─".repeat(122));
1747
1748    let (ready, total) = reclaimable_split(repos);
1749    let candidates = repos
1750        .iter()
1751        .filter(|r| matches!(r.reason, SkipReason::Candidate))
1752        .count();
1753    // The one bold figure on the screen — and it is the *actionable* one, not the grand
1754    // total. Each row's own bloat is plain green; bolding what a prune would free right
1755    // now is what makes it the line the eye lands on.
1756    output::print_info(&format!(
1757        "Total: {} repos  |  {} candidates  |  {} ready now  |  {} reclaimable in all",
1758        repos.len(),
1759        candidates,
1760        output::format_bytes_styled(ready),
1761        output::format_bytes(total)
1762    ));
1763
1764    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
1765    // the folder's apparent size — the rest is hardlinked into the manager's store.
1766    // Said once here rather than per row so the table stays scannable.
1767    let shared: u64 = repos
1768        .iter()
1769        .flat_map(|r| &r.bloat_dirs)
1770        .map(|b| b.shared_bytes)
1771        .sum();
1772    if shared > 0 {
1773        output::print_info(&format!(
1774            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1775             node_modules does not free those bytes, the store keeps them.",
1776            format_bytes(shared)
1777        ));
1778    }
1779
1780    // AI-assistant tool output (graphify, understand-anything) is never a prune
1781    // candidate: nothing here has a lockfile proving it can be rebuilt, so `devp run`
1782    // does not touch it. Reported once, in its own block, so its size is not a mystery.
1783    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1784    let with_tools: Vec<_> = repos.iter().filter(|r| !r.tools.is_empty()).collect();
1785    if !with_tools.is_empty() {
1786        println!();
1787        output::print_info("Tools (report-only, never pruned):");
1788        for repo in with_tools {
1789            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1790            for tool in &repo.tools {
1791                println!(
1792                    "  {}  {:<20}  {}",
1793                    output::pad_display(&path_str, 35),
1794                    tool.name,
1795                    format_bytes(tool.size_bytes)
1796                );
1797            }
1798        }
1799    }
1800}
1801
1802// ── Tests ─────────────────────────────────────────────────────────────────────
1803
1804#[cfg(test)]
1805mod tests {
1806    use ratatui::style::Color;
1807
1808    use crate::engine::SkipReason;
1809
1810    use super::*;
1811    use crate::engine::RepoStatusEntry;
1812
1813    #[test]
1814    fn test_row_style_logic() {
1815        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1816        assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1817        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
1818        assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1819        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1820        assert_eq!(
1821            reason_color(&SkipReason::ConfigError(String::new())),
1822            Color::Yellow
1823        );
1824    }
1825
1826    /// One entry, with only the fields the view actually orders and filters on.
1827    fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1828        RepoStatusEntry {
1829            path: std::path::PathBuf::from(path),
1830            entry: crate::config::RepoEntry::new(),
1831            reason,
1832            adapters: vec!["uv".to_string()],
1833            bloat_dirs: Vec::new(),
1834            reclaimable_bytes: bytes,
1835            reclaimable_by_adapter: Vec::new(),
1836            last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1837            idle_days: 30,
1838            tools: Vec::new(),
1839        }
1840    }
1841
1842    fn sample() -> Vec<RepoStatusEntry> {
1843        vec![
1844            entry("/code/alpha", SkipReason::Candidate, 500, 90),
1845            entry("/code/beta", SkipReason::Active, 9_000, 1),
1846            entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1847            entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1848        ]
1849    }
1850
1851    #[test]
1852    fn the_default_view_is_every_row_in_the_order_it_arrived() {
1853        let repos = sample();
1854        let app = StatusApp::new(&repos);
1855        assert_eq!(app.view, vec![0, 1, 2, 3]);
1856    }
1857
1858    #[test]
1859    fn sorting_by_size_puts_the_biggest_reclaim_first() {
1860        let repos = sample();
1861        let mut app = StatusApp::new(&repos);
1862        app.sort = SortKey::Size;
1863        app.rebuild_view();
1864        assert_eq!(app.view, vec![1, 3, 0, 2]);
1865    }
1866
1867    #[test]
1868    fn sorting_by_activity_puts_the_longest_untouched_first() {
1869        let repos = sample();
1870        let mut app = StatusApp::new(&repos);
1871        app.sort = SortKey::Activity;
1872        app.rebuild_view();
1873        assert_eq!(app.view, vec![2, 0, 3, 1]);
1874    }
1875
1876    #[test]
1877    fn filters_narrow_the_view_without_disturbing_the_selection() {
1878        let repos = sample();
1879        let mut app = StatusApp::new(&repos);
1880        app.mode = ViewMode::PruneSelect;
1881        app.selected[3] = true;
1882
1883        app.filter = Filter::Candidates;
1884        app.rebuild_view();
1885        assert_eq!(app.view, vec![0, 3]);
1886
1887        app.filter = Filter::Problems;
1888        app.rebuild_view();
1889        assert_eq!(app.view, vec![2]);
1890
1891        // The checked repository is filtered off screen, and stays checked: a filter is
1892        // a way to look, not a way to silently un-arm a prune.
1893        assert!(app.selected[3]);
1894        assert_eq!(app.selected_count(), 1);
1895    }
1896
1897    #[test]
1898    fn a_search_matches_the_path_and_the_adapters() {
1899        let repos = sample();
1900        let mut app = StatusApp::new(&repos);
1901
1902        app.search = "ELT".to_string();
1903        app.rebuild_view();
1904        assert_eq!(app.view, vec![3], "the match is case-insensitive");
1905
1906        app.search = "uv".to_string();
1907        app.rebuild_view();
1908        assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1909
1910        app.search = "nothing-matches-this".to_string();
1911        app.rebuild_view();
1912        assert!(app.view.is_empty());
1913        // Nothing on screen means nothing highlighted — the `i` and prune keys read the
1914        // cursor, and a cursor pointing into an empty view would index out of bounds.
1915        assert_eq!(app.cursor_repo(), None);
1916    }
1917
1918    #[test]
1919    fn the_cursor_follows_its_repository_through_a_filter() {
1920        let repos = sample();
1921        let mut app = StatusApp::new(&repos);
1922        app.table_state.select(Some(3)); // `/code/delta`
1923        assert_eq!(app.cursor_repo(), Some(3));
1924
1925        app.filter = Filter::Candidates;
1926        app.rebuild_view();
1927        // Row 1 of the two-row view — a different row number, the same repository.
1928        assert_eq!(app.cursor_repo(), Some(3));
1929    }
1930
1931    #[test]
1932    fn toggle_all_is_scoped_to_what_is_on_screen() {
1933        let repos = sample();
1934        let mut app = StatusApp::new(&repos);
1935        app.search = "alpha".to_string();
1936        app.rebuild_view();
1937        app.toggle_all_candidates();
1938
1939        assert!(app.selected[0]);
1940        assert!(
1941            !app.selected[3],
1942            "delta was filtered out and must not be armed"
1943        );
1944    }
1945
1946    #[test]
1947    fn the_header_separates_what_is_prunable_now_from_everything() {
1948        // The whole point of the split: `beta` is 9 KB of dependencies in a repository
1949        // somebody worked in yesterday. It counts towards the machine's footprint and
1950        // towards nothing a prune would do today.
1951        let repos = sample();
1952        let (ready, total) = reclaimable_split(&repos);
1953        assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1954        assert_eq!(total, 11_500, "every registered repository");
1955    }
1956
1957    #[test]
1958    fn a_filter_never_changes_the_header_totals() {
1959        // A dashboard that shrank its own totals when a filter was applied would make
1960        // the machine look tidier than it is.
1961        let repos = sample();
1962        let mut app = StatusApp::new(&repos);
1963        let before = reclaimable_split(app.repos);
1964
1965        app.filter = Filter::Problems;
1966        app.rebuild_view();
1967        assert_eq!(app.view.len(), 1);
1968        assert_eq!(reclaimable_split(app.repos), before);
1969    }
1970
1971    #[test]
1972    fn space_checks_a_repository_that_is_only_too_recently_worked_in() {
1973        // The bug this covers: every repository on a machine used daily is `Active`,
1974        // so a candidate-only rule left prune-select with nothing it would accept and
1975        // no way to tell that apart from a mode that had failed to open.
1976        let repos = sample();
1977        let mut app = StatusApp::new(&repos);
1978        app.mode = ViewMode::PruneSelect;
1979        app.table_state.select(Some(1)); // `/code/beta` — active, 9 KB to reclaim
1980
1981        app.toggle_current();
1982        assert!(
1983            app.selected[1],
1984            "an active repository is the user's to pick"
1985        );
1986        assert!(app.notice.is_none());
1987
1988        app.toggle_current();
1989        assert!(!app.selected[1], "and the same key unchecks it");
1990    }
1991
1992    #[test]
1993    fn space_refuses_what_a_prune_could_not_deliver_and_says_why() {
1994        let repos = vec![
1995            entry("/code/ignored", SkipReason::Ignored, 500, 90),
1996            entry("/code/empty", SkipReason::NoBloat, 0, 90),
1997            entry("/code/gone", SkipReason::PathMissing, 0, 90),
1998            entry(
1999                "/code/broken",
2000                SkipReason::ConfigError("bad key".into()),
2001                0,
2002                90,
2003            ),
2004        ];
2005        let mut app = StatusApp::new(&repos);
2006        app.mode = ViewMode::PruneSelect;
2007
2008        for i in 0..repos.len() {
2009            app.table_state.select(Some(i));
2010            app.toggle_current();
2011            assert!(!app.selected[i], "row {i} must stay unchecked");
2012            assert!(
2013                app.notice.is_some(),
2014                "row {i} refused a keypress without explaining itself"
2015            );
2016        }
2017
2018        // The un-ignore affordance is on this screen; the refusal points at it.
2019        app.table_state.select(Some(0));
2020        app.toggle_current();
2021        assert!(app.notice.as_deref().unwrap().contains("[i]"));
2022    }
2023
2024    #[test]
2025    fn an_active_repository_with_nothing_to_reclaim_is_still_refused() {
2026        // Selectable is "the idle threshold is the only thing in the way", not "active".
2027        let repos = vec![entry("/code/bare", SkipReason::Active, 0, 1)];
2028        let mut app = StatusApp::new(&repos);
2029        app.mode = ViewMode::PruneSelect;
2030        app.table_state.select(Some(0));
2031
2032        app.toggle_current();
2033        assert!(!app.selected[0]);
2034    }
2035
2036    #[test]
2037    fn the_footer_counts_every_row_space_would_accept() {
2038        // Not `candidate_count`: it read "0 of 0" on a machine where rows were plainly
2039        // selectable, which is the number that made the mode look broken.
2040        let repos = sample();
2041        let app = StatusApp::new(&repos);
2042        assert_eq!(app.candidate_count(), 2, "alpha + delta");
2043        assert_eq!(
2044            app.selectable_count(),
2045            3,
2046            "alpha + delta + beta, the active one with bloat"
2047        );
2048    }
2049
2050    #[test]
2051    fn toggle_all_leaves_active_repositories_alone() {
2052        // Enter prunes with no second confirmation, so `a` must never arm a repository
2053        // somebody is working in today.
2054        let repos = sample();
2055        let mut app = StatusApp::new(&repos);
2056        app.toggle_all_candidates();
2057
2058        assert!(app.selected[0], "alpha is a candidate");
2059        assert!(app.selected[3], "delta is a candidate");
2060        assert!(!app.selected[1], "beta is active and must be opted into");
2061    }
2062
2063    #[test]
2064    fn weeks_are_bucketed_oldest_first_and_the_window_is_strict() {
2065        const WEEK: i64 = 7 * 24 * 60 * 60;
2066        let now = 100 * WEEK;
2067        let stamps = [
2068            now,            // this week
2069            now - 10,       // this week
2070            now - WEEK - 1, // last week
2071            now - 3 * WEEK, // outside a 3-week window
2072            now + 60,       // a skewed clock in the future
2073        ];
2074        assert_eq!(bucket_by_week(&stamps, now, 3), vec![0, 1, 2]);
2075    }
2076
2077    #[test]
2078    fn an_empty_week_is_a_dot_and_the_busiest_is_a_full_bar() {
2079        let line = sparkline(&[0, 1, 8, 4]);
2080        let chars: Vec<char> = line.chars().collect();
2081        assert_eq!(chars.len(), 4);
2082        assert_eq!(chars[0], '·');
2083        assert_ne!(chars[1], '·', "one commit must not look like none");
2084        assert_eq!(chars[2], '█');
2085        assert_eq!(sparkline(&[0, 0]), "··");
2086    }
2087
2088    #[test]
2089    fn largest_entries_are_sorted_labelled_and_truncated() {
2090        let dir = tempfile::tempdir().unwrap();
2091        let modules = dir.path().join("node_modules");
2092        std::fs::create_dir_all(modules.join("big")).unwrap();
2093        std::fs::write(modules.join("big").join("a.bin"), vec![0u8; 4096]).unwrap();
2094        std::fs::write(modules.join("small.txt"), vec![0u8; 10]).unwrap();
2095        std::fs::write(modules.join("mid.txt"), vec![0u8; 100]).unwrap();
2096
2097        let dirs = vec![("node_modules".to_string(), modules)];
2098        let top = largest_entries(&dirs, 2);
2099        let names: Vec<&str> = top.iter().map(|(n, _)| n.as_str()).collect();
2100        assert_eq!(names, ["node_modules/big", "node_modules/mid.txt"]);
2101        assert!(top[0].1 >= 4096);
2102    }
2103
2104    #[test]
2105    fn i_offers_to_stop_ignoring_a_repository_held_by_the_legacy_marker() {
2106        let dir = tempfile::tempdir().unwrap();
2107        let mut repo = entry("/unused", SkipReason::Ignored, 0, 90);
2108        repo.path = dir.path().to_path_buf();
2109
2110        assert_eq!(ignore_toggle_for(&repo).unwrap(), Some(true));
2111
2112        std::fs::write(
2113            dir.path().join(crate::constants::DEVPRUNE_IGNORE_FILE),
2114            "{}",
2115        )
2116        .unwrap();
2117        assert_eq!(
2118            ignore_toggle_for(&repo).unwrap(),
2119            Some(false),
2120            "the marker means it is already ignored, so `i` must un-ignore"
2121        );
2122
2123        apply_ignore_toggle(&repo, false).unwrap();
2124        assert!(
2125            !dir.path()
2126                .join(crate::constants::DEVPRUNE_IGNORE_FILE)
2127                .exists()
2128        );
2129        assert_eq!(ignore_toggle_for(&repo).unwrap(), Some(true));
2130    }
2131
2132    #[test]
2133    fn i_has_nothing_to_offer_on_a_missing_path() {
2134        let repo = entry("/code/gone", SkipReason::PathMissing, 0, 90);
2135        assert_eq!(ignore_toggle_for(&repo).unwrap(), None);
2136    }
2137}