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 {
321 match reason {
322 SkipReason::Candidate => Color::Green,
323 SkipReason::Active => Color::Reset,
324 SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
325 SkipReason::PathMissing => Color::Red,
326 SkipReason::ConfigError(_) => Color::Yellow,
328 }
329}
330
331fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
332 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
333
334 let outer = Layout::default()
335 .direction(Direction::Vertical)
336 .constraints([
337 Constraint::Length(3), Constraint::Min(5), Constraint::Length(6), ])
343 .split(frame.area());
344
345 let mode_label = if is_prune_mode {
347 Span::styled(
348 " PRUNE-SELECT MODE ",
349 Style::default()
350 .bg(Color::Yellow)
351 .fg(Color::Black)
352 .add_modifier(Modifier::BOLD),
353 )
354 } else {
355 Span::styled(
356 " BROWSE MODE ",
357 Style::default()
358 .bg(Color::Cyan)
359 .fg(Color::Black)
360 .add_modifier(Modifier::BOLD),
361 )
362 };
363
364 let header_line = Line::from(vec![
365 Span::styled(
366 " dev-prune ",
367 Style::default()
368 .fg(Color::Black)
369 .bg(Color::Green)
370 .add_modifier(Modifier::BOLD),
371 ),
372 Span::raw(" "),
373 mode_label,
374 Span::styled(
375 format!(
376 " {} repos | {} candidates | {} reclaimable",
377 app.repos.len(),
378 app.candidate_count(),
379 format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
380 ),
381 Style::default().fg(Color::DarkGray),
382 ),
383 ]);
384
385 let header_widget =
386 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
387 Style::default().fg(if is_prune_mode {
388 Color::Yellow
389 } else {
390 Color::Cyan
391 }),
392 ));
393 frame.render_widget(header_widget, outer[0]);
394
395 let col_headers = Row::new(vec![
397 Cell::from(if is_prune_mode { "Sel" } else { "#" })
398 .style(Style::default().add_modifier(Modifier::BOLD)),
399 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
400 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
401 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
402 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
403 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
404 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
405 ])
406 .height(1)
407 .bottom_margin(1)
408 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
412
413 let highlighted_row = app.table_state.selected();
417
418 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
422
423 let rows: Vec<Row> = app
424 .repos
425 .iter()
426 .enumerate()
427 .map(|(i, repo)| {
428 let is_selected = app.selected[i];
429 let color = reason_color(&repo.reason);
430
431 let sel_cell = if is_prune_mode {
432 if matches!(repo.reason, SkipReason::Candidate) {
433 if is_selected {
434 Cell::from("[x]").style(
435 Style::default()
436 .fg(Color::Green)
437 .add_modifier(Modifier::BOLD),
438 )
439 } else {
440 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
441 }
442 } else {
443 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
444 }
445 } else {
446 Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
447 };
448
449 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
450
451 let reason_str = repo.reason.to_string();
452 let adapters_str = if repo.adapters.is_empty() {
453 "—".to_string()
454 } else {
455 repo.adapters.join(", ")
456 };
457 let bloat_str = if repo.reclaimable_bytes > 0 {
458 format_bytes(repo.reclaimable_bytes)
459 } else {
460 "—".to_string()
461 };
462 let bloat_color = if repo.reclaimable_bytes > 0 {
465 Color::Green
466 } else {
467 Color::DarkGray
468 };
469 let activity_str = repo
470 .last_activity
471 .map(|d| d.format("%Y-%m-%d").to_string())
472 .unwrap_or_else(|| "—".to_string());
473 let pruned_str = repo
474 .entry
475 .last_pruned_at
476 .map(|d| d.format("%Y-%m-%d").to_string())
477 .unwrap_or_else(|| "Never".to_string());
478
479 let row_style = if is_selected {
480 Style::default().bg(Color::Rgb(20, 50, 30))
481 } else {
482 Style::default()
483 };
484
485 let on_dark_bg = is_selected || highlighted_row == Some(i);
486 let path_style = if on_dark_bg {
487 Style::default().fg(Color::White)
488 } else {
489 Style::default()
490 };
491 let date_color = if on_dark_bg {
492 Color::Gray
493 } else {
494 Color::DarkGray
495 };
496
497 Row::new(vec![
498 sel_cell,
499 Cell::from(path_str).style(path_style),
500 Cell::from(reason_str).style(Style::default().fg(color)),
501 Cell::from(adapters_str),
502 Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
503 Cell::from(activity_str).style(Style::default().fg(date_color)),
504 Cell::from(pruned_str).style(Style::default().fg(date_color)),
505 ])
506 .style(row_style)
507 })
508 .collect();
509
510 let table = Table::new(
511 rows,
512 [
513 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
521 )
522 .header(col_headers)
523 .block(
524 Block::default()
525 .title(" Registered Repositories ")
526 .borders(Borders::ALL)
527 .border_style(Style::default().fg(Color::DarkGray)),
528 )
529 .row_highlight_style(
530 Style::default()
531 .bg(Color::Rgb(30, 40, 70))
532 .add_modifier(Modifier::BOLD),
533 )
534 .highlight_symbol("▶ ");
535
536 frame.render_stateful_widget(table, outer[1], &mut app.table_state);
540
541 let mut footer_lines = if is_prune_mode {
543 vec![
544 Line::from(vec![
545 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
546 Span::styled(
551 format!(
552 "{} of {} candidates ",
553 app.selected_count(),
554 app.candidate_count()
555 ),
556 Style::default().add_modifier(Modifier::BOLD),
557 ),
558 Span::styled(
559 format!("({})", format_bytes(app.selected_bytes())),
560 Style::default()
561 .fg(Color::Green)
562 .add_modifier(Modifier::BOLD),
563 ),
564 ]),
565 Line::from(vec![
566 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
567 Span::raw(" Navigate "),
568 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
569 Span::raw(" Jump "),
570 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
571 Span::raw(" Toggle "),
572 Span::styled("[a]", Style::default().fg(Color::Cyan)),
573 Span::raw(" Toggle All "),
574 Span::styled(
575 "[Enter]",
576 Style::default()
577 .fg(Color::Green)
578 .add_modifier(Modifier::BOLD),
579 ),
580 Span::raw(" Prune Selected "),
581 Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
582 Span::raw(" Back to Browse "),
583 Span::styled("[q]", Style::default().fg(Color::Cyan)),
584 Span::raw(" Quit"),
585 ]),
586 Line::from(vec![]),
587 ]
588 } else {
589 vec![
590 Line::from(vec![
591 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
592 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
593 Span::raw(" "),
594 Span::styled("■ Active", Style::default().fg(Color::Reset)),
595 Span::raw(" "),
596 Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
599 Span::raw(" "),
600 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
601 ]),
602 Line::from(vec![
603 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
604 Span::raw(" Navigate "),
605 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
606 Span::raw(" Jump "),
607 Span::styled(
612 "[p]",
613 Style::default()
614 .fg(Color::Cyan)
615 .add_modifier(Modifier::BOLD),
616 ),
617 Span::raw(" Prune-Select Mode "),
618 Span::styled(
619 "[i]",
620 Style::default()
621 .fg(Color::Cyan)
622 .add_modifier(Modifier::BOLD),
623 ),
624 Span::raw(" Toggle Ignore "),
625 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
626 Span::raw(" Quit"),
627 ]),
628 Line::from(vec![
629 Span::styled(
630 "[i] ",
631 Style::default()
632 .fg(Color::Cyan)
633 .add_modifier(Modifier::BOLD),
634 ),
635 Span::styled(
636 "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
637 Style::default().fg(Color::DarkGray),
638 ),
639 ]),
640 ]
641 };
642
643 footer_lines.push(Line::from(Span::styled(
647 constants::ATTRIBUTION_LINE,
648 Style::default().fg(Color::DarkGray),
649 )));
650
651 let footer =
652 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
653 Style::default().fg(if is_prune_mode {
654 Color::Yellow
655 } else {
656 Color::Green
657 }),
658 ));
659 frame.render_widget(footer, outer[2]);
660}
661
662pub fn render_status_plain(repos: &[RepoStatusEntry]) {
666 use crate::output;
667 use colored::Colorize;
668
669 output::print_header("dev-prune status");
670 println!(
675 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
676 "#",
677 output::pad_display("Repository", 35),
678 "Status / Reason",
679 "Adapters",
680 "Bloat",
681 "Last Activity",
682 "Last Pruned"
683 );
684 println!(" {}", "─".repeat(122));
685
686 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
687 for (i, repo) in repos.iter().enumerate() {
688 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
689 let reason = repo.reason.to_string();
690 let adapters = if repo.adapters.is_empty() {
691 "—".to_string()
692 } else {
693 repo.adapters.join("+")
694 };
695 let bloat = if repo.reclaimable_bytes > 0 {
696 format_bytes(repo.reclaimable_bytes)
697 } else {
698 "—".to_string()
699 };
700 let activity = repo
701 .last_activity
702 .map(|d| d.format("%Y-%m-%d").to_string())
703 .unwrap_or_else(|| "—".to_string());
704 let pruned = repo
705 .entry
706 .last_pruned_at
707 .map(|d| d.format("%Y-%m-%d").to_string())
708 .unwrap_or_else(|| "Never".to_string());
709
710 let reason_cell = format!("{reason:<22}");
727 let reason_cell = match &repo.reason {
728 SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
729 SkipReason::Active => reason_cell,
730 SkipReason::Ignored | SkipReason::NoBloat => {
731 Colorize::dimmed(reason_cell.as_str()).to_string()
732 }
733 SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
734 SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
735 };
736 let bloat_cell = if repo.reclaimable_bytes > 0 {
739 Colorize::green(format!("{bloat:<12}").as_str()).to_string()
740 } else {
741 format!("{bloat:<12}")
742 };
743 println!(
744 " {:>3} {} {} {:<12} {} {:<13} {:<13}",
745 i + 1,
746 output::pad_display(&path_str, 35),
747 reason_cell,
748 adapters,
749 bloat_cell,
750 activity,
751 pruned
752 );
753 }
754 println!(" {}", "─".repeat(122));
755
756 let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
757 let candidates = repos
758 .iter()
759 .filter(|r| matches!(r.reason, SkipReason::Candidate))
760 .count();
761 output::print_info(&format!(
764 "Total: {} repos | {} candidates | {} reclaimable",
765 repos.len(),
766 candidates,
767 output::format_bytes_styled(total)
768 ));
769
770 let shared: u64 = repos
774 .iter()
775 .flat_map(|r| &r.bloat_dirs)
776 .map(|b| b.shared_bytes)
777 .sum();
778 if shared > 0 {
779 output::print_info(&format!(
780 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
781 node_modules does not free those bytes, the store keeps them.",
782 format_bytes(shared)
783 ));
784 }
785}
786
787#[cfg(test)]
790mod tests {
791 use ratatui::style::Color;
792
793 use crate::engine::SkipReason;
794
795 use super::reason_color;
796
797 #[test]
798 fn test_row_style_logic() {
799 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
800 assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
801 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
803 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
804 assert_eq!(
805 reason_color(&SkipReason::ConfigError(String::new())),
806 Color::Yellow
807 );
808 }
809}