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::time::Duration;
13
14use anyhow::{Context, Result};
15use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
16use ratatui::prelude::*;
17use ratatui::widgets::*;
18
19use crate::engine::{RepoStatusEntry, SkipReason};
20use crate::output::format_bytes;
21use crate::tui::Tui;
22
23/// Mode the status view is in.
24enum ViewMode {
25    /// Browsing the table — 'p' enters PruneSelect mode.
26    Browse,
27    /// User is selecting candidates to prune.
28    PruneSelect,
29}
30
31struct StatusApp<'a> {
32    repos: &'a [RepoStatusEntry],
33    table_state: TableState,
34    /// Which rows are checked for prune (indexed to `repos`).
35    selected: Vec<bool>,
36    mode: ViewMode,
37    /// If `Some`, the user confirmed and we return these indices.
38    confirmed_indices: Option<Vec<usize>>,
39    /// Set to true when the user toggles ignore config in .devprune.json or presence of ignore.devprune.json so caller can reload.
40    pub should_reload: bool,
41}
42
43impl<'a> StatusApp<'a> {
44    fn new(repos: &'a [RepoStatusEntry]) -> Self {
45        let selected = vec![false; repos.len()];
46        let mut table_state = TableState::default();
47        table_state.select(Some(0));
48        Self {
49            repos,
50            table_state,
51            selected,
52            mode: ViewMode::Browse,
53            confirmed_indices: None,
54            should_reload: false,
55        }
56    }
57
58    fn move_up(&mut self) {
59        let i = match self.table_state.selected() {
60            Some(i) => {
61                if i == 0 {
62                    self.repos.len().saturating_sub(1)
63                } else {
64                    i - 1
65                }
66            }
67            None => 0,
68        };
69        self.table_state.select(Some(i));
70    }
71
72    fn move_down(&mut self) {
73        let i = match self.table_state.selected() {
74            Some(i) => {
75                if i >= self.repos.len().saturating_sub(1) {
76                    0
77                } else {
78                    i + 1
79                }
80            }
81            None => 0,
82        };
83        self.table_state.select(Some(i));
84    }
85
86    fn toggle_current(&mut self) {
87        if let Some(i) = self.table_state.selected() {
88            // Only allow toggling candidate repos for pruning
89            if matches!(self.repos[i].reason, SkipReason::Candidate) {
90                self.selected[i] = !self.selected[i];
91            }
92        }
93    }
94
95    fn toggle_all_candidates(&mut self) {
96        let any_candidate_selected = self
97            .repos
98            .iter()
99            .enumerate()
100            .any(|(i, r)| matches!(r.reason, SkipReason::Candidate) && self.selected[i]);
101
102        for (i, repo) in self.repos.iter().enumerate() {
103            if matches!(repo.reason, SkipReason::Candidate) {
104                self.selected[i] = !any_candidate_selected;
105            }
106        }
107    }
108
109    fn confirm_prune(&mut self) {
110        let indices: Vec<usize> = self
111            .selected
112            .iter()
113            .enumerate()
114            .filter(|&(_, s)| *s)
115            .map(|(i, _)| i)
116            .collect();
117        self.confirmed_indices = Some(indices);
118    }
119
120    fn selected_bytes(&self) -> u64 {
121        self.repos
122            .iter()
123            .enumerate()
124            .filter(|(i, _)| self.selected[*i])
125            .map(|(_, r)| r.reclaimable_bytes)
126            .sum()
127    }
128
129    fn selected_count(&self) -> usize {
130        self.selected.iter().filter(|&&s| s).count()
131    }
132
133    fn candidate_count(&self) -> usize {
134        self.repos
135            .iter()
136            .filter(|r| matches!(r.reason, SkipReason::Candidate))
137            .count()
138    }
139}
140
141/// Render the full interactive status view.
142///
143/// Returns `Some(Vec<usize>)` of selected repo indices if the user confirmed
144/// a prune, or `None` if they just quit.
145///
146/// Re-runs automatically when the user toggles ignore config in `devprune.json` so the
147/// status reflects the change immediately.
148pub fn render_status_tui(
149    repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
150) -> Result<Option<Vec<usize>>> {
151    loop {
152        let repos = repos_loader();
153
154        if repos.is_empty() {
155            return Ok(None);
156        }
157
158        let mut app = StatusApp::new(&repos);
159        {
160            // Scoped so the terminal is restored before anything below prints, and on
161            // every exit path from the loop — return, error, or panic.
162            let mut tui = Tui::new()?;
163            tui.drain_stale_input(Duration::from_millis(100));
164            run_status_loop(&mut tui.terminal, &mut app)?;
165        }
166
167        if app.should_reload {
168            // User toggled ignore — reload and re-render
169            continue;
170        }
171
172        return Ok(app.confirmed_indices);
173    }
174}
175
176fn run_status_loop(
177    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
178    app: &mut StatusApp,
179) -> Result<()> {
180    loop {
181        terminal.draw(|frame| render_ui(frame, app))?;
182
183        if event::poll(Duration::from_millis(100))? {
184            if let Event::Key(key) = event::read()? {
185                if key.kind == KeyEventKind::Release {
186                    continue;
187                }
188                // Raw mode delivers Ctrl-C as a key event rather than a signal, so
189                // without this the one key everybody reaches for to escape does nothing.
190                if key.modifiers.contains(KeyModifiers::CONTROL)
191                    && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
192                {
193                    return Ok(());
194                }
195                match key.code {
196                    KeyCode::Up | KeyCode::Char('k') => app.move_up(),
197                    KeyCode::Down | KeyCode::Char('j') => app.move_down(),
198                    KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
199                    KeyCode::End | KeyCode::Char('G') => {
200                        app.table_state
201                            .select(Some(app.repos.len().saturating_sub(1)));
202                    }
203                    KeyCode::PageUp => {
204                        let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
205                        app.table_state.select(Some(i));
206                    }
207                    KeyCode::PageDown => {
208                        let i = (app.table_state.selected().unwrap_or(0) + 10)
209                            .min(app.repos.len().saturating_sub(1));
210                        app.table_state.select(Some(i));
211                    }
212                    KeyCode::Char(' ') => {
213                        if matches!(app.mode, ViewMode::PruneSelect) {
214                            app.toggle_current();
215                        }
216                    }
217                    KeyCode::Char('a') | KeyCode::Char('A') => {
218                        if matches!(app.mode, ViewMode::PruneSelect) {
219                            app.toggle_all_candidates();
220                        }
221                    }
222                    KeyCode::Char('p') | KeyCode::Char('P') => {
223                        app.mode = ViewMode::PruneSelect;
224                        // Auto-select all candidates
225                        for (i, repo) in app.repos.iter().enumerate() {
226                            if matches!(repo.reason, SkipReason::Candidate) {
227                                app.selected[i] = true;
228                            }
229                        }
230                    }
231                    KeyCode::Char('i') | KeyCode::Char('I') => {
232                        // Toggle ignore in .devprune.json on the current repo
233                        if let Some(idx) = app.table_state.selected() {
234                            let repo = &app.repos[idx];
235                            // Refuses a config that does not parse. Starting from the
236                            // defaults would have written a fresh file over the broken
237                            // one, discarding every other override it held — and the
238                            // dashboard already shows such a repo as `config_error`.
239                            let mut per_repo =
240                                crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
241                                    .map_err(|e| anyhow::anyhow!(e))
242                                    .with_context(|| {
243                                        format!(
244                                            "Could not toggle ignore for {}",
245                                            crate::output::clean_path(&repo.path)
246                                        )
247                                    })?
248                                    .unwrap_or_default();
249                            per_repo.ignore = !per_repo.ignore;
250                            // Both writes are propagated rather than swallowed. A silent
251                            // failure here redraws the table unchanged, which reads as a
252                            // dead key; worse, a repository the user just un-ignored would
253                            // still be pruned on the next pass.
254                            per_repo.save_to_repo(&repo.path).with_context(|| {
255                                format!(
256                                    "Could not write the config for {}",
257                                    crate::output::clean_path(&repo.path)
258                                )
259                            })?;
260
261                            // The legacy marker file still counts as "ignored", so it has
262                            // to go for the toggle to mean anything.
263                            let legacy_ignore =
264                                repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
265                            if legacy_ignore.exists() {
266                                std::fs::remove_file(&legacy_ignore).with_context(|| {
267                                    format!(
268                                        "Could not remove {}",
269                                        crate::output::clean_path(&legacy_ignore)
270                                    )
271                                })?;
272                            }
273
274                            // Signal caller to reload status
275                            app.should_reload = true;
276                            return Ok(());
277                        }
278                    }
279                    KeyCode::Enter => {
280                        if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
281                            app.confirm_prune();
282                            return Ok(());
283                        }
284                    }
285                    KeyCode::Esc => {
286                        if matches!(app.mode, ViewMode::PruneSelect) {
287                            // Exit prune-select mode, go back to browse
288                            app.mode = ViewMode::Browse;
289                            for s in &mut app.selected {
290                                *s = false;
291                            }
292                        } else {
293                            return Ok(());
294                        }
295                    }
296                    KeyCode::Char('q') => return Ok(()),
297                    _ => {}
298                }
299            }
300        }
301    }
302}
303
304fn reason_color(reason: &SkipReason) -> Color {
305    match reason {
306        SkipReason::Candidate => Color::Green,
307        SkipReason::Active => Color::Cyan,
308        SkipReason::Ignored => Color::DarkGray,
309        SkipReason::NoBloat => Color::Blue,
310        SkipReason::PathMissing => Color::Red,
311        // Actionable rather than broken: the repo is fine, its config file is not.
312        SkipReason::ConfigError(_) => Color::Yellow,
313    }
314}
315
316fn render_ui(frame: &mut Frame, app: &StatusApp) {
317    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
318
319    let outer = Layout::default()
320        .direction(Direction::Vertical)
321        .constraints([
322            Constraint::Length(3), // header
323            Constraint::Min(5),    // table
324            Constraint::Length(5), // footer
325        ])
326        .split(frame.area());
327
328    // ── Header ───────────────────────────────────────────────────────────────
329    let mode_label = if is_prune_mode {
330        Span::styled(
331            " PRUNE-SELECT MODE ",
332            Style::default()
333                .bg(Color::Yellow)
334                .fg(Color::Black)
335                .add_modifier(Modifier::BOLD),
336        )
337    } else {
338        Span::styled(
339            " BROWSE MODE ",
340            Style::default()
341                .bg(Color::Cyan)
342                .fg(Color::Black)
343                .add_modifier(Modifier::BOLD),
344        )
345    };
346
347    let header_line = Line::from(vec![
348        Span::styled(
349            " dev-prune ",
350            Style::default()
351                .fg(Color::Black)
352                .bg(Color::Green)
353                .add_modifier(Modifier::BOLD),
354        ),
355        Span::raw(" "),
356        mode_label,
357        Span::styled(
358            format!(
359                "  {} repos  |  {} candidates  |  {} reclaimable",
360                app.repos.len(),
361                app.candidate_count(),
362                format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
363            ),
364            Style::default().fg(Color::DarkGray),
365        ),
366    ]);
367
368    let header_widget =
369        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
370            Style::default().fg(if is_prune_mode {
371                Color::Yellow
372            } else {
373                Color::Cyan
374            }),
375        ));
376    frame.render_widget(header_widget, outer[0]);
377
378    // ── Table ─────────────────────────────────────────────────────────────────
379    let col_headers = Row::new(vec![
380        Cell::from(if is_prune_mode { "Sel" } else { "#" })
381            .style(Style::default().add_modifier(Modifier::BOLD)),
382        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
383        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
384        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
385        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
386        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
387        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
388    ])
389    .height(1)
390    .bottom_margin(1)
391    .style(Style::default().bg(Color::Rgb(20, 25, 40)));
392
393    let rows: Vec<Row> = app
394        .repos
395        .iter()
396        .enumerate()
397        .map(|(i, repo)| {
398            let is_selected = app.selected[i];
399            let color = reason_color(&repo.reason);
400
401            let sel_cell = if is_prune_mode {
402                if matches!(repo.reason, SkipReason::Candidate) {
403                    if is_selected {
404                        Cell::from("[x]").style(
405                            Style::default()
406                                .fg(Color::Green)
407                                .add_modifier(Modifier::BOLD),
408                        )
409                    } else {
410                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
411                    }
412                } else {
413                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
414                }
415            } else {
416                Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
417            };
418
419            let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
420            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
421
422            let reason_str = repo.reason.to_string();
423            let adapters_str = if repo.adapters.is_empty() {
424                "—".to_string()
425            } else {
426                repo.adapters.join(", ")
427            };
428            let bloat_str = if repo.reclaimable_bytes > 0 {
429                format_bytes(repo.reclaimable_bytes)
430            } else {
431                "—".to_string()
432            };
433            let activity_str = repo
434                .last_activity
435                .map(|d| d.format("%Y-%m-%d").to_string())
436                .unwrap_or_else(|| "—".to_string());
437            let pruned_str = repo
438                .entry
439                .last_pruned_at
440                .map(|d| d.format("%Y-%m-%d").to_string())
441                .unwrap_or_else(|| "Never".to_string());
442
443            let row_style = if is_selected {
444                Style::default().bg(Color::Rgb(20, 50, 30))
445            } else {
446                Style::default()
447            };
448
449            Row::new(vec![
450                sel_cell,
451                Cell::from(path_str).style(Style::default().fg(Color::White)),
452                Cell::from(reason_str).style(Style::default().fg(color)),
453                Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
454                Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
455                Cell::from(activity_str).style(Style::default().fg(Color::Gray)),
456                Cell::from(pruned_str).style(Style::default().fg(Color::Gray)),
457            ])
458            .style(row_style)
459        })
460        .collect();
461
462    let table = Table::new(
463        rows,
464        [
465            Constraint::Length(4),  // sel/#
466            Constraint::Min(24),    // path
467            Constraint::Length(22), // reason
468            Constraint::Length(16), // adapters
469            Constraint::Length(11), // bloat
470            Constraint::Length(13), // last activity
471            Constraint::Length(13), // last pruned
472        ],
473    )
474    .header(col_headers)
475    .block(
476        Block::default()
477            .title(" Registered Repositories ")
478            .borders(Borders::ALL)
479            .border_style(Style::default().fg(Color::Gray)),
480    )
481    .row_highlight_style(
482        Style::default()
483            .bg(Color::Rgb(30, 40, 70))
484            .add_modifier(Modifier::BOLD),
485    )
486    .highlight_symbol("▶ ");
487
488    frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
489
490    // ── Footer ────────────────────────────────────────────────────────────────
491    let footer_lines = if is_prune_mode {
492        vec![
493            Line::from(vec![
494                Span::styled("Selected: ", Style::default().fg(Color::Gray)),
495                Span::styled(
496                    format!(
497                        "{} of {} candidates  ({})",
498                        app.selected_count(),
499                        app.candidate_count(),
500                        format_bytes(app.selected_bytes())
501                    ),
502                    Style::default()
503                        .fg(Color::Yellow)
504                        .add_modifier(Modifier::BOLD),
505                ),
506            ]),
507            Line::from(vec![
508                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
509                Span::raw(" Navigate  "),
510                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
511                Span::raw(" Jump  "),
512                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
513                Span::raw(" Toggle  "),
514                Span::styled("[a]", Style::default().fg(Color::Cyan)),
515                Span::raw(" Toggle All  "),
516                Span::styled(
517                    "[Enter]",
518                    Style::default()
519                        .fg(Color::Green)
520                        .add_modifier(Modifier::BOLD),
521                ),
522                Span::raw(" Prune Selected  "),
523                Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
524                Span::raw(" Back to Browse  "),
525                Span::styled("[q]", Style::default().fg(Color::Red)),
526                Span::raw(" Quit"),
527            ]),
528            Line::from(vec![]),
529        ]
530    } else {
531        vec![
532            Line::from(vec![
533                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
534                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
535                Span::raw("  "),
536                Span::styled("■ Active", Style::default().fg(Color::Cyan)),
537                Span::raw("  "),
538                Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
539                Span::raw("  "),
540                Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
541                Span::raw("  "),
542                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
543            ]),
544            Line::from(vec![
545                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
546                Span::raw(" Navigate  "),
547                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
548                Span::raw(" Jump  "),
549                Span::styled(
550                    "[p]",
551                    Style::default()
552                        .fg(Color::Yellow)
553                        .add_modifier(Modifier::BOLD),
554                ),
555                Span::raw(" Prune-Select Mode  "),
556                Span::styled(
557                    "[i]",
558                    Style::default()
559                        .fg(Color::Magenta)
560                        .add_modifier(Modifier::BOLD),
561                ),
562                Span::raw(" Toggle Ignore  "),
563                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
564                Span::raw(" Quit"),
565            ]),
566            Line::from(vec![
567                Span::styled(
568                    "[i] ",
569                    Style::default()
570                        .fg(Color::Magenta)
571                        .add_modifier(Modifier::BOLD),
572                ),
573                Span::styled(
574                    "toggles `ignore` in `.devprune.json` and updates `.gitignore` — refreshes instantly.",
575                    Style::default().fg(Color::DarkGray),
576                ),
577            ]),
578        ]
579    };
580
581    let footer =
582        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
583            Style::default().fg(if is_prune_mode {
584                Color::Yellow
585            } else {
586                Color::Green
587            }),
588        ));
589    frame.render_widget(footer, outer[2]);
590}
591
592// ── Plain-text fallback ──────────────────────────────────────────────────────
593
594/// Plain text fallback rendering for non-TUI environments.
595pub fn render_status_plain(repos: &[RepoStatusEntry]) {
596    use crate::output;
597
598    output::print_header("dev-prune status");
599    println!(
600        "\n  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
601        "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
602    );
603    println!("  {}", "─".repeat(118));
604
605    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
606    for (i, repo) in repos.iter().enumerate() {
607        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
608        let reason = repo.reason.to_string();
609        let adapters = if repo.adapters.is_empty() {
610            "—".to_string()
611        } else {
612            repo.adapters.join("+")
613        };
614        let bloat = if repo.reclaimable_bytes > 0 {
615            format_bytes(repo.reclaimable_bytes)
616        } else {
617            "—".to_string()
618        };
619        let activity = repo
620            .last_activity
621            .map(|d| d.format("%Y-%m-%d").to_string())
622            .unwrap_or_else(|| "—".to_string());
623        let pruned = repo
624            .entry
625            .last_pruned_at
626            .map(|d| d.format("%Y-%m-%d").to_string())
627            .unwrap_or_else(|| "Never".to_string());
628
629        println!(
630            "  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
631            i + 1,
632            path_str,
633            reason,
634            adapters,
635            bloat,
636            activity,
637            pruned
638        );
639    }
640    println!("  {}", "─".repeat(118));
641
642    let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
643    let candidates = repos
644        .iter()
645        .filter(|r| matches!(r.reason, SkipReason::Candidate))
646        .count();
647    output::print_info(&format!(
648        "Total: {} repos  |  {} candidates  |  {} reclaimable",
649        repos.len(),
650        candidates,
651        format_bytes(total)
652    ));
653}
654
655// ── Tests ─────────────────────────────────────────────────────────────────────
656
657#[cfg(test)]
658mod tests {
659    use ratatui::style::Color;
660
661    use crate::engine::SkipReason;
662
663    use super::reason_color;
664
665    #[test]
666    fn test_row_style_logic() {
667        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
668        assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
669        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
670        assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
671        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
672    }
673}