1use 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
25enum ViewMode {
27 Browse,
29 PruneSelect,
31}
32
33struct StatusApp<'a> {
34 repos: &'a [RepoStatusEntry],
35 table_state: TableState,
36 selected: Vec<bool>,
38 mode: ViewMode,
39 confirmed_indices: Option<Vec<usize>>,
41 pub should_reload: bool,
43}
44
45impl<'a> StatusApp<'a> {
46 fn new(repos: &'a [RepoStatusEntry]) -> Self {
47 let selected = vec![false; repos.len()];
48 let mut table_state = TableState::default();
49 table_state.select(Some(0));
50 Self {
51 repos,
52 table_state,
53 selected,
54 mode: ViewMode::Browse,
55 confirmed_indices: None,
56 should_reload: false,
57 }
58 }
59
60 fn move_up(&mut self) {
61 let i = match self.table_state.selected() {
62 Some(i) => {
63 if i == 0 {
64 self.repos.len().saturating_sub(1)
65 } else {
66 i - 1
67 }
68 }
69 None => 0,
70 };
71 self.table_state.select(Some(i));
72 }
73
74 fn move_down(&mut self) {
75 let i = match self.table_state.selected() {
76 Some(i) => {
77 if i >= self.repos.len().saturating_sub(1) {
78 0
79 } else {
80 i + 1
81 }
82 }
83 None => 0,
84 };
85 self.table_state.select(Some(i));
86 }
87
88 fn toggle_current(&mut self) {
89 if let Some(i) = self.table_state.selected() {
90 if matches!(self.repos[i].reason, SkipReason::Candidate) {
92 self.selected[i] = !self.selected[i];
93 }
94 }
95 }
96
97 fn toggle_all_candidates(&mut self) {
98 let any_candidate_selected = self
99 .repos
100 .iter()
101 .enumerate()
102 .any(|(i, r)| matches!(r.reason, SkipReason::Candidate) && self.selected[i]);
103
104 for (i, repo) in self.repos.iter().enumerate() {
105 if matches!(repo.reason, SkipReason::Candidate) {
106 self.selected[i] = !any_candidate_selected;
107 }
108 }
109 }
110
111 fn confirm_prune(&mut self) {
112 let indices: Vec<usize> = self
113 .selected
114 .iter()
115 .enumerate()
116 .filter(|&(_, s)| *s)
117 .map(|(i, _)| i)
118 .collect();
119 self.confirmed_indices = Some(indices);
120 }
121
122 fn selected_bytes(&self) -> u64 {
123 self.repos
124 .iter()
125 .enumerate()
126 .filter(|(i, _)| self.selected[*i])
127 .map(|(_, r)| r.reclaimable_bytes)
128 .sum()
129 }
130
131 fn selected_count(&self) -> usize {
132 self.selected.iter().filter(|&&s| s).count()
133 }
134
135 fn candidate_count(&self) -> usize {
136 self.repos
137 .iter()
138 .filter(|r| matches!(r.reason, SkipReason::Candidate))
139 .count()
140 }
141}
142
143pub fn render_status_tui(
154 repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
155) -> Result<Option<Vec<PathBuf>>> {
156 loop {
157 let repos = repos_loader();
158
159 if repos.is_empty() {
160 return Ok(None);
161 }
162
163 let mut app = StatusApp::new(&repos);
164 {
165 let mut tui = Tui::new()?;
168 tui.drain_stale_input(Duration::from_millis(100));
169 run_status_loop(&mut tui.terminal, &mut app)?;
170 }
171
172 if app.should_reload {
173 continue;
175 }
176
177 return Ok(app
180 .confirmed_indices
181 .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
182 }
183}
184
185fn run_status_loop(
186 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
187 app: &mut StatusApp,
188) -> Result<()> {
189 loop {
190 terminal.draw(|frame| render_ui(frame, app))?;
191
192 if event::poll(Duration::from_millis(100))?
193 && let Event::Key(key) = event::read()?
194 {
195 if key.kind == KeyEventKind::Release {
196 continue;
197 }
198 if key.modifiers.contains(KeyModifiers::CONTROL)
201 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
202 {
203 return Ok(());
204 }
205 match key.code {
206 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
207 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
208 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
209 KeyCode::End | KeyCode::Char('G') => {
210 app.table_state
211 .select(Some(app.repos.len().saturating_sub(1)));
212 }
213 KeyCode::PageUp => {
214 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
215 app.table_state.select(Some(i));
216 }
217 KeyCode::PageDown => {
218 let i = (app.table_state.selected().unwrap_or(0) + 10)
219 .min(app.repos.len().saturating_sub(1));
220 app.table_state.select(Some(i));
221 }
222 KeyCode::Char(' ') => {
223 if matches!(app.mode, ViewMode::PruneSelect) {
224 app.toggle_current();
225 }
226 }
227 KeyCode::Char('a') | KeyCode::Char('A') => {
228 if matches!(app.mode, ViewMode::PruneSelect) {
229 app.toggle_all_candidates();
230 }
231 }
232 KeyCode::Char('p') | KeyCode::Char('P') => {
233 app.mode = ViewMode::PruneSelect;
234 for (i, repo) in app.repos.iter().enumerate() {
236 if matches!(repo.reason, SkipReason::Candidate) {
237 app.selected[i] = true;
238 }
239 }
240 }
241 KeyCode::Char('i') | KeyCode::Char('I') => {
242 if let Some(idx) = app.table_state.selected() {
244 let repo = &app.repos[idx];
245 if matches!(repo.reason, SkipReason::PathMissing) {
249 continue;
250 }
251 let mut per_repo =
256 crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
257 .map_err(|e| anyhow::anyhow!(e))
258 .with_context(|| {
259 format!(
260 "Could not toggle ignore for {}",
261 crate::output::clean_path(&repo.path)
262 )
263 })?
264 .unwrap_or_default();
265 per_repo.ignore = !per_repo.ignore;
266 per_repo.save_to_repo(&repo.path).with_context(|| {
271 format!(
272 "Could not write the config for {}",
273 crate::output::clean_path(&repo.path)
274 )
275 })?;
276
277 let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
280 if legacy_ignore.exists() {
281 std::fs::remove_file(&legacy_ignore).with_context(|| {
282 format!(
283 "Could not remove {}",
284 crate::output::clean_path(&legacy_ignore)
285 )
286 })?;
287 }
288
289 app.should_reload = true;
291 return Ok(());
292 }
293 }
294 KeyCode::Enter => {
295 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
296 app.confirm_prune();
297 return Ok(());
298 }
299 }
300 KeyCode::Esc => {
301 if matches!(app.mode, ViewMode::PruneSelect) {
302 app.mode = ViewMode::Browse;
304 app.selected.fill(false);
305 } else {
306 return Ok(());
307 }
308 }
309 KeyCode::Char('q') => return Ok(()),
310 _ => {}
311 }
312 }
313 }
314}
315
316fn reason_color(reason: &SkipReason) -> Color {
317 match reason {
318 SkipReason::Candidate => Color::Green,
319 SkipReason::Active => Color::Cyan,
320 SkipReason::Ignored => Color::DarkGray,
321 SkipReason::NoBloat => Color::Blue,
322 SkipReason::PathMissing => Color::Red,
323 SkipReason::ConfigError(_) => Color::Yellow,
325 }
326}
327
328fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
329 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
330
331 let outer = Layout::default()
332 .direction(Direction::Vertical)
333 .constraints([
334 Constraint::Length(3), Constraint::Min(5), Constraint::Length(6), ])
340 .split(frame.area());
341
342 let mode_label = if is_prune_mode {
344 Span::styled(
345 " PRUNE-SELECT MODE ",
346 Style::default()
347 .bg(Color::Yellow)
348 .fg(Color::Black)
349 .add_modifier(Modifier::BOLD),
350 )
351 } else {
352 Span::styled(
353 " BROWSE MODE ",
354 Style::default()
355 .bg(Color::Cyan)
356 .fg(Color::Black)
357 .add_modifier(Modifier::BOLD),
358 )
359 };
360
361 let header_line = Line::from(vec![
362 Span::styled(
363 " dev-prune ",
364 Style::default()
365 .fg(Color::Black)
366 .bg(Color::Green)
367 .add_modifier(Modifier::BOLD),
368 ),
369 Span::raw(" "),
370 mode_label,
371 Span::styled(
372 format!(
373 " {} repos | {} candidates | {} reclaimable",
374 app.repos.len(),
375 app.candidate_count(),
376 format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
377 ),
378 Style::default().fg(Color::DarkGray),
379 ),
380 ]);
381
382 let header_widget =
383 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
384 Style::default().fg(if is_prune_mode {
385 Color::Yellow
386 } else {
387 Color::Cyan
388 }),
389 ));
390 frame.render_widget(header_widget, outer[0]);
391
392 let col_headers = Row::new(vec![
394 Cell::from(if is_prune_mode { "Sel" } else { "#" })
395 .style(Style::default().add_modifier(Modifier::BOLD)),
396 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
397 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
398 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
399 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
400 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
401 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
402 ])
403 .height(1)
404 .bottom_margin(1)
405 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
409
410 let highlighted_row = app.table_state.selected();
414
415 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
419
420 let rows: Vec<Row> = app
421 .repos
422 .iter()
423 .enumerate()
424 .map(|(i, repo)| {
425 let is_selected = app.selected[i];
426 let color = reason_color(&repo.reason);
427
428 let sel_cell = if is_prune_mode {
429 if matches!(repo.reason, SkipReason::Candidate) {
430 if is_selected {
431 Cell::from("[x]").style(
432 Style::default()
433 .fg(Color::Green)
434 .add_modifier(Modifier::BOLD),
435 )
436 } else {
437 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
438 }
439 } else {
440 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
441 }
442 } else {
443 Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
444 };
445
446 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
447
448 let reason_str = repo.reason.to_string();
449 let adapters_str = if repo.adapters.is_empty() {
450 "—".to_string()
451 } else {
452 repo.adapters.join(", ")
453 };
454 let bloat_str = if repo.reclaimable_bytes > 0 {
455 format_bytes(repo.reclaimable_bytes)
456 } else {
457 "—".to_string()
458 };
459 let activity_str = repo
460 .last_activity
461 .map(|d| d.format("%Y-%m-%d").to_string())
462 .unwrap_or_else(|| "—".to_string());
463 let pruned_str = repo
464 .entry
465 .last_pruned_at
466 .map(|d| d.format("%Y-%m-%d").to_string())
467 .unwrap_or_else(|| "Never".to_string());
468
469 let row_style = if is_selected {
470 Style::default().bg(Color::Rgb(20, 50, 30))
471 } else {
472 Style::default()
473 };
474
475 let on_dark_bg = is_selected || highlighted_row == Some(i);
476 let path_style = if on_dark_bg {
477 Style::default().fg(Color::White)
478 } else {
479 Style::default()
480 };
481 let date_color = if on_dark_bg {
482 Color::Gray
483 } else {
484 Color::DarkGray
485 };
486
487 Row::new(vec![
488 sel_cell,
489 Cell::from(path_str).style(path_style),
490 Cell::from(reason_str).style(Style::default().fg(color)),
491 Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
492 Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
493 Cell::from(activity_str).style(Style::default().fg(date_color)),
494 Cell::from(pruned_str).style(Style::default().fg(date_color)),
495 ])
496 .style(row_style)
497 })
498 .collect();
499
500 let table = Table::new(
501 rows,
502 [
503 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
511 )
512 .header(col_headers)
513 .block(
514 Block::default()
515 .title(" Registered Repositories ")
516 .borders(Borders::ALL)
517 .border_style(Style::default().fg(Color::DarkGray)),
518 )
519 .row_highlight_style(
520 Style::default()
521 .bg(Color::Rgb(30, 40, 70))
522 .add_modifier(Modifier::BOLD),
523 )
524 .highlight_symbol("▶ ");
525
526 frame.render_stateful_widget(table, outer[1], &mut app.table_state);
530
531 let mut footer_lines = if is_prune_mode {
533 vec![
534 Line::from(vec![
535 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
536 Span::styled(
537 format!(
538 "{} of {} candidates ({})",
539 app.selected_count(),
540 app.candidate_count(),
541 format_bytes(app.selected_bytes())
542 ),
543 Style::default()
544 .fg(Color::Yellow)
545 .add_modifier(Modifier::BOLD),
546 ),
547 ]),
548 Line::from(vec![
549 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
550 Span::raw(" Navigate "),
551 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
552 Span::raw(" Jump "),
553 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
554 Span::raw(" Toggle "),
555 Span::styled("[a]", Style::default().fg(Color::Cyan)),
556 Span::raw(" Toggle All "),
557 Span::styled(
558 "[Enter]",
559 Style::default()
560 .fg(Color::Green)
561 .add_modifier(Modifier::BOLD),
562 ),
563 Span::raw(" Prune Selected "),
564 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
565 Span::raw(" Back to Browse "),
566 Span::styled("[q]", Style::default().fg(Color::Red)),
567 Span::raw(" Quit"),
568 ]),
569 Line::from(vec![]),
570 ]
571 } else {
572 vec![
573 Line::from(vec![
574 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
575 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
576 Span::raw(" "),
577 Span::styled("■ Active", Style::default().fg(Color::Cyan)),
578 Span::raw(" "),
579 Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
580 Span::raw(" "),
581 Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
582 Span::raw(" "),
583 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
584 ]),
585 Line::from(vec![
586 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
587 Span::raw(" Navigate "),
588 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
589 Span::raw(" Jump "),
590 Span::styled(
591 "[p]",
592 Style::default()
593 .fg(Color::Yellow)
594 .add_modifier(Modifier::BOLD),
595 ),
596 Span::raw(" Prune-Select Mode "),
597 Span::styled(
598 "[i]",
599 Style::default()
600 .fg(Color::Magenta)
601 .add_modifier(Modifier::BOLD),
602 ),
603 Span::raw(" Toggle Ignore "),
604 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
605 Span::raw(" Quit"),
606 ]),
607 Line::from(vec![
608 Span::styled(
609 "[i] ",
610 Style::default()
611 .fg(Color::Magenta)
612 .add_modifier(Modifier::BOLD),
613 ),
614 Span::styled(
615 "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
616 Style::default().fg(Color::DarkGray),
617 ),
618 ]),
619 ]
620 };
621
622 footer_lines.push(Line::from(Span::styled(
626 constants::ATTRIBUTION_LINE,
627 Style::default().fg(Color::DarkGray),
628 )));
629
630 let footer =
631 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
632 Style::default().fg(if is_prune_mode {
633 Color::Yellow
634 } else {
635 Color::Green
636 }),
637 ));
638 frame.render_widget(footer, outer[2]);
639}
640
641pub fn render_status_plain(repos: &[RepoStatusEntry]) {
645 use crate::output;
646 use colored::Colorize;
647
648 output::print_header("dev-prune status");
649 println!(
654 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
655 "#",
656 output::pad_display("Repository", 35),
657 "Status / Reason",
658 "Adapters",
659 "Bloat",
660 "Last Activity",
661 "Last Pruned"
662 );
663 println!(" {}", "─".repeat(122));
664
665 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
666 for (i, repo) in repos.iter().enumerate() {
667 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
668 let reason = repo.reason.to_string();
669 let adapters = if repo.adapters.is_empty() {
670 "—".to_string()
671 } else {
672 repo.adapters.join("+")
673 };
674 let bloat = if repo.reclaimable_bytes > 0 {
675 format_bytes(repo.reclaimable_bytes)
676 } else {
677 "—".to_string()
678 };
679 let activity = repo
680 .last_activity
681 .map(|d| d.format("%Y-%m-%d").to_string())
682 .unwrap_or_else(|| "—".to_string());
683 let pruned = repo
684 .entry
685 .last_pruned_at
686 .map(|d| d.format("%Y-%m-%d").to_string())
687 .unwrap_or_else(|| "Never".to_string());
688
689 let reason_cell = format!("{reason:<22}");
694 let reason_cell = match &repo.reason {
695 SkipReason::Candidate => reason_cell.green().to_string(),
696 SkipReason::Active => reason_cell.cyan().to_string(),
697 SkipReason::Ignored => reason_cell.dimmed().to_string(),
698 SkipReason::NoBloat => reason_cell.blue().to_string(),
699 SkipReason::PathMissing => reason_cell.red().to_string(),
700 SkipReason::ConfigError(_) => reason_cell.yellow().to_string(),
701 };
702 let bloat_cell = if repo.reclaimable_bytes > 0 {
703 format!("{bloat:<12}").green().bold().to_string()
704 } else {
705 format!("{bloat:<12}")
706 };
707 println!(
708 " {:>3} {} {} {} {} {:<13} {:<13}",
709 i + 1,
710 output::pad_display(&path_str, 35),
711 reason_cell,
712 format!("{adapters:<12}").magenta(),
713 bloat_cell,
714 activity,
715 pruned
716 );
717 }
718 println!(" {}", "─".repeat(122));
719
720 let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
721 let candidates = repos
722 .iter()
723 .filter(|r| matches!(r.reason, SkipReason::Candidate))
724 .count();
725 output::print_info(&format!(
726 "Total: {} repos | {} candidates | {} reclaimable",
727 repos.len(),
728 candidates,
729 format_bytes(total)
730 ));
731
732 let shared: u64 = repos
736 .iter()
737 .flat_map(|r| &r.bloat_dirs)
738 .map(|b| b.shared_bytes)
739 .sum();
740 if shared > 0 {
741 output::print_info(&format!(
742 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
743 node_modules does not free those bytes, the store keeps them.",
744 format_bytes(shared)
745 ));
746 }
747}
748
749#[cfg(test)]
752mod tests {
753 use ratatui::style::Color;
754
755 use crate::engine::SkipReason;
756
757 use super::reason_color;
758
759 #[test]
760 fn test_row_style_logic() {
761 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
762 assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
763 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
765 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
766 }
767}