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 if let Event::Key(key) = event::read()? {
194 if key.kind == KeyEventKind::Release {
195 continue;
196 }
197 if key.modifiers.contains(KeyModifiers::CONTROL)
200 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
201 {
202 return Ok(());
203 }
204 match key.code {
205 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
206 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
207 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
208 KeyCode::End | KeyCode::Char('G') => {
209 app.table_state
210 .select(Some(app.repos.len().saturating_sub(1)));
211 }
212 KeyCode::PageUp => {
213 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
214 app.table_state.select(Some(i));
215 }
216 KeyCode::PageDown => {
217 let i = (app.table_state.selected().unwrap_or(0) + 10)
218 .min(app.repos.len().saturating_sub(1));
219 app.table_state.select(Some(i));
220 }
221 KeyCode::Char(' ') => {
222 if matches!(app.mode, ViewMode::PruneSelect) {
223 app.toggle_current();
224 }
225 }
226 KeyCode::Char('a') | KeyCode::Char('A') => {
227 if matches!(app.mode, ViewMode::PruneSelect) {
228 app.toggle_all_candidates();
229 }
230 }
231 KeyCode::Char('p') | KeyCode::Char('P') => {
232 app.mode = ViewMode::PruneSelect;
233 for (i, repo) in app.repos.iter().enumerate() {
235 if matches!(repo.reason, SkipReason::Candidate) {
236 app.selected[i] = true;
237 }
238 }
239 }
240 KeyCode::Char('i') | KeyCode::Char('I') => {
241 if let Some(idx) = app.table_state.selected() {
243 let repo = &app.repos[idx];
244 let mut per_repo =
249 crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
250 .map_err(|e| anyhow::anyhow!(e))
251 .with_context(|| {
252 format!(
253 "Could not toggle ignore for {}",
254 crate::output::clean_path(&repo.path)
255 )
256 })?
257 .unwrap_or_default();
258 per_repo.ignore = !per_repo.ignore;
259 per_repo.save_to_repo(&repo.path).with_context(|| {
264 format!(
265 "Could not write the config for {}",
266 crate::output::clean_path(&repo.path)
267 )
268 })?;
269
270 let legacy_ignore =
273 repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
274 if legacy_ignore.exists() {
275 std::fs::remove_file(&legacy_ignore).with_context(|| {
276 format!(
277 "Could not remove {}",
278 crate::output::clean_path(&legacy_ignore)
279 )
280 })?;
281 }
282
283 app.should_reload = true;
285 return Ok(());
286 }
287 }
288 KeyCode::Enter => {
289 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
290 app.confirm_prune();
291 return Ok(());
292 }
293 }
294 KeyCode::Esc => {
295 if matches!(app.mode, ViewMode::PruneSelect) {
296 app.mode = ViewMode::Browse;
298 for s in &mut app.selected {
299 *s = false;
300 }
301 } else {
302 return Ok(());
303 }
304 }
305 KeyCode::Char('q') => return Ok(()),
306 _ => {}
307 }
308 }
309 }
310 }
311}
312
313fn reason_color(reason: &SkipReason) -> Color {
314 match reason {
315 SkipReason::Candidate => Color::Green,
316 SkipReason::Active => Color::Cyan,
317 SkipReason::Ignored => Color::DarkGray,
318 SkipReason::NoBloat => Color::Blue,
319 SkipReason::PathMissing => Color::Red,
320 SkipReason::ConfigError(_) => Color::Yellow,
322 }
323}
324
325fn render_ui(frame: &mut Frame, app: &StatusApp) {
326 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
327
328 let outer = Layout::default()
329 .direction(Direction::Vertical)
330 .constraints([
331 Constraint::Length(3), Constraint::Min(5), Constraint::Length(6), ])
337 .split(frame.area());
338
339 let mode_label = if is_prune_mode {
341 Span::styled(
342 " PRUNE-SELECT MODE ",
343 Style::default()
344 .bg(Color::Yellow)
345 .fg(Color::Black)
346 .add_modifier(Modifier::BOLD),
347 )
348 } else {
349 Span::styled(
350 " BROWSE MODE ",
351 Style::default()
352 .bg(Color::Cyan)
353 .fg(Color::Black)
354 .add_modifier(Modifier::BOLD),
355 )
356 };
357
358 let header_line = Line::from(vec![
359 Span::styled(
360 " dev-prune ",
361 Style::default()
362 .fg(Color::Black)
363 .bg(Color::Green)
364 .add_modifier(Modifier::BOLD),
365 ),
366 Span::raw(" "),
367 mode_label,
368 Span::styled(
369 format!(
370 " {} repos | {} candidates | {} reclaimable",
371 app.repos.len(),
372 app.candidate_count(),
373 format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
374 ),
375 Style::default().fg(Color::DarkGray),
376 ),
377 ]);
378
379 let header_widget =
380 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
381 Style::default().fg(if is_prune_mode {
382 Color::Yellow
383 } else {
384 Color::Cyan
385 }),
386 ));
387 frame.render_widget(header_widget, outer[0]);
388
389 let col_headers = Row::new(vec![
391 Cell::from(if is_prune_mode { "Sel" } else { "#" })
392 .style(Style::default().add_modifier(Modifier::BOLD)),
393 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
394 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
395 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
396 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
397 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
398 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
399 ])
400 .height(1)
401 .bottom_margin(1)
402 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
406
407 let highlighted_row = app.table_state.selected();
411
412 let rows: Vec<Row> = app
413 .repos
414 .iter()
415 .enumerate()
416 .map(|(i, repo)| {
417 let is_selected = app.selected[i];
418 let color = reason_color(&repo.reason);
419
420 let sel_cell = if is_prune_mode {
421 if matches!(repo.reason, SkipReason::Candidate) {
422 if is_selected {
423 Cell::from("[x]").style(
424 Style::default()
425 .fg(Color::Green)
426 .add_modifier(Modifier::BOLD),
427 )
428 } else {
429 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
430 }
431 } else {
432 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
433 }
434 } else {
435 Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
436 };
437
438 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
439 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
440
441 let reason_str = repo.reason.to_string();
442 let adapters_str = if repo.adapters.is_empty() {
443 "—".to_string()
444 } else {
445 repo.adapters.join(", ")
446 };
447 let bloat_str = if repo.reclaimable_bytes > 0 {
448 format_bytes(repo.reclaimable_bytes)
449 } else {
450 "—".to_string()
451 };
452 let activity_str = repo
453 .last_activity
454 .map(|d| d.format("%Y-%m-%d").to_string())
455 .unwrap_or_else(|| "—".to_string());
456 let pruned_str = repo
457 .entry
458 .last_pruned_at
459 .map(|d| d.format("%Y-%m-%d").to_string())
460 .unwrap_or_else(|| "Never".to_string());
461
462 let row_style = if is_selected {
463 Style::default().bg(Color::Rgb(20, 50, 30))
464 } else {
465 Style::default()
466 };
467
468 let on_dark_bg = is_selected || highlighted_row == Some(i);
469 let path_style = if on_dark_bg {
470 Style::default().fg(Color::White)
471 } else {
472 Style::default()
473 };
474 let date_color = if on_dark_bg {
475 Color::Gray
476 } else {
477 Color::DarkGray
478 };
479
480 Row::new(vec![
481 sel_cell,
482 Cell::from(path_str).style(path_style),
483 Cell::from(reason_str).style(Style::default().fg(color)),
484 Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
485 Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
486 Cell::from(activity_str).style(Style::default().fg(date_color)),
487 Cell::from(pruned_str).style(Style::default().fg(date_color)),
488 ])
489 .style(row_style)
490 })
491 .collect();
492
493 let table = Table::new(
494 rows,
495 [
496 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
504 )
505 .header(col_headers)
506 .block(
507 Block::default()
508 .title(" Registered Repositories ")
509 .borders(Borders::ALL)
510 .border_style(Style::default().fg(Color::DarkGray)),
511 )
512 .row_highlight_style(
513 Style::default()
514 .bg(Color::Rgb(30, 40, 70))
515 .add_modifier(Modifier::BOLD),
516 )
517 .highlight_symbol("▶ ");
518
519 frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
520
521 let mut footer_lines = if is_prune_mode {
523 vec![
524 Line::from(vec![
525 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
526 Span::styled(
527 format!(
528 "{} of {} candidates ({})",
529 app.selected_count(),
530 app.candidate_count(),
531 format_bytes(app.selected_bytes())
532 ),
533 Style::default()
534 .fg(Color::Yellow)
535 .add_modifier(Modifier::BOLD),
536 ),
537 ]),
538 Line::from(vec![
539 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
540 Span::raw(" Navigate "),
541 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
542 Span::raw(" Jump "),
543 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
544 Span::raw(" Toggle "),
545 Span::styled("[a]", Style::default().fg(Color::Cyan)),
546 Span::raw(" Toggle All "),
547 Span::styled(
548 "[Enter]",
549 Style::default()
550 .fg(Color::Green)
551 .add_modifier(Modifier::BOLD),
552 ),
553 Span::raw(" Prune Selected "),
554 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
555 Span::raw(" Back to Browse "),
556 Span::styled("[q]", Style::default().fg(Color::Red)),
557 Span::raw(" Quit"),
558 ]),
559 Line::from(vec![]),
560 ]
561 } else {
562 vec![
563 Line::from(vec![
564 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
565 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
566 Span::raw(" "),
567 Span::styled("■ Active", Style::default().fg(Color::Cyan)),
568 Span::raw(" "),
569 Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
570 Span::raw(" "),
571 Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
572 Span::raw(" "),
573 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
574 ]),
575 Line::from(vec![
576 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
577 Span::raw(" Navigate "),
578 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
579 Span::raw(" Jump "),
580 Span::styled(
581 "[p]",
582 Style::default()
583 .fg(Color::Yellow)
584 .add_modifier(Modifier::BOLD),
585 ),
586 Span::raw(" Prune-Select Mode "),
587 Span::styled(
588 "[i]",
589 Style::default()
590 .fg(Color::Magenta)
591 .add_modifier(Modifier::BOLD),
592 ),
593 Span::raw(" Toggle Ignore "),
594 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
595 Span::raw(" Quit"),
596 ]),
597 Line::from(vec![
598 Span::styled(
599 "[i] ",
600 Style::default()
601 .fg(Color::Magenta)
602 .add_modifier(Modifier::BOLD),
603 ),
604 Span::styled(
605 "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
606 Style::default().fg(Color::DarkGray),
607 ),
608 ]),
609 ]
610 };
611
612 footer_lines.push(Line::from(Span::styled(
616 constants::ATTRIBUTION_LINE,
617 Style::default().fg(Color::DarkGray),
618 )));
619
620 let footer =
621 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
622 Style::default().fg(if is_prune_mode {
623 Color::Yellow
624 } else {
625 Color::Green
626 }),
627 ));
628 frame.render_widget(footer, outer[2]);
629}
630
631pub fn render_status_plain(repos: &[RepoStatusEntry]) {
635 use crate::output;
636
637 output::print_header("dev-prune status");
638 println!(
639 "\n {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
640 "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
641 );
642 println!(" {}", "─".repeat(118));
643
644 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
645 for (i, repo) in repos.iter().enumerate() {
646 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
647 let reason = repo.reason.to_string();
648 let adapters = if repo.adapters.is_empty() {
649 "—".to_string()
650 } else {
651 repo.adapters.join("+")
652 };
653 let bloat = if repo.reclaimable_bytes > 0 {
654 format_bytes(repo.reclaimable_bytes)
655 } else {
656 "—".to_string()
657 };
658 let activity = repo
659 .last_activity
660 .map(|d| d.format("%Y-%m-%d").to_string())
661 .unwrap_or_else(|| "—".to_string());
662 let pruned = repo
663 .entry
664 .last_pruned_at
665 .map(|d| d.format("%Y-%m-%d").to_string())
666 .unwrap_or_else(|| "Never".to_string());
667
668 println!(
669 " {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
670 i + 1,
671 path_str,
672 reason,
673 adapters,
674 bloat,
675 activity,
676 pruned
677 );
678 }
679 println!(" {}", "─".repeat(118));
680
681 let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
682 let candidates = repos
683 .iter()
684 .filter(|r| matches!(r.reason, SkipReason::Candidate))
685 .count();
686 output::print_info(&format!(
687 "Total: {} repos | {} candidates | {} reclaimable",
688 repos.len(),
689 candidates,
690 format_bytes(total)
691 ));
692
693 let shared: u64 = repos
697 .iter()
698 .flat_map(|r| &r.bloat_dirs)
699 .map(|b| b.shared_bytes)
700 .sum();
701 if shared > 0 {
702 output::print_info(&format!(
703 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
704 node_modules does not free those bytes, the store keeps them.",
705 format_bytes(shared)
706 ));
707 }
708}
709
710#[cfg(test)]
713mod tests {
714 use ratatui::style::Color;
715
716 use crate::engine::SkipReason;
717
718 use super::reason_color;
719
720 #[test]
721 fn test_row_style_logic() {
722 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
723 assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
724 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
726 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
727 }
728}