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 layers = crate::config::RepoConfigLayers::load(&repo.path)
490 .map_err(|e| anyhow::anyhow!(e))
491 .with_context(|| {
492 format!(
493 "Could not toggle ignore for {}",
494 crate::output::clean_path(&repo.path)
495 )
496 })?;
497 if layers.source_of("ignore") == crate::config::ConfigSource::Project {
503 continue;
504 }
505 let mut per_repo = layers.personal_config().cloned().unwrap_or_default();
506 per_repo.ignore = !per_repo.ignore;
507 per_repo.save_to_repo(&repo.path).with_context(|| {
512 format!(
513 "Could not write the config for {}",
514 crate::output::clean_path(&repo.path)
515 )
516 })?;
517
518 let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
521 if legacy_ignore.exists() {
522 std::fs::remove_file(&legacy_ignore).with_context(|| {
523 format!(
524 "Could not remove {}",
525 crate::output::clean_path(&legacy_ignore)
526 )
527 })?;
528 }
529
530 app.should_reload = true;
532 return Ok(());
533 }
534 }
535 KeyCode::Enter => {
536 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
537 app.confirm_prune();
538 return Ok(());
539 }
540 }
541 KeyCode::Esc => {
542 if matches!(app.mode, ViewMode::PruneSelect) {
543 app.mode = ViewMode::Browse;
545 app.selected.fill(false);
546 } else {
547 return Ok(());
548 }
549 }
550 KeyCode::Char('q') => return Ok(()),
551 _ => {}
552 }
553 }
554 }
555}
556
557fn reason_color(reason: &SkipReason) -> Color {
562 match reason {
563 SkipReason::Candidate => Color::Green,
564 SkipReason::Active => Color::Reset,
565 SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
566 SkipReason::PathMissing => Color::Red,
567 SkipReason::ConfigError(_) => Color::Yellow,
569 }
570}
571
572fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
573 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
574
575 let outer = Layout::default()
576 .direction(Direction::Vertical)
577 .constraints([
578 Constraint::Length(3), Constraint::Min(5), Constraint::Length(7), ])
586 .split(frame.area());
587
588 let mode_label = if is_prune_mode {
590 Span::styled(
591 " PRUNE-SELECT MODE ",
592 Style::default()
593 .bg(Color::Yellow)
594 .fg(Color::Black)
595 .add_modifier(Modifier::BOLD),
596 )
597 } else {
598 Span::styled(
599 " BROWSE MODE ",
600 Style::default()
601 .bg(Color::Cyan)
602 .fg(Color::Black)
603 .add_modifier(Modifier::BOLD),
604 )
605 };
606
607 let (ready, total) = reclaimable_split(app.repos);
608 let header_line = Line::from(vec![
609 Span::styled(
610 " dev-prune ",
611 Style::default()
612 .fg(Color::Black)
613 .bg(Color::Green)
614 .add_modifier(Modifier::BOLD),
615 ),
616 Span::raw(" "),
617 mode_label,
618 Span::styled(
619 format!(
620 " {} | {} candidates | ",
621 if app.view.len() == app.repos.len() {
625 format!("{} repos", app.repos.len())
626 } else {
627 format!("showing {} of {} repos", app.view.len(), app.repos.len())
628 },
629 app.candidate_count(),
630 ),
631 Style::default().fg(Color::DarkGray),
632 ),
633 Span::styled(
636 format!("{} ready now", format_bytes(ready)),
637 Style::default()
638 .fg(Color::Green)
639 .add_modifier(Modifier::BOLD),
640 ),
641 Span::styled(
642 format!(" | {} reclaimable in all", format_bytes(total)),
643 Style::default().fg(Color::DarkGray),
644 ),
645 ]);
646
647 let header_widget =
648 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
649 Style::default().fg(if is_prune_mode {
650 Color::Yellow
651 } else {
652 Color::Cyan
653 }),
654 ));
655 frame.render_widget(header_widget, outer[0]);
656
657 let col_headers = Row::new(vec![
659 Cell::from(if is_prune_mode { "Sel" } else { "#" })
660 .style(Style::default().add_modifier(Modifier::BOLD)),
661 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
662 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
663 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
664 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
665 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
666 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
667 ])
668 .height(1)
669 .bottom_margin(1)
670 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
674
675 let highlighted_row = app.table_state.selected();
679
680 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
684
685 let rows: Vec<Row> = app
686 .view
687 .iter()
688 .enumerate()
689 .map(|(row, &i)| {
690 let repo = &app.repos[i];
691 let is_selected = app.selected[i];
692 let color = reason_color(&repo.reason);
693
694 let sel_cell = if is_prune_mode {
695 if matches!(repo.reason, SkipReason::Candidate) {
696 if is_selected {
697 Cell::from("[x]").style(
698 Style::default()
699 .fg(Color::Green)
700 .add_modifier(Modifier::BOLD),
701 )
702 } else {
703 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
704 }
705 } else {
706 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
707 }
708 } else {
709 Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
710 };
711
712 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
713
714 let reason_str = repo.reason.to_string();
715 let adapters_str = if repo.adapters.is_empty() {
716 "—".to_string()
717 } else {
718 repo.adapters.join(", ")
719 };
720 let bloat_str = if repo.reclaimable_bytes > 0 {
721 format_bytes(repo.reclaimable_bytes)
722 } else {
723 "—".to_string()
724 };
725 let bloat_color = if repo.reclaimable_bytes > 0 {
728 Color::Green
729 } else {
730 Color::DarkGray
731 };
732 let activity_str = repo
733 .last_activity
734 .map(|d| d.format("%Y-%m-%d").to_string())
735 .unwrap_or_else(|| "—".to_string());
736 let pruned_str = repo
737 .entry
738 .last_pruned_at
739 .map(|d| d.format("%Y-%m-%d").to_string())
740 .unwrap_or_else(|| "Never".to_string());
741
742 let row_style = if is_selected {
743 Style::default().bg(Color::Rgb(20, 50, 30))
744 } else {
745 Style::default()
746 };
747
748 let on_dark_bg = is_selected || highlighted_row == Some(i);
749 let path_style = if on_dark_bg {
750 Style::default().fg(Color::White)
751 } else {
752 Style::default()
753 };
754 let date_color = if on_dark_bg {
755 Color::Gray
756 } else {
757 Color::DarkGray
758 };
759
760 Row::new(vec![
761 sel_cell,
762 Cell::from(path_str).style(path_style),
763 Cell::from(reason_str).style(Style::default().fg(color)),
764 Cell::from(adapters_str),
765 Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
766 Cell::from(activity_str).style(Style::default().fg(date_color)),
767 Cell::from(pruned_str).style(Style::default().fg(date_color)),
768 ])
769 .style(row_style)
770 })
771 .collect();
772
773 let table = Table::new(
774 rows,
775 [
776 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
784 )
785 .header(col_headers)
786 .block(
787 Block::default()
788 .title(" Registered Repositories ")
789 .borders(Borders::ALL)
790 .border_style(Style::default().fg(Color::DarkGray)),
791 )
792 .row_highlight_style(
793 Style::default()
794 .bg(Color::Rgb(30, 40, 70))
795 .add_modifier(Modifier::BOLD),
796 )
797 .highlight_symbol("▶ ");
798
799 frame.render_stateful_widget(table, outer[1], &mut app.table_state);
803
804 let mut footer_lines = if is_prune_mode {
806 vec![
807 Line::from(vec![
808 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
809 Span::styled(
814 format!(
815 "{} of {} candidates ",
816 app.selected_count(),
817 app.candidate_count()
818 ),
819 Style::default().add_modifier(Modifier::BOLD),
820 ),
821 Span::styled(
822 format!("({})", format_bytes(app.selected_bytes())),
823 Style::default()
824 .fg(Color::Green)
825 .add_modifier(Modifier::BOLD),
826 ),
827 ]),
828 Line::from(vec![
829 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
830 Span::raw(" Navigate "),
831 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
832 Span::raw(" Jump "),
833 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
834 Span::raw(" Toggle "),
835 Span::styled("[a]", Style::default().fg(Color::Cyan)),
836 Span::raw(" Toggle All "),
837 Span::styled(
838 "[Enter]",
839 Style::default()
840 .fg(Color::Green)
841 .add_modifier(Modifier::BOLD),
842 ),
843 Span::raw(" Prune Selected "),
844 Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
845 Span::raw(" Back to Browse "),
846 Span::styled("[q]", Style::default().fg(Color::Cyan)),
847 Span::raw(" Quit"),
848 ]),
849 Line::from(vec![]),
850 ]
851 } else {
852 vec![
853 Line::from(vec![
854 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
855 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
856 Span::raw(" "),
857 Span::styled("■ Active", Style::default().fg(Color::Reset)),
858 Span::raw(" "),
859 Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
862 Span::raw(" "),
863 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
864 ]),
865 Line::from(vec![
866 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
867 Span::raw(" Navigate "),
868 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
869 Span::raw(" Jump "),
870 Span::styled(
875 "[p]",
876 Style::default()
877 .fg(Color::Cyan)
878 .add_modifier(Modifier::BOLD),
879 ),
880 Span::raw(" Prune-Select Mode "),
881 Span::styled(
882 "[i]",
883 Style::default()
884 .fg(Color::Cyan)
885 .add_modifier(Modifier::BOLD),
886 ),
887 Span::raw(" Toggle Ignore "),
888 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
889 Span::raw(" Quit"),
890 ]),
891 Line::from(vec![
892 Span::styled(
893 "[i] ",
894 Style::default()
895 .fg(Color::Cyan)
896 .add_modifier(Modifier::BOLD),
897 ),
898 Span::styled(
899 "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly. Inert where a committed `project.devprune.json` sets `ignore`; edit that file instead.",
900 Style::default().fg(Color::DarkGray),
901 ),
902 ]),
903 ]
904 };
905
906 let mut state_line = vec![
910 Span::styled(
911 "[s]",
912 Style::default()
913 .fg(Color::Cyan)
914 .add_modifier(Modifier::BOLD),
915 ),
916 Span::raw(" Sort: "),
917 Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
918 Span::raw(" "),
919 Span::styled(
920 "[f]",
921 Style::default()
922 .fg(Color::Cyan)
923 .add_modifier(Modifier::BOLD),
924 ),
925 Span::raw(" Filter: "),
926 Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
927 Span::raw(" "),
928 Span::styled(
929 "[/]",
930 Style::default()
931 .fg(Color::Cyan)
932 .add_modifier(Modifier::BOLD),
933 ),
934 Span::raw(" Search: "),
935 ];
936 if app.searching {
937 state_line.push(Span::styled(
940 format!("{}█", app.search),
941 Style::default()
942 .fg(Color::Yellow)
943 .add_modifier(Modifier::BOLD),
944 ));
945 state_line.push(Span::styled(
946 " (Enter to keep, Esc to clear)",
947 Style::default().fg(Color::DarkGray),
948 ));
949 } else if app.search.is_empty() {
950 state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
951 } else {
952 state_line.push(Span::styled(
953 app.search.clone(),
954 Style::default().fg(Color::Yellow),
955 ));
956 }
957 if app.view.is_empty() {
958 state_line.push(Span::styled(
961 " no repositories match",
962 Style::default().fg(Color::Red),
963 ));
964 }
965 footer_lines.push(Line::from(state_line));
966
967 footer_lines.push(Line::from(Span::styled(
971 constants::ATTRIBUTION_LINE,
972 Style::default().fg(Color::DarkGray),
973 )));
974
975 let footer =
976 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
977 Style::default().fg(if is_prune_mode {
978 Color::Yellow
979 } else {
980 Color::Green
981 }),
982 ));
983 frame.render_widget(footer, outer[2]);
984}
985
986pub fn render_status_plain(repos: &[RepoStatusEntry]) {
990 use crate::output;
991 use colored::Colorize;
992
993 output::print_header("dev-prune status");
994 println!(
999 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
1000 "#",
1001 output::pad_display("Repository", 35),
1002 "Status / Reason",
1003 "Adapters",
1004 "Bloat",
1005 "Last Activity",
1006 "Last Pruned"
1007 );
1008 println!(" {}", "─".repeat(122));
1009
1010 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1011 for (i, repo) in repos.iter().enumerate() {
1012 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1013 let reason = repo.reason.to_string();
1014 let adapters = if repo.adapters.is_empty() {
1015 "—".to_string()
1016 } else {
1017 repo.adapters.join("+")
1018 };
1019 let bloat = if repo.reclaimable_bytes > 0 {
1020 format_bytes(repo.reclaimable_bytes)
1021 } else {
1022 "—".to_string()
1023 };
1024 let activity = repo
1025 .last_activity
1026 .map(|d| d.format("%Y-%m-%d").to_string())
1027 .unwrap_or_else(|| "—".to_string());
1028 let pruned = repo
1029 .entry
1030 .last_pruned_at
1031 .map(|d| d.format("%Y-%m-%d").to_string())
1032 .unwrap_or_else(|| "Never".to_string());
1033
1034 let reason_cell = format!("{reason:<22}");
1051 let reason_cell = match &repo.reason {
1052 SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1053 SkipReason::Active => reason_cell,
1054 SkipReason::Ignored | SkipReason::NoBloat => {
1055 Colorize::dimmed(reason_cell.as_str()).to_string()
1056 }
1057 SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1058 SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1059 };
1060 let bloat_cell = if repo.reclaimable_bytes > 0 {
1063 Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1064 } else {
1065 format!("{bloat:<12}")
1066 };
1067 println!(
1068 " {:>3} {} {} {:<12} {} {:<13} {:<13}",
1069 i + 1,
1070 output::pad_display(&path_str, 35),
1071 reason_cell,
1072 adapters,
1073 bloat_cell,
1074 activity,
1075 pruned
1076 );
1077 }
1078 println!(" {}", "─".repeat(122));
1079
1080 let (ready, total) = reclaimable_split(repos);
1081 let candidates = repos
1082 .iter()
1083 .filter(|r| matches!(r.reason, SkipReason::Candidate))
1084 .count();
1085 output::print_info(&format!(
1089 "Total: {} repos | {} candidates | {} ready now | {} reclaimable in all",
1090 repos.len(),
1091 candidates,
1092 output::format_bytes_styled(ready),
1093 output::format_bytes(total)
1094 ));
1095
1096 let shared: u64 = repos
1100 .iter()
1101 .flat_map(|r| &r.bloat_dirs)
1102 .map(|b| b.shared_bytes)
1103 .sum();
1104 if shared > 0 {
1105 output::print_info(&format!(
1106 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1107 node_modules does not free those bytes, the store keeps them.",
1108 format_bytes(shared)
1109 ));
1110 }
1111}
1112
1113#[cfg(test)]
1116mod tests {
1117 use ratatui::style::Color;
1118
1119 use crate::engine::SkipReason;
1120
1121 use super::*;
1122 use crate::engine::RepoStatusEntry;
1123
1124 #[test]
1125 fn test_row_style_logic() {
1126 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1127 assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1128 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1130 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1131 assert_eq!(
1132 reason_color(&SkipReason::ConfigError(String::new())),
1133 Color::Yellow
1134 );
1135 }
1136
1137 fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1139 RepoStatusEntry {
1140 path: std::path::PathBuf::from(path),
1141 entry: crate::config::RepoEntry::new(),
1142 reason,
1143 adapters: vec!["uv".to_string()],
1144 bloat_dirs: Vec::new(),
1145 reclaimable_bytes: bytes,
1146 reclaimable_by_adapter: Vec::new(),
1147 last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1148 idle_days: 30,
1149 }
1150 }
1151
1152 fn sample() -> Vec<RepoStatusEntry> {
1153 vec![
1154 entry("/code/alpha", SkipReason::Candidate, 500, 90),
1155 entry("/code/beta", SkipReason::Active, 9_000, 1),
1156 entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1157 entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1158 ]
1159 }
1160
1161 #[test]
1162 fn the_default_view_is_every_row_in_the_order_it_arrived() {
1163 let repos = sample();
1164 let app = StatusApp::new(&repos);
1165 assert_eq!(app.view, vec![0, 1, 2, 3]);
1166 }
1167
1168 #[test]
1169 fn sorting_by_size_puts_the_biggest_reclaim_first() {
1170 let repos = sample();
1171 let mut app = StatusApp::new(&repos);
1172 app.sort = SortKey::Size;
1173 app.rebuild_view();
1174 assert_eq!(app.view, vec![1, 3, 0, 2]);
1175 }
1176
1177 #[test]
1178 fn sorting_by_activity_puts_the_longest_untouched_first() {
1179 let repos = sample();
1180 let mut app = StatusApp::new(&repos);
1181 app.sort = SortKey::Activity;
1182 app.rebuild_view();
1183 assert_eq!(app.view, vec![2, 0, 3, 1]);
1184 }
1185
1186 #[test]
1187 fn filters_narrow_the_view_without_disturbing_the_selection() {
1188 let repos = sample();
1189 let mut app = StatusApp::new(&repos);
1190 app.mode = ViewMode::PruneSelect;
1191 app.selected[3] = true;
1192
1193 app.filter = Filter::Candidates;
1194 app.rebuild_view();
1195 assert_eq!(app.view, vec![0, 3]);
1196
1197 app.filter = Filter::Problems;
1198 app.rebuild_view();
1199 assert_eq!(app.view, vec![2]);
1200
1201 assert!(app.selected[3]);
1204 assert_eq!(app.selected_count(), 1);
1205 }
1206
1207 #[test]
1208 fn a_search_matches_the_path_and_the_adapters() {
1209 let repos = sample();
1210 let mut app = StatusApp::new(&repos);
1211
1212 app.search = "ELT".to_string();
1213 app.rebuild_view();
1214 assert_eq!(app.view, vec![3], "the match is case-insensitive");
1215
1216 app.search = "uv".to_string();
1217 app.rebuild_view();
1218 assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1219
1220 app.search = "nothing-matches-this".to_string();
1221 app.rebuild_view();
1222 assert!(app.view.is_empty());
1223 assert_eq!(app.cursor_repo(), None);
1226 }
1227
1228 #[test]
1229 fn the_cursor_follows_its_repository_through_a_filter() {
1230 let repos = sample();
1231 let mut app = StatusApp::new(&repos);
1232 app.table_state.select(Some(3)); assert_eq!(app.cursor_repo(), Some(3));
1234
1235 app.filter = Filter::Candidates;
1236 app.rebuild_view();
1237 assert_eq!(app.cursor_repo(), Some(3));
1239 }
1240
1241 #[test]
1242 fn toggle_all_is_scoped_to_what_is_on_screen() {
1243 let repos = sample();
1244 let mut app = StatusApp::new(&repos);
1245 app.search = "alpha".to_string();
1246 app.rebuild_view();
1247 app.toggle_all_candidates();
1248
1249 assert!(app.selected[0]);
1250 assert!(
1251 !app.selected[3],
1252 "delta was filtered out and must not be armed"
1253 );
1254 }
1255
1256 #[test]
1257 fn the_header_separates_what_is_prunable_now_from_everything() {
1258 let repos = sample();
1262 let (ready, total) = reclaimable_split(&repos);
1263 assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1264 assert_eq!(total, 11_500, "every registered repository");
1265 }
1266
1267 #[test]
1268 fn a_filter_never_changes_the_header_totals() {
1269 let repos = sample();
1272 let mut app = StatusApp::new(&repos);
1273 let before = reclaimable_split(app.repos);
1274
1275 app.filter = Filter::Problems;
1276 app.rebuild_view();
1277 assert_eq!(app.view.len(), 1);
1278 assert_eq!(reclaimable_split(app.repos), before);
1279 }
1280}