Skip to main content

dev_prune/tui/
status_view.rs

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