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)));
403
404 let rows: Vec<Row> = app
405 .repos
406 .iter()
407 .enumerate()
408 .map(|(i, repo)| {
409 let is_selected = app.selected[i];
410 let color = reason_color(&repo.reason);
411
412 let sel_cell = if is_prune_mode {
413 if matches!(repo.reason, SkipReason::Candidate) {
414 if is_selected {
415 Cell::from("[x]").style(
416 Style::default()
417 .fg(Color::Green)
418 .add_modifier(Modifier::BOLD),
419 )
420 } else {
421 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
422 }
423 } else {
424 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
425 }
426 } else {
427 Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
428 };
429
430 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
431 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
432
433 let reason_str = repo.reason.to_string();
434 let adapters_str = if repo.adapters.is_empty() {
435 "—".to_string()
436 } else {
437 repo.adapters.join(", ")
438 };
439 let bloat_str = if repo.reclaimable_bytes > 0 {
440 format_bytes(repo.reclaimable_bytes)
441 } else {
442 "—".to_string()
443 };
444 let activity_str = repo
445 .last_activity
446 .map(|d| d.format("%Y-%m-%d").to_string())
447 .unwrap_or_else(|| "—".to_string());
448 let pruned_str = repo
449 .entry
450 .last_pruned_at
451 .map(|d| d.format("%Y-%m-%d").to_string())
452 .unwrap_or_else(|| "Never".to_string());
453
454 let row_style = if is_selected {
455 Style::default().bg(Color::Rgb(20, 50, 30))
456 } else {
457 Style::default()
458 };
459
460 Row::new(vec![
461 sel_cell,
462 Cell::from(path_str).style(Style::default().fg(Color::White)),
463 Cell::from(reason_str).style(Style::default().fg(color)),
464 Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
465 Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
466 Cell::from(activity_str).style(Style::default().fg(Color::Gray)),
467 Cell::from(pruned_str).style(Style::default().fg(Color::Gray)),
468 ])
469 .style(row_style)
470 })
471 .collect();
472
473 let table = Table::new(
474 rows,
475 [
476 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
484 )
485 .header(col_headers)
486 .block(
487 Block::default()
488 .title(" Registered Repositories ")
489 .borders(Borders::ALL)
490 .border_style(Style::default().fg(Color::Gray)),
491 )
492 .row_highlight_style(
493 Style::default()
494 .bg(Color::Rgb(30, 40, 70))
495 .add_modifier(Modifier::BOLD),
496 )
497 .highlight_symbol("▶ ");
498
499 frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
500
501 let mut footer_lines = if is_prune_mode {
503 vec![
504 Line::from(vec![
505 Span::styled("Selected: ", Style::default().fg(Color::Gray)),
506 Span::styled(
507 format!(
508 "{} of {} candidates ({})",
509 app.selected_count(),
510 app.candidate_count(),
511 format_bytes(app.selected_bytes())
512 ),
513 Style::default()
514 .fg(Color::Yellow)
515 .add_modifier(Modifier::BOLD),
516 ),
517 ]),
518 Line::from(vec![
519 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
520 Span::raw(" Navigate "),
521 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
522 Span::raw(" Jump "),
523 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
524 Span::raw(" Toggle "),
525 Span::styled("[a]", Style::default().fg(Color::Cyan)),
526 Span::raw(" Toggle All "),
527 Span::styled(
528 "[Enter]",
529 Style::default()
530 .fg(Color::Green)
531 .add_modifier(Modifier::BOLD),
532 ),
533 Span::raw(" Prune Selected "),
534 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
535 Span::raw(" Back to Browse "),
536 Span::styled("[q]", Style::default().fg(Color::Red)),
537 Span::raw(" Quit"),
538 ]),
539 Line::from(vec![]),
540 ]
541 } else {
542 vec![
543 Line::from(vec![
544 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
545 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
546 Span::raw(" "),
547 Span::styled("■ Active", Style::default().fg(Color::Cyan)),
548 Span::raw(" "),
549 Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
550 Span::raw(" "),
551 Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
552 Span::raw(" "),
553 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
554 ]),
555 Line::from(vec![
556 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
557 Span::raw(" Navigate "),
558 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
559 Span::raw(" Jump "),
560 Span::styled(
561 "[p]",
562 Style::default()
563 .fg(Color::Yellow)
564 .add_modifier(Modifier::BOLD),
565 ),
566 Span::raw(" Prune-Select Mode "),
567 Span::styled(
568 "[i]",
569 Style::default()
570 .fg(Color::Magenta)
571 .add_modifier(Modifier::BOLD),
572 ),
573 Span::raw(" Toggle Ignore "),
574 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
575 Span::raw(" Quit"),
576 ]),
577 Line::from(vec![
578 Span::styled(
579 "[i] ",
580 Style::default()
581 .fg(Color::Magenta)
582 .add_modifier(Modifier::BOLD),
583 ),
584 Span::styled(
585 "toggles `ignore` in `.devprune.json` and updates `.gitignore` — refreshes instantly.",
586 Style::default().fg(Color::DarkGray),
587 ),
588 ]),
589 ]
590 };
591
592 footer_lines.push(Line::from(Span::styled(
596 constants::ATTRIBUTION_LINE,
597 Style::default().fg(Color::DarkGray),
598 )));
599
600 let footer =
601 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
602 Style::default().fg(if is_prune_mode {
603 Color::Yellow
604 } else {
605 Color::Green
606 }),
607 ));
608 frame.render_widget(footer, outer[2]);
609}
610
611pub fn render_status_plain(repos: &[RepoStatusEntry]) {
615 use crate::output;
616
617 output::print_header("dev-prune status");
618 println!(
619 "\n {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
620 "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
621 );
622 println!(" {}", "─".repeat(118));
623
624 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
625 for (i, repo) in repos.iter().enumerate() {
626 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
627 let reason = repo.reason.to_string();
628 let adapters = if repo.adapters.is_empty() {
629 "—".to_string()
630 } else {
631 repo.adapters.join("+")
632 };
633 let bloat = if repo.reclaimable_bytes > 0 {
634 format_bytes(repo.reclaimable_bytes)
635 } else {
636 "—".to_string()
637 };
638 let activity = repo
639 .last_activity
640 .map(|d| d.format("%Y-%m-%d").to_string())
641 .unwrap_or_else(|| "—".to_string());
642 let pruned = repo
643 .entry
644 .last_pruned_at
645 .map(|d| d.format("%Y-%m-%d").to_string())
646 .unwrap_or_else(|| "Never".to_string());
647
648 println!(
649 " {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
650 i + 1,
651 path_str,
652 reason,
653 adapters,
654 bloat,
655 activity,
656 pruned
657 );
658 }
659 println!(" {}", "─".repeat(118));
660
661 let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
662 let candidates = repos
663 .iter()
664 .filter(|r| matches!(r.reason, SkipReason::Candidate))
665 .count();
666 output::print_info(&format!(
667 "Total: {} repos | {} candidates | {} reclaimable",
668 repos.len(),
669 candidates,
670 format_bytes(total)
671 ));
672
673 let shared: u64 = repos
677 .iter()
678 .flat_map(|r| &r.bloat_dirs)
679 .map(|b| b.shared_bytes)
680 .sum();
681 if shared > 0 {
682 output::print_info(&format!(
683 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
684 node_modules does not free those bytes, the store keeps them.",
685 format_bytes(shared)
686 ));
687 }
688}
689
690#[cfg(test)]
693mod tests {
694 use ratatui::style::Color;
695
696 use crate::engine::SkipReason;
697
698 use super::reason_color;
699
700 #[test]
701 fn test_row_style_logic() {
702 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
703 assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
704 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
706 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
707 }
708}