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
33#[derive(Clone, Copy, PartialEq, Eq)]
40enum SortKey {
41 Default,
42 Size,
44 Activity,
46 Name,
48}
49
50impl SortKey {
51 fn next(self) -> Self {
52 match self {
53 SortKey::Default => SortKey::Size,
54 SortKey::Size => SortKey::Activity,
55 SortKey::Activity => SortKey::Name,
56 SortKey::Name => SortKey::Default,
57 }
58 }
59
60 fn label(self) -> &'static str {
61 match self {
62 SortKey::Default => "relevance",
63 SortKey::Size => "size ↓",
64 SortKey::Activity => "idle longest ↓",
65 SortKey::Name => "name ↑",
66 }
67 }
68}
69
70#[derive(Clone, Copy, PartialEq, Eq)]
72enum Filter {
73 All,
74 Candidates,
76 WithBloat,
79 Problems,
82}
83
84impl Filter {
85 fn next(self) -> Self {
86 match self {
87 Filter::All => Filter::Candidates,
88 Filter::Candidates => Filter::WithBloat,
89 Filter::WithBloat => Filter::Problems,
90 Filter::Problems => Filter::All,
91 }
92 }
93
94 fn label(self) -> &'static str {
95 match self {
96 Filter::All => "all",
97 Filter::Candidates => "candidates",
98 Filter::WithBloat => "has bloat",
99 Filter::Problems => "problems",
100 }
101 }
102
103 fn accepts(self, repo: &RepoStatusEntry) -> bool {
104 match self {
105 Filter::All => true,
106 Filter::Candidates => matches!(repo.reason, SkipReason::Candidate),
107 Filter::WithBloat => repo.reclaimable_bytes > 0,
108 Filter::Problems => matches!(
109 repo.reason,
110 SkipReason::PathMissing | SkipReason::ConfigError(_)
111 ),
112 }
113 }
114}
115
116struct StatusApp<'a> {
117 repos: &'a [RepoStatusEntry],
118 view: Vec<usize>,
125 table_state: TableState,
126 selected: Vec<bool>,
128 mode: ViewMode,
129 sort: SortKey,
130 filter: Filter,
131 search: String,
133 searching: bool,
135 confirmed_indices: Option<Vec<usize>>,
137 pub should_reload: bool,
139}
140
141impl<'a> StatusApp<'a> {
142 fn new(repos: &'a [RepoStatusEntry]) -> Self {
143 let selected = vec![false; repos.len()];
144 let mut table_state = TableState::default();
145 table_state.select(Some(0));
146 let mut app = Self {
147 repos,
148 view: Vec::new(),
149 table_state,
150 selected,
151 mode: ViewMode::Browse,
152 sort: SortKey::Default,
153 filter: Filter::All,
154 search: String::new(),
155 searching: false,
156 confirmed_indices: None,
157 should_reload: false,
158 };
159 app.rebuild_view();
160 app
161 }
162
163 fn rebuild_view(&mut self) {
170 let anchor = self.cursor_repo();
171 let needle = self.search.to_lowercase();
172
173 let mut view: Vec<usize> = (0..self.repos.len())
174 .filter(|&i| {
175 let repo = &self.repos[i];
176 if !self.filter.accepts(repo) {
177 return false;
178 }
179 if needle.is_empty() {
180 return true;
181 }
182 repo.path.to_string_lossy().to_lowercase().contains(&needle)
185 || repo
186 .adapters
187 .iter()
188 .any(|a| a.to_lowercase().contains(&needle))
189 })
190 .collect();
191
192 match self.sort {
193 SortKey::Default => {}
196 SortKey::Size => view.sort_by(|&a, &b| {
197 self.repos[b]
198 .reclaimable_bytes
199 .cmp(&self.repos[a].reclaimable_bytes)
200 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
201 }),
202 SortKey::Activity => view.sort_by(|&a, &b| {
205 let key = |i: usize| {
206 self.repos[i]
207 .last_activity
208 .map(|t| (0, t))
209 .unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
210 };
211 key(a)
212 .cmp(&key(b))
213 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
214 }),
215 SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
216 }
217
218 self.view = view;
219 let row = anchor
220 .and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
221 .unwrap_or(0);
222 self.table_state
225 .select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
226 }
227
228 fn cursor_repo(&self) -> Option<usize> {
230 self.view.get(self.table_state.selected()?).copied()
231 }
232
233 fn move_up(&mut self) {
234 let i = match self.table_state.selected() {
235 Some(i) => {
236 if i == 0 {
237 self.view.len().saturating_sub(1)
238 } else {
239 i - 1
240 }
241 }
242 None => 0,
243 };
244 self.table_state.select(Some(i));
245 }
246
247 fn move_down(&mut self) {
248 let i = match self.table_state.selected() {
249 Some(i) => {
250 if i >= self.view.len().saturating_sub(1) {
251 0
252 } else {
253 i + 1
254 }
255 }
256 None => 0,
257 };
258 self.table_state.select(Some(i));
259 }
260
261 fn toggle_current(&mut self) {
262 if let Some(i) = self.cursor_repo() {
263 if matches!(self.repos[i].reason, SkipReason::Candidate) {
265 self.selected[i] = !self.selected[i];
266 }
267 }
268 }
269
270 fn toggle_all_candidates(&mut self) {
276 let visible: Vec<usize> = self
277 .view
278 .iter()
279 .copied()
280 .filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
281 .collect();
282 let any_selected = visible.iter().any(|&i| self.selected[i]);
283 for i in visible {
284 self.selected[i] = !any_selected;
285 }
286 }
287
288 fn confirm_prune(&mut self) {
289 let indices: Vec<usize> = self
290 .selected
291 .iter()
292 .enumerate()
293 .filter(|&(_, s)| *s)
294 .map(|(i, _)| i)
295 .collect();
296 self.confirmed_indices = Some(indices);
297 }
298
299 fn selected_bytes(&self) -> u64 {
300 self.repos
301 .iter()
302 .enumerate()
303 .filter(|(i, _)| self.selected[*i])
304 .map(|(_, r)| r.reclaimable_bytes)
305 .sum()
306 }
307
308 fn selected_count(&self) -> usize {
309 self.selected.iter().filter(|&&s| s).count()
310 }
311
312 fn candidate_count(&self) -> usize {
313 self.repos
314 .iter()
315 .filter(|r| matches!(r.reason, SkipReason::Candidate))
316 .count()
317 }
318}
319
320fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
331 let ready = repos
332 .iter()
333 .filter(|r| matches!(r.reason, SkipReason::Candidate))
334 .map(|r| r.reclaimable_bytes)
335 .sum();
336 let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
337 (ready, total)
338}
339
340pub fn render_status_tui(
351 repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
352) -> Result<Option<Vec<PathBuf>>> {
353 loop {
354 let repos = repos_loader();
355
356 if repos.is_empty() {
357 return Ok(None);
358 }
359
360 let mut app = StatusApp::new(&repos);
361 {
362 let mut tui = Tui::new()?;
365 tui.drain_stale_input(Duration::from_millis(100));
366 run_status_loop(&mut tui.terminal, &mut app)?;
367 }
368
369 if app.should_reload {
370 continue;
372 }
373
374 return Ok(app
377 .confirmed_indices
378 .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
379 }
380}
381
382fn run_status_loop(
383 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
384 app: &mut StatusApp,
385) -> Result<()> {
386 loop {
387 terminal.draw(|frame| render_ui(frame, app))?;
388
389 if event::poll(Duration::from_millis(100))?
390 && let Event::Key(key) = event::read()?
391 {
392 if key.kind == KeyEventKind::Release {
393 continue;
394 }
395 if key.modifiers.contains(KeyModifiers::CONTROL)
398 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
399 {
400 return Ok(());
401 }
402 if app.searching {
406 match key.code {
407 KeyCode::Char(c) => {
408 app.search.push(c);
409 app.rebuild_view();
410 }
411 KeyCode::Backspace => {
412 app.search.pop();
413 app.rebuild_view();
414 }
415 KeyCode::Enter => app.searching = false,
419 KeyCode::Esc => {
420 app.searching = false;
421 app.search.clear();
422 app.rebuild_view();
423 }
424 _ => {}
425 }
426 continue;
427 }
428 match key.code {
429 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
430 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
431 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
432 KeyCode::End | KeyCode::Char('G') => {
433 app.table_state
434 .select(Some(app.view.len().saturating_sub(1)));
435 }
436 KeyCode::PageUp => {
437 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
438 app.table_state.select(Some(i));
439 }
440 KeyCode::PageDown => {
441 let i = (app.table_state.selected().unwrap_or(0) + 10)
442 .min(app.view.len().saturating_sub(1));
443 app.table_state.select(Some(i));
444 }
445 KeyCode::Char('s') | KeyCode::Char('S') => {
446 app.sort = app.sort.next();
447 app.rebuild_view();
448 }
449 KeyCode::Char('f') | KeyCode::Char('F') => {
450 app.filter = app.filter.next();
451 app.rebuild_view();
452 }
453 KeyCode::Char('/') => app.searching = true,
454 KeyCode::Char(' ') => {
455 if matches!(app.mode, ViewMode::PruneSelect) {
456 app.toggle_current();
457 }
458 }
459 KeyCode::Char('a') | KeyCode::Char('A') => {
460 if matches!(app.mode, ViewMode::PruneSelect) {
461 app.toggle_all_candidates();
462 }
463 }
464 KeyCode::Char('p') | KeyCode::Char('P') => {
465 app.mode = ViewMode::PruneSelect;
466 for i in app.view.clone() {
470 if matches!(app.repos[i].reason, SkipReason::Candidate) {
471 app.selected[i] = true;
472 }
473 }
474 }
475 KeyCode::Char('i') | KeyCode::Char('I') => {
476 if let Some(idx) = app.cursor_repo() {
478 let repo = &app.repos[idx];
479 if matches!(repo.reason, SkipReason::PathMissing) {
483 continue;
484 }
485 let mut per_repo =
490 crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
491 .map_err(|e| anyhow::anyhow!(e))
492 .with_context(|| {
493 format!(
494 "Could not toggle ignore for {}",
495 crate::output::clean_path(&repo.path)
496 )
497 })?
498 .unwrap_or_default();
499 per_repo.ignore = !per_repo.ignore;
500 per_repo.save_to_repo(&repo.path).with_context(|| {
505 format!(
506 "Could not write the config for {}",
507 crate::output::clean_path(&repo.path)
508 )
509 })?;
510
511 let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
514 if legacy_ignore.exists() {
515 std::fs::remove_file(&legacy_ignore).with_context(|| {
516 format!(
517 "Could not remove {}",
518 crate::output::clean_path(&legacy_ignore)
519 )
520 })?;
521 }
522
523 app.should_reload = true;
525 return Ok(());
526 }
527 }
528 KeyCode::Enter => {
529 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
530 app.confirm_prune();
531 return Ok(());
532 }
533 }
534 KeyCode::Esc => {
535 if matches!(app.mode, ViewMode::PruneSelect) {
536 app.mode = ViewMode::Browse;
538 app.selected.fill(false);
539 } else {
540 return Ok(());
541 }
542 }
543 KeyCode::Char('q') => return Ok(()),
544 _ => {}
545 }
546 }
547 }
548}
549
550fn reason_color(reason: &SkipReason) -> Color {
555 match reason {
556 SkipReason::Candidate => Color::Green,
557 SkipReason::Active => Color::Reset,
558 SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
559 SkipReason::PathMissing => Color::Red,
560 SkipReason::ConfigError(_) => Color::Yellow,
562 }
563}
564
565fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
566 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
567
568 let outer = Layout::default()
569 .direction(Direction::Vertical)
570 .constraints([
571 Constraint::Length(3), Constraint::Min(5), Constraint::Length(7), ])
579 .split(frame.area());
580
581 let mode_label = if is_prune_mode {
583 Span::styled(
584 " PRUNE-SELECT MODE ",
585 Style::default()
586 .bg(Color::Yellow)
587 .fg(Color::Black)
588 .add_modifier(Modifier::BOLD),
589 )
590 } else {
591 Span::styled(
592 " BROWSE MODE ",
593 Style::default()
594 .bg(Color::Cyan)
595 .fg(Color::Black)
596 .add_modifier(Modifier::BOLD),
597 )
598 };
599
600 let (ready, total) = reclaimable_split(app.repos);
601 let header_line = Line::from(vec![
602 Span::styled(
603 " dev-prune ",
604 Style::default()
605 .fg(Color::Black)
606 .bg(Color::Green)
607 .add_modifier(Modifier::BOLD),
608 ),
609 Span::raw(" "),
610 mode_label,
611 Span::styled(
612 format!(
613 " {} | {} candidates | ",
614 if app.view.len() == app.repos.len() {
618 format!("{} repos", app.repos.len())
619 } else {
620 format!("showing {} of {} repos", app.view.len(), app.repos.len())
621 },
622 app.candidate_count(),
623 ),
624 Style::default().fg(Color::DarkGray),
625 ),
626 Span::styled(
629 format!("{} ready now", format_bytes(ready)),
630 Style::default()
631 .fg(Color::Green)
632 .add_modifier(Modifier::BOLD),
633 ),
634 Span::styled(
635 format!(" | {} reclaimable in all", format_bytes(total)),
636 Style::default().fg(Color::DarkGray),
637 ),
638 ]);
639
640 let header_widget =
641 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
642 Style::default().fg(if is_prune_mode {
643 Color::Yellow
644 } else {
645 Color::Cyan
646 }),
647 ));
648 frame.render_widget(header_widget, outer[0]);
649
650 let col_headers = Row::new(vec![
652 Cell::from(if is_prune_mode { "Sel" } else { "#" })
653 .style(Style::default().add_modifier(Modifier::BOLD)),
654 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
655 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
656 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
657 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
658 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
659 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
660 ])
661 .height(1)
662 .bottom_margin(1)
663 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
667
668 let highlighted_row = app.table_state.selected();
672
673 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
677
678 let rows: Vec<Row> = app
679 .view
680 .iter()
681 .enumerate()
682 .map(|(row, &i)| {
683 let repo = &app.repos[i];
684 let is_selected = app.selected[i];
685 let color = reason_color(&repo.reason);
686
687 let sel_cell = if is_prune_mode {
688 if matches!(repo.reason, SkipReason::Candidate) {
689 if is_selected {
690 Cell::from("[x]").style(
691 Style::default()
692 .fg(Color::Green)
693 .add_modifier(Modifier::BOLD),
694 )
695 } else {
696 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
697 }
698 } else {
699 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
700 }
701 } else {
702 Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
703 };
704
705 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
706
707 let reason_str = repo.reason.to_string();
708 let adapters_str = if repo.adapters.is_empty() {
709 "—".to_string()
710 } else {
711 repo.adapters.join(", ")
712 };
713 let bloat_str = if repo.reclaimable_bytes > 0 {
714 format_bytes(repo.reclaimable_bytes)
715 } else {
716 "—".to_string()
717 };
718 let bloat_color = if repo.reclaimable_bytes > 0 {
721 Color::Green
722 } else {
723 Color::DarkGray
724 };
725 let activity_str = repo
726 .last_activity
727 .map(|d| d.format("%Y-%m-%d").to_string())
728 .unwrap_or_else(|| "—".to_string());
729 let pruned_str = repo
730 .entry
731 .last_pruned_at
732 .map(|d| d.format("%Y-%m-%d").to_string())
733 .unwrap_or_else(|| "Never".to_string());
734
735 let row_style = if is_selected {
736 Style::default().bg(Color::Rgb(20, 50, 30))
737 } else {
738 Style::default()
739 };
740
741 let on_dark_bg = is_selected || highlighted_row == Some(i);
742 let path_style = if on_dark_bg {
743 Style::default().fg(Color::White)
744 } else {
745 Style::default()
746 };
747 let date_color = if on_dark_bg {
748 Color::Gray
749 } else {
750 Color::DarkGray
751 };
752
753 Row::new(vec![
754 sel_cell,
755 Cell::from(path_str).style(path_style),
756 Cell::from(reason_str).style(Style::default().fg(color)),
757 Cell::from(adapters_str),
758 Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
759 Cell::from(activity_str).style(Style::default().fg(date_color)),
760 Cell::from(pruned_str).style(Style::default().fg(date_color)),
761 ])
762 .style(row_style)
763 })
764 .collect();
765
766 let table = Table::new(
767 rows,
768 [
769 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
777 )
778 .header(col_headers)
779 .block(
780 Block::default()
781 .title(" Registered Repositories ")
782 .borders(Borders::ALL)
783 .border_style(Style::default().fg(Color::DarkGray)),
784 )
785 .row_highlight_style(
786 Style::default()
787 .bg(Color::Rgb(30, 40, 70))
788 .add_modifier(Modifier::BOLD),
789 )
790 .highlight_symbol("▶ ");
791
792 frame.render_stateful_widget(table, outer[1], &mut app.table_state);
796
797 let mut footer_lines = if is_prune_mode {
799 vec![
800 Line::from(vec![
801 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
802 Span::styled(
807 format!(
808 "{} of {} candidates ",
809 app.selected_count(),
810 app.candidate_count()
811 ),
812 Style::default().add_modifier(Modifier::BOLD),
813 ),
814 Span::styled(
815 format!("({})", format_bytes(app.selected_bytes())),
816 Style::default()
817 .fg(Color::Green)
818 .add_modifier(Modifier::BOLD),
819 ),
820 ]),
821 Line::from(vec![
822 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
823 Span::raw(" Navigate "),
824 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
825 Span::raw(" Jump "),
826 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
827 Span::raw(" Toggle "),
828 Span::styled("[a]", Style::default().fg(Color::Cyan)),
829 Span::raw(" Toggle All "),
830 Span::styled(
831 "[Enter]",
832 Style::default()
833 .fg(Color::Green)
834 .add_modifier(Modifier::BOLD),
835 ),
836 Span::raw(" Prune Selected "),
837 Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
838 Span::raw(" Back to Browse "),
839 Span::styled("[q]", Style::default().fg(Color::Cyan)),
840 Span::raw(" Quit"),
841 ]),
842 Line::from(vec![]),
843 ]
844 } else {
845 vec![
846 Line::from(vec![
847 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
848 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
849 Span::raw(" "),
850 Span::styled("■ Active", Style::default().fg(Color::Reset)),
851 Span::raw(" "),
852 Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
855 Span::raw(" "),
856 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
857 ]),
858 Line::from(vec![
859 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
860 Span::raw(" Navigate "),
861 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
862 Span::raw(" Jump "),
863 Span::styled(
868 "[p]",
869 Style::default()
870 .fg(Color::Cyan)
871 .add_modifier(Modifier::BOLD),
872 ),
873 Span::raw(" Prune-Select Mode "),
874 Span::styled(
875 "[i]",
876 Style::default()
877 .fg(Color::Cyan)
878 .add_modifier(Modifier::BOLD),
879 ),
880 Span::raw(" Toggle Ignore "),
881 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
882 Span::raw(" Quit"),
883 ]),
884 Line::from(vec![
885 Span::styled(
886 "[i] ",
887 Style::default()
888 .fg(Color::Cyan)
889 .add_modifier(Modifier::BOLD),
890 ),
891 Span::styled(
892 "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
893 Style::default().fg(Color::DarkGray),
894 ),
895 ]),
896 ]
897 };
898
899 let mut state_line = vec![
903 Span::styled(
904 "[s]",
905 Style::default()
906 .fg(Color::Cyan)
907 .add_modifier(Modifier::BOLD),
908 ),
909 Span::raw(" Sort: "),
910 Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
911 Span::raw(" "),
912 Span::styled(
913 "[f]",
914 Style::default()
915 .fg(Color::Cyan)
916 .add_modifier(Modifier::BOLD),
917 ),
918 Span::raw(" Filter: "),
919 Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
920 Span::raw(" "),
921 Span::styled(
922 "[/]",
923 Style::default()
924 .fg(Color::Cyan)
925 .add_modifier(Modifier::BOLD),
926 ),
927 Span::raw(" Search: "),
928 ];
929 if app.searching {
930 state_line.push(Span::styled(
933 format!("{}█", app.search),
934 Style::default()
935 .fg(Color::Yellow)
936 .add_modifier(Modifier::BOLD),
937 ));
938 state_line.push(Span::styled(
939 " (Enter to keep, Esc to clear)",
940 Style::default().fg(Color::DarkGray),
941 ));
942 } else if app.search.is_empty() {
943 state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
944 } else {
945 state_line.push(Span::styled(
946 app.search.clone(),
947 Style::default().fg(Color::Yellow),
948 ));
949 }
950 if app.view.is_empty() {
951 state_line.push(Span::styled(
954 " no repositories match",
955 Style::default().fg(Color::Red),
956 ));
957 }
958 footer_lines.push(Line::from(state_line));
959
960 footer_lines.push(Line::from(Span::styled(
964 constants::ATTRIBUTION_LINE,
965 Style::default().fg(Color::DarkGray),
966 )));
967
968 let footer =
969 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
970 Style::default().fg(if is_prune_mode {
971 Color::Yellow
972 } else {
973 Color::Green
974 }),
975 ));
976 frame.render_widget(footer, outer[2]);
977}
978
979pub fn render_status_plain(repos: &[RepoStatusEntry]) {
983 use crate::output;
984 use colored::Colorize;
985
986 output::print_header("dev-prune status");
987 println!(
992 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
993 "#",
994 output::pad_display("Repository", 35),
995 "Status / Reason",
996 "Adapters",
997 "Bloat",
998 "Last Activity",
999 "Last Pruned"
1000 );
1001 println!(" {}", "─".repeat(122));
1002
1003 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1004 for (i, repo) in repos.iter().enumerate() {
1005 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1006 let reason = repo.reason.to_string();
1007 let adapters = if repo.adapters.is_empty() {
1008 "—".to_string()
1009 } else {
1010 repo.adapters.join("+")
1011 };
1012 let bloat = if repo.reclaimable_bytes > 0 {
1013 format_bytes(repo.reclaimable_bytes)
1014 } else {
1015 "—".to_string()
1016 };
1017 let activity = repo
1018 .last_activity
1019 .map(|d| d.format("%Y-%m-%d").to_string())
1020 .unwrap_or_else(|| "—".to_string());
1021 let pruned = repo
1022 .entry
1023 .last_pruned_at
1024 .map(|d| d.format("%Y-%m-%d").to_string())
1025 .unwrap_or_else(|| "Never".to_string());
1026
1027 let reason_cell = format!("{reason:<22}");
1044 let reason_cell = match &repo.reason {
1045 SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1046 SkipReason::Active => reason_cell,
1047 SkipReason::Ignored | SkipReason::NoBloat => {
1048 Colorize::dimmed(reason_cell.as_str()).to_string()
1049 }
1050 SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1051 SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1052 };
1053 let bloat_cell = if repo.reclaimable_bytes > 0 {
1056 Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1057 } else {
1058 format!("{bloat:<12}")
1059 };
1060 println!(
1061 " {:>3} {} {} {:<12} {} {:<13} {:<13}",
1062 i + 1,
1063 output::pad_display(&path_str, 35),
1064 reason_cell,
1065 adapters,
1066 bloat_cell,
1067 activity,
1068 pruned
1069 );
1070 }
1071 println!(" {}", "─".repeat(122));
1072
1073 let (ready, total) = reclaimable_split(repos);
1074 let candidates = repos
1075 .iter()
1076 .filter(|r| matches!(r.reason, SkipReason::Candidate))
1077 .count();
1078 output::print_info(&format!(
1082 "Total: {} repos | {} candidates | {} ready now | {} reclaimable in all",
1083 repos.len(),
1084 candidates,
1085 output::format_bytes_styled(ready),
1086 output::format_bytes(total)
1087 ));
1088
1089 let shared: u64 = repos
1093 .iter()
1094 .flat_map(|r| &r.bloat_dirs)
1095 .map(|b| b.shared_bytes)
1096 .sum();
1097 if shared > 0 {
1098 output::print_info(&format!(
1099 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1100 node_modules does not free those bytes, the store keeps them.",
1101 format_bytes(shared)
1102 ));
1103 }
1104}
1105
1106#[cfg(test)]
1109mod tests {
1110 use ratatui::style::Color;
1111
1112 use crate::engine::SkipReason;
1113
1114 use super::*;
1115 use crate::engine::RepoStatusEntry;
1116
1117 #[test]
1118 fn test_row_style_logic() {
1119 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1120 assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1121 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1123 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1124 assert_eq!(
1125 reason_color(&SkipReason::ConfigError(String::new())),
1126 Color::Yellow
1127 );
1128 }
1129
1130 fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1132 RepoStatusEntry {
1133 path: std::path::PathBuf::from(path),
1134 entry: crate::config::RepoEntry::new(),
1135 reason,
1136 adapters: vec!["uv".to_string()],
1137 bloat_dirs: Vec::new(),
1138 reclaimable_bytes: bytes,
1139 reclaimable_by_adapter: Vec::new(),
1140 last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1141 idle_days: 30,
1142 }
1143 }
1144
1145 fn sample() -> Vec<RepoStatusEntry> {
1146 vec![
1147 entry("/code/alpha", SkipReason::Candidate, 500, 90),
1148 entry("/code/beta", SkipReason::Active, 9_000, 1),
1149 entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1150 entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1151 ]
1152 }
1153
1154 #[test]
1155 fn the_default_view_is_every_row_in_the_order_it_arrived() {
1156 let repos = sample();
1157 let app = StatusApp::new(&repos);
1158 assert_eq!(app.view, vec![0, 1, 2, 3]);
1159 }
1160
1161 #[test]
1162 fn sorting_by_size_puts_the_biggest_reclaim_first() {
1163 let repos = sample();
1164 let mut app = StatusApp::new(&repos);
1165 app.sort = SortKey::Size;
1166 app.rebuild_view();
1167 assert_eq!(app.view, vec![1, 3, 0, 2]);
1168 }
1169
1170 #[test]
1171 fn sorting_by_activity_puts_the_longest_untouched_first() {
1172 let repos = sample();
1173 let mut app = StatusApp::new(&repos);
1174 app.sort = SortKey::Activity;
1175 app.rebuild_view();
1176 assert_eq!(app.view, vec![2, 0, 3, 1]);
1177 }
1178
1179 #[test]
1180 fn filters_narrow_the_view_without_disturbing_the_selection() {
1181 let repos = sample();
1182 let mut app = StatusApp::new(&repos);
1183 app.mode = ViewMode::PruneSelect;
1184 app.selected[3] = true;
1185
1186 app.filter = Filter::Candidates;
1187 app.rebuild_view();
1188 assert_eq!(app.view, vec![0, 3]);
1189
1190 app.filter = Filter::Problems;
1191 app.rebuild_view();
1192 assert_eq!(app.view, vec![2]);
1193
1194 assert!(app.selected[3]);
1197 assert_eq!(app.selected_count(), 1);
1198 }
1199
1200 #[test]
1201 fn a_search_matches_the_path_and_the_adapters() {
1202 let repos = sample();
1203 let mut app = StatusApp::new(&repos);
1204
1205 app.search = "ELT".to_string();
1206 app.rebuild_view();
1207 assert_eq!(app.view, vec![3], "the match is case-insensitive");
1208
1209 app.search = "uv".to_string();
1210 app.rebuild_view();
1211 assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1212
1213 app.search = "nothing-matches-this".to_string();
1214 app.rebuild_view();
1215 assert!(app.view.is_empty());
1216 assert_eq!(app.cursor_repo(), None);
1219 }
1220
1221 #[test]
1222 fn the_cursor_follows_its_repository_through_a_filter() {
1223 let repos = sample();
1224 let mut app = StatusApp::new(&repos);
1225 app.table_state.select(Some(3)); assert_eq!(app.cursor_repo(), Some(3));
1227
1228 app.filter = Filter::Candidates;
1229 app.rebuild_view();
1230 assert_eq!(app.cursor_repo(), Some(3));
1232 }
1233
1234 #[test]
1235 fn toggle_all_is_scoped_to_what_is_on_screen() {
1236 let repos = sample();
1237 let mut app = StatusApp::new(&repos);
1238 app.search = "alpha".to_string();
1239 app.rebuild_view();
1240 app.toggle_all_candidates();
1241
1242 assert!(app.selected[0]);
1243 assert!(
1244 !app.selected[3],
1245 "delta was filtered out and must not be armed"
1246 );
1247 }
1248
1249 #[test]
1250 fn the_header_separates_what_is_prunable_now_from_everything() {
1251 let repos = sample();
1255 let (ready, total) = reclaimable_split(&repos);
1256 assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1257 assert_eq!(total, 11_500, "every registered repository");
1258 }
1259
1260 #[test]
1261 fn a_filter_never_changes_the_header_totals() {
1262 let repos = sample();
1265 let mut app = StatusApp::new(&repos);
1266 let before = reclaimable_split(app.repos);
1267
1268 app.filter = Filter::Problems;
1269 app.rebuild_view();
1270 assert_eq!(app.view.len(), 1);
1271 assert_eq!(reclaimable_split(app.repos), before);
1272 }
1273}