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 notice: Option<String>,
139 pub should_reload: bool,
141}
142
143fn refusal(repo: &RepoStatusEntry) -> Option<String> {
155 match &repo.reason {
156 SkipReason::Candidate | SkipReason::Active if repo.reclaimable_bytes > 0 => None,
157 SkipReason::Candidate | SkipReason::Active | SkipReason::NoBloat => {
158 Some("nothing to reclaim here".to_string())
159 }
160 SkipReason::Ignored => Some("ignored — press [i] to un-ignore it".to_string()),
161 SkipReason::PathMissing => {
162 Some("path is missing — `devp unlink --missing` clears it".to_string())
163 }
164 SkipReason::ConfigError(e) => Some(format!("config error: {e}")),
165 }
166}
167
168impl<'a> StatusApp<'a> {
169 fn new(repos: &'a [RepoStatusEntry]) -> Self {
170 let selected = vec![false; repos.len()];
171 let mut table_state = TableState::default();
172 table_state.select(Some(0));
173 let mut app = Self {
174 repos,
175 view: Vec::new(),
176 table_state,
177 selected,
178 mode: ViewMode::Browse,
179 sort: SortKey::Default,
180 filter: Filter::All,
181 search: String::new(),
182 searching: false,
183 confirmed_indices: None,
184 notice: None,
185 should_reload: false,
186 };
187 app.rebuild_view();
188 app
189 }
190
191 fn rebuild_view(&mut self) {
198 let anchor = self.cursor_repo();
199 let needle = self.search.to_lowercase();
200
201 let mut view: Vec<usize> = (0..self.repos.len())
202 .filter(|&i| {
203 let repo = &self.repos[i];
204 if !self.filter.accepts(repo) {
205 return false;
206 }
207 if needle.is_empty() {
208 return true;
209 }
210 repo.path.to_string_lossy().to_lowercase().contains(&needle)
213 || repo
214 .adapters
215 .iter()
216 .any(|a| a.to_lowercase().contains(&needle))
217 })
218 .collect();
219
220 match self.sort {
221 SortKey::Default => {}
224 SortKey::Size => view.sort_by(|&a, &b| {
225 self.repos[b]
226 .reclaimable_bytes
227 .cmp(&self.repos[a].reclaimable_bytes)
228 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
229 }),
230 SortKey::Activity => view.sort_by(|&a, &b| {
233 let key = |i: usize| {
234 self.repos[i]
235 .last_activity
236 .map(|t| (0, t))
237 .unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
238 };
239 key(a)
240 .cmp(&key(b))
241 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
242 }),
243 SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
244 }
245
246 self.view = view;
247 let row = anchor
248 .and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
249 .unwrap_or(0);
250 self.table_state
253 .select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
254 }
255
256 fn cursor_repo(&self) -> Option<usize> {
258 self.view.get(self.table_state.selected()?).copied()
259 }
260
261 fn move_up(&mut self) {
262 let i = match self.table_state.selected() {
263 Some(i) => {
264 if i == 0 {
265 self.view.len().saturating_sub(1)
266 } else {
267 i - 1
268 }
269 }
270 None => 0,
271 };
272 self.table_state.select(Some(i));
273 }
274
275 fn move_down(&mut self) {
276 let i = match self.table_state.selected() {
277 Some(i) => {
278 if i >= self.view.len().saturating_sub(1) {
279 0
280 } else {
281 i + 1
282 }
283 }
284 None => 0,
285 };
286 self.table_state.select(Some(i));
287 }
288
289 fn toggle_current(&mut self) {
290 let Some(i) = self.cursor_repo() else {
291 return;
292 };
293 match refusal(&self.repos[i]) {
294 Some(why) => self.notice = Some(why),
298 None => {
299 self.selected[i] = !self.selected[i];
300 self.notice = None;
301 }
302 }
303 }
304
305 fn toggle_all_candidates(&mut self) {
316 let visible: Vec<usize> = self
317 .view
318 .iter()
319 .copied()
320 .filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
321 .collect();
322 let any_selected = visible.iter().any(|&i| self.selected[i]);
323 for i in visible {
324 self.selected[i] = !any_selected;
325 }
326 }
327
328 fn confirm_prune(&mut self) {
329 let indices: Vec<usize> = self
330 .selected
331 .iter()
332 .enumerate()
333 .filter(|&(_, s)| *s)
334 .map(|(i, _)| i)
335 .collect();
336 self.confirmed_indices = Some(indices);
337 }
338
339 fn selected_bytes(&self) -> u64 {
340 self.repos
341 .iter()
342 .enumerate()
343 .filter(|(i, _)| self.selected[*i])
344 .map(|(_, r)| r.reclaimable_bytes)
345 .sum()
346 }
347
348 fn selected_count(&self) -> usize {
349 self.selected.iter().filter(|&&s| s).count()
350 }
351
352 fn candidate_count(&self) -> usize {
353 self.repos
354 .iter()
355 .filter(|r| matches!(r.reason, SkipReason::Candidate))
356 .count()
357 }
358
359 fn selectable_count(&self) -> usize {
361 self.repos.iter().filter(|r| refusal(r).is_none()).count()
362 }
363}
364
365fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
376 let ready = repos
377 .iter()
378 .filter(|r| matches!(r.reason, SkipReason::Candidate))
379 .map(|r| r.reclaimable_bytes)
380 .sum();
381 let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
382 (ready, total)
383}
384
385pub fn render_status_tui(
396 repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
397) -> Result<Option<Vec<PathBuf>>> {
398 loop {
399 let repos = repos_loader();
400
401 if repos.is_empty() {
402 return Ok(None);
403 }
404
405 let mut app = StatusApp::new(&repos);
406 {
407 let mut tui = Tui::new()?;
410 tui.drain_stale_input(Duration::from_millis(100));
411 run_status_loop(&mut tui.terminal, &mut app)?;
412 }
413
414 if app.should_reload {
415 continue;
417 }
418
419 return Ok(app
422 .confirmed_indices
423 .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
424 }
425}
426
427fn run_status_loop(
428 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
429 app: &mut StatusApp,
430) -> Result<()> {
431 loop {
432 terminal.draw(|frame| render_ui(frame, app))?;
433
434 if event::poll(Duration::from_millis(100))?
435 && let Event::Key(key) = event::read()?
436 {
437 if key.kind == KeyEventKind::Release {
438 continue;
439 }
440 if key.modifiers.contains(KeyModifiers::CONTROL)
443 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
444 {
445 return Ok(());
446 }
447 if app.searching {
451 match key.code {
452 KeyCode::Char(c) => {
453 app.search.push(c);
454 app.rebuild_view();
455 }
456 KeyCode::Backspace => {
457 app.search.pop();
458 app.rebuild_view();
459 }
460 KeyCode::Enter => app.searching = false,
464 KeyCode::Esc => {
465 app.searching = false;
466 app.search.clear();
467 app.rebuild_view();
468 }
469 _ => {}
470 }
471 continue;
472 }
473 match key.code {
474 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
475 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
476 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
477 KeyCode::End | KeyCode::Char('G') => {
478 app.table_state
479 .select(Some(app.view.len().saturating_sub(1)));
480 }
481 KeyCode::PageUp => {
482 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
483 app.table_state.select(Some(i));
484 }
485 KeyCode::PageDown => {
486 let i = (app.table_state.selected().unwrap_or(0) + 10)
487 .min(app.view.len().saturating_sub(1));
488 app.table_state.select(Some(i));
489 }
490 KeyCode::Char('s') | KeyCode::Char('S') => {
491 app.sort = app.sort.next();
492 app.rebuild_view();
493 }
494 KeyCode::Char('f') | KeyCode::Char('F') => {
495 app.filter = app.filter.next();
496 app.rebuild_view();
497 }
498 KeyCode::Char('/') => app.searching = true,
499 KeyCode::Char(' ') => {
500 if matches!(app.mode, ViewMode::PruneSelect) {
501 app.toggle_current();
502 }
503 }
504 KeyCode::Char('a') | KeyCode::Char('A') => {
505 if matches!(app.mode, ViewMode::PruneSelect) {
506 app.toggle_all_candidates();
507 }
508 }
509 KeyCode::Char('p') | KeyCode::Char('P') => {
510 app.mode = ViewMode::PruneSelect;
511 let mut armed = 0;
519 for i in app.view.clone() {
520 if matches!(app.repos[i].reason, SkipReason::Candidate) {
521 app.selected[i] = true;
522 armed += 1;
523 }
524 }
525 app.notice = (armed == 0).then(|| {
528 if app.view.iter().any(|&i| refusal(&app.repos[i]).is_none()) {
529 "No repository is past the idle threshold — press [Space] \n to select one you are still working in."
530 .to_string()
531 } else {
532 "Nothing here can be pruned — press [f] to widen the filter."
533 .to_string()
534 }
535 });
536 }
537 KeyCode::Char('i') | KeyCode::Char('I') => {
538 if let Some(idx) = app.cursor_repo() {
540 let repo = &app.repos[idx];
541 if matches!(repo.reason, SkipReason::PathMissing) {
545 continue;
546 }
547 let layers = crate::config::RepoConfigLayers::load(&repo.path)
552 .map_err(|e| anyhow::anyhow!(e))
553 .with_context(|| {
554 format!(
555 "Could not toggle ignore for {}",
556 crate::output::clean_path(&repo.path)
557 )
558 })?;
559 if layers.source_of("ignore") == crate::config::ConfigSource::Project {
565 continue;
566 }
567 let mut per_repo = layers.personal_config().cloned().unwrap_or_default();
568 per_repo.ignore = !per_repo.ignore;
569 per_repo.save_to_repo(&repo.path).with_context(|| {
574 format!(
575 "Could not write the config for {}",
576 crate::output::clean_path(&repo.path)
577 )
578 })?;
579
580 let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
583 if legacy_ignore.exists() {
584 std::fs::remove_file(&legacy_ignore).with_context(|| {
585 format!(
586 "Could not remove {}",
587 crate::output::clean_path(&legacy_ignore)
588 )
589 })?;
590 }
591
592 app.should_reload = true;
594 return Ok(());
595 }
596 }
597 KeyCode::Enter => {
598 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
599 app.confirm_prune();
600 return Ok(());
601 }
602 }
603 KeyCode::Esc => {
604 if matches!(app.mode, ViewMode::PruneSelect) {
605 app.mode = ViewMode::Browse;
607 app.selected.fill(false);
608 app.notice = None;
609 } else {
610 return Ok(());
611 }
612 }
613 KeyCode::Char('q') => return Ok(()),
614 _ => {}
615 }
616 }
617 }
618}
619
620fn reason_color(reason: &SkipReason) -> Color {
625 match reason {
626 SkipReason::Candidate => Color::Green,
627 SkipReason::Active => Color::Reset,
628 SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
629 SkipReason::PathMissing => Color::Red,
630 SkipReason::ConfigError(_) => Color::Yellow,
632 }
633}
634
635fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
636 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
637
638 let outer = Layout::default()
639 .direction(Direction::Vertical)
640 .constraints([
641 Constraint::Length(3), Constraint::Min(5), Constraint::Length(7), ])
649 .split(frame.area());
650
651 let mode_label = if is_prune_mode {
653 Span::styled(
654 " PRUNE-SELECT MODE ",
655 Style::default()
656 .bg(Color::Yellow)
657 .fg(Color::Black)
658 .add_modifier(Modifier::BOLD),
659 )
660 } else {
661 Span::styled(
662 " BROWSE MODE ",
663 Style::default()
664 .bg(Color::Cyan)
665 .fg(Color::Black)
666 .add_modifier(Modifier::BOLD),
667 )
668 };
669
670 let (ready, total) = reclaimable_split(app.repos);
671 let header_line = Line::from(vec![
672 Span::styled(
673 " dev-prune ",
674 Style::default()
675 .fg(Color::Black)
676 .bg(Color::Green)
677 .add_modifier(Modifier::BOLD),
678 ),
679 Span::raw(" "),
680 mode_label,
681 Span::styled(
682 format!(
683 " {} | {} candidates | ",
684 if app.view.len() == app.repos.len() {
688 format!("{} repos", app.repos.len())
689 } else {
690 format!("showing {} of {} repos", app.view.len(), app.repos.len())
691 },
692 app.candidate_count(),
693 ),
694 Style::default().fg(Color::DarkGray),
695 ),
696 Span::styled(
699 format!("{} ready now", format_bytes(ready)),
700 Style::default()
701 .fg(Color::Green)
702 .add_modifier(Modifier::BOLD),
703 ),
704 Span::styled(
705 format!(" | {} reclaimable in all", format_bytes(total)),
706 Style::default().fg(Color::DarkGray),
707 ),
708 ]);
709
710 let header_widget =
711 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
712 Style::default().fg(if is_prune_mode {
713 Color::Yellow
714 } else {
715 Color::Cyan
716 }),
717 ));
718 frame.render_widget(header_widget, outer[0]);
719
720 let col_headers = Row::new(vec![
722 Cell::from(if is_prune_mode { "Sel" } else { "#" })
723 .style(Style::default().add_modifier(Modifier::BOLD)),
724 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
725 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
726 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
727 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
728 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
729 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
730 ])
731 .height(1)
732 .bottom_margin(1)
733 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
737
738 let highlighted_row = app.table_state.selected();
742
743 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
747
748 let rows: Vec<Row> = app
749 .view
750 .iter()
751 .enumerate()
752 .map(|(row, &i)| {
753 let repo = &app.repos[i];
754 let is_selected = app.selected[i];
755 let color = reason_color(&repo.reason);
756
757 let sel_cell = if is_prune_mode {
758 if matches!(repo.reason, SkipReason::Candidate) {
759 if is_selected {
760 Cell::from("[x]").style(
761 Style::default()
762 .fg(Color::Green)
763 .add_modifier(Modifier::BOLD),
764 )
765 } else {
766 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
767 }
768 } else {
769 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
770 }
771 } else {
772 Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
773 };
774
775 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
776
777 let reason_str = repo.reason.to_string();
778 let adapters_str = if repo.adapters.is_empty() {
779 "—".to_string()
780 } else {
781 repo.adapters.join(", ")
782 };
783 let bloat_str = if repo.reclaimable_bytes > 0 {
784 format_bytes(repo.reclaimable_bytes)
785 } else {
786 "—".to_string()
787 };
788 let bloat_color = if repo.reclaimable_bytes > 0 {
791 Color::Green
792 } else {
793 Color::DarkGray
794 };
795 let activity_str = repo
796 .last_activity
797 .map(|d| d.format("%Y-%m-%d").to_string())
798 .unwrap_or_else(|| "—".to_string());
799 let pruned_str = repo
800 .entry
801 .last_pruned_at
802 .map(|d| d.format("%Y-%m-%d").to_string())
803 .unwrap_or_else(|| "Never".to_string());
804
805 let row_style = if is_selected {
806 Style::default().bg(Color::Rgb(20, 50, 30))
807 } else {
808 Style::default()
809 };
810
811 let on_dark_bg = is_selected || highlighted_row == Some(i);
812 let path_style = if on_dark_bg {
813 Style::default().fg(Color::White)
814 } else {
815 Style::default()
816 };
817 let date_color = if on_dark_bg {
818 Color::Gray
819 } else {
820 Color::DarkGray
821 };
822
823 Row::new(vec![
824 sel_cell,
825 Cell::from(path_str).style(path_style),
826 Cell::from(reason_str).style(Style::default().fg(color)),
827 Cell::from(adapters_str),
828 Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
829 Cell::from(activity_str).style(Style::default().fg(date_color)),
830 Cell::from(pruned_str).style(Style::default().fg(date_color)),
831 ])
832 .style(row_style)
833 })
834 .collect();
835
836 let table = Table::new(
837 rows,
838 [
839 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
847 )
848 .header(col_headers)
849 .block(
850 Block::default()
851 .title(" Registered Repositories ")
852 .borders(Borders::ALL)
853 .border_style(Style::default().fg(Color::DarkGray)),
854 )
855 .row_highlight_style(
856 Style::default()
857 .bg(Color::Rgb(30, 40, 70))
858 .add_modifier(Modifier::BOLD),
859 )
860 .highlight_symbol("▶ ");
861
862 frame.render_stateful_widget(table, outer[1], &mut app.table_state);
866
867 let mut footer_lines = if is_prune_mode {
869 vec![
870 Line::from(vec![
871 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
872 Span::styled(
877 format!(
881 "{} of {} selectable ",
882 app.selected_count(),
883 app.selectable_count()
884 ),
885 Style::default().add_modifier(Modifier::BOLD),
886 ),
887 Span::styled(
888 format!("({})", format_bytes(app.selected_bytes())),
889 Style::default()
890 .fg(Color::Green)
891 .add_modifier(Modifier::BOLD),
892 ),
893 ]),
894 Line::from(vec![
895 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
896 Span::raw(" Navigate "),
897 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
898 Span::raw(" Jump "),
899 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
900 Span::raw(" Toggle "),
901 Span::styled("[a]", Style::default().fg(Color::Cyan)),
902 Span::raw(" Toggle All "),
903 Span::styled(
904 "[Enter]",
905 Style::default()
906 .fg(Color::Green)
907 .add_modifier(Modifier::BOLD),
908 ),
909 Span::raw(" Prune Selected "),
910 Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
911 Span::raw(" Back to Browse "),
912 Span::styled("[q]", Style::default().fg(Color::Cyan)),
913 Span::raw(" Quit"),
914 ]),
915 Line::from(match &app.notice {
918 Some(msg) => vec![Span::styled(
919 format!("⚠ {msg}"),
920 Style::default().fg(Color::Yellow),
921 )],
922 None => vec![],
923 }),
924 ]
925 } else {
926 vec![
927 Line::from(vec![
928 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
929 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
930 Span::raw(" "),
931 Span::styled("■ Active", Style::default().fg(Color::Reset)),
932 Span::raw(" "),
933 Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
936 Span::raw(" "),
937 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
938 ]),
939 Line::from(vec![
940 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
941 Span::raw(" Navigate "),
942 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
943 Span::raw(" Jump "),
944 Span::styled(
949 "[p]",
950 Style::default()
951 .fg(Color::Cyan)
952 .add_modifier(Modifier::BOLD),
953 ),
954 Span::raw(" Prune-Select Mode "),
955 Span::styled(
956 "[i]",
957 Style::default()
958 .fg(Color::Cyan)
959 .add_modifier(Modifier::BOLD),
960 ),
961 Span::raw(" Toggle Ignore "),
962 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
963 Span::raw(" Quit"),
964 ]),
965 Line::from(vec![
966 Span::styled(
967 "[i] ",
968 Style::default()
969 .fg(Color::Cyan)
970 .add_modifier(Modifier::BOLD),
971 ),
972 Span::styled(
973 "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.",
974 Style::default().fg(Color::DarkGray),
975 ),
976 ]),
977 ]
978 };
979
980 let mut state_line = vec![
984 Span::styled(
985 "[s]",
986 Style::default()
987 .fg(Color::Cyan)
988 .add_modifier(Modifier::BOLD),
989 ),
990 Span::raw(" Sort: "),
991 Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
992 Span::raw(" "),
993 Span::styled(
994 "[f]",
995 Style::default()
996 .fg(Color::Cyan)
997 .add_modifier(Modifier::BOLD),
998 ),
999 Span::raw(" Filter: "),
1000 Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
1001 Span::raw(" "),
1002 Span::styled(
1003 "[/]",
1004 Style::default()
1005 .fg(Color::Cyan)
1006 .add_modifier(Modifier::BOLD),
1007 ),
1008 Span::raw(" Search: "),
1009 ];
1010 if app.searching {
1011 state_line.push(Span::styled(
1014 format!("{}█", app.search),
1015 Style::default()
1016 .fg(Color::Yellow)
1017 .add_modifier(Modifier::BOLD),
1018 ));
1019 state_line.push(Span::styled(
1020 " (Enter to keep, Esc to clear)",
1021 Style::default().fg(Color::DarkGray),
1022 ));
1023 } else if app.search.is_empty() {
1024 state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
1025 } else {
1026 state_line.push(Span::styled(
1027 app.search.clone(),
1028 Style::default().fg(Color::Yellow),
1029 ));
1030 }
1031 if app.view.is_empty() {
1032 state_line.push(Span::styled(
1035 " no repositories match",
1036 Style::default().fg(Color::Red),
1037 ));
1038 }
1039 footer_lines.push(Line::from(state_line));
1040
1041 footer_lines.push(Line::from(Span::styled(
1045 constants::ATTRIBUTION_LINE,
1046 Style::default().fg(Color::DarkGray),
1047 )));
1048
1049 let footer =
1050 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
1051 Style::default().fg(if is_prune_mode {
1052 Color::Yellow
1053 } else {
1054 Color::Green
1055 }),
1056 ));
1057 frame.render_widget(footer, outer[2]);
1058}
1059
1060pub fn render_status_plain(repos: &[RepoStatusEntry]) {
1064 use crate::output;
1065 use colored::Colorize;
1066
1067 output::print_header("dev-prune status");
1068 println!(
1073 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
1074 "#",
1075 output::pad_display("Repository", 35),
1076 "Status / Reason",
1077 "Adapters",
1078 "Bloat",
1079 "Last Activity",
1080 "Last Pruned"
1081 );
1082 println!(" {}", "─".repeat(122));
1083
1084 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1085 for (i, repo) in repos.iter().enumerate() {
1086 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1087 let reason = repo.reason.to_string();
1088 let adapters = if repo.adapters.is_empty() {
1089 "—".to_string()
1090 } else {
1091 repo.adapters.join("+")
1092 };
1093 let bloat = if repo.reclaimable_bytes > 0 {
1094 format_bytes(repo.reclaimable_bytes)
1095 } else {
1096 "—".to_string()
1097 };
1098 let activity = repo
1099 .last_activity
1100 .map(|d| d.format("%Y-%m-%d").to_string())
1101 .unwrap_or_else(|| "—".to_string());
1102 let pruned = repo
1103 .entry
1104 .last_pruned_at
1105 .map(|d| d.format("%Y-%m-%d").to_string())
1106 .unwrap_or_else(|| "Never".to_string());
1107
1108 let reason_cell = format!("{reason:<22}");
1125 let reason_cell = match &repo.reason {
1126 SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1127 SkipReason::Active => reason_cell,
1128 SkipReason::Ignored | SkipReason::NoBloat => {
1129 Colorize::dimmed(reason_cell.as_str()).to_string()
1130 }
1131 SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1132 SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1133 };
1134 let bloat_cell = if repo.reclaimable_bytes > 0 {
1137 Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1138 } else {
1139 format!("{bloat:<12}")
1140 };
1141 println!(
1142 " {:>3} {} {} {:<12} {} {:<13} {:<13}",
1143 i + 1,
1144 output::pad_display(&path_str, 35),
1145 reason_cell,
1146 adapters,
1147 bloat_cell,
1148 activity,
1149 pruned
1150 );
1151 }
1152 println!(" {}", "─".repeat(122));
1153
1154 let (ready, total) = reclaimable_split(repos);
1155 let candidates = repos
1156 .iter()
1157 .filter(|r| matches!(r.reason, SkipReason::Candidate))
1158 .count();
1159 output::print_info(&format!(
1163 "Total: {} repos | {} candidates | {} ready now | {} reclaimable in all",
1164 repos.len(),
1165 candidates,
1166 output::format_bytes_styled(ready),
1167 output::format_bytes(total)
1168 ));
1169
1170 let shared: u64 = repos
1174 .iter()
1175 .flat_map(|r| &r.bloat_dirs)
1176 .map(|b| b.shared_bytes)
1177 .sum();
1178 if shared > 0 {
1179 output::print_info(&format!(
1180 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1181 node_modules does not free those bytes, the store keeps them.",
1182 format_bytes(shared)
1183 ));
1184 }
1185}
1186
1187#[cfg(test)]
1190mod tests {
1191 use ratatui::style::Color;
1192
1193 use crate::engine::SkipReason;
1194
1195 use super::*;
1196 use crate::engine::RepoStatusEntry;
1197
1198 #[test]
1199 fn test_row_style_logic() {
1200 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1201 assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1202 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1204 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1205 assert_eq!(
1206 reason_color(&SkipReason::ConfigError(String::new())),
1207 Color::Yellow
1208 );
1209 }
1210
1211 fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1213 RepoStatusEntry {
1214 path: std::path::PathBuf::from(path),
1215 entry: crate::config::RepoEntry::new(),
1216 reason,
1217 adapters: vec!["uv".to_string()],
1218 bloat_dirs: Vec::new(),
1219 reclaimable_bytes: bytes,
1220 reclaimable_by_adapter: Vec::new(),
1221 last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1222 idle_days: 30,
1223 }
1224 }
1225
1226 fn sample() -> Vec<RepoStatusEntry> {
1227 vec![
1228 entry("/code/alpha", SkipReason::Candidate, 500, 90),
1229 entry("/code/beta", SkipReason::Active, 9_000, 1),
1230 entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1231 entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1232 ]
1233 }
1234
1235 #[test]
1236 fn the_default_view_is_every_row_in_the_order_it_arrived() {
1237 let repos = sample();
1238 let app = StatusApp::new(&repos);
1239 assert_eq!(app.view, vec![0, 1, 2, 3]);
1240 }
1241
1242 #[test]
1243 fn sorting_by_size_puts_the_biggest_reclaim_first() {
1244 let repos = sample();
1245 let mut app = StatusApp::new(&repos);
1246 app.sort = SortKey::Size;
1247 app.rebuild_view();
1248 assert_eq!(app.view, vec![1, 3, 0, 2]);
1249 }
1250
1251 #[test]
1252 fn sorting_by_activity_puts_the_longest_untouched_first() {
1253 let repos = sample();
1254 let mut app = StatusApp::new(&repos);
1255 app.sort = SortKey::Activity;
1256 app.rebuild_view();
1257 assert_eq!(app.view, vec![2, 0, 3, 1]);
1258 }
1259
1260 #[test]
1261 fn filters_narrow_the_view_without_disturbing_the_selection() {
1262 let repos = sample();
1263 let mut app = StatusApp::new(&repos);
1264 app.mode = ViewMode::PruneSelect;
1265 app.selected[3] = true;
1266
1267 app.filter = Filter::Candidates;
1268 app.rebuild_view();
1269 assert_eq!(app.view, vec![0, 3]);
1270
1271 app.filter = Filter::Problems;
1272 app.rebuild_view();
1273 assert_eq!(app.view, vec![2]);
1274
1275 assert!(app.selected[3]);
1278 assert_eq!(app.selected_count(), 1);
1279 }
1280
1281 #[test]
1282 fn a_search_matches_the_path_and_the_adapters() {
1283 let repos = sample();
1284 let mut app = StatusApp::new(&repos);
1285
1286 app.search = "ELT".to_string();
1287 app.rebuild_view();
1288 assert_eq!(app.view, vec![3], "the match is case-insensitive");
1289
1290 app.search = "uv".to_string();
1291 app.rebuild_view();
1292 assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1293
1294 app.search = "nothing-matches-this".to_string();
1295 app.rebuild_view();
1296 assert!(app.view.is_empty());
1297 assert_eq!(app.cursor_repo(), None);
1300 }
1301
1302 #[test]
1303 fn the_cursor_follows_its_repository_through_a_filter() {
1304 let repos = sample();
1305 let mut app = StatusApp::new(&repos);
1306 app.table_state.select(Some(3)); assert_eq!(app.cursor_repo(), Some(3));
1308
1309 app.filter = Filter::Candidates;
1310 app.rebuild_view();
1311 assert_eq!(app.cursor_repo(), Some(3));
1313 }
1314
1315 #[test]
1316 fn toggle_all_is_scoped_to_what_is_on_screen() {
1317 let repos = sample();
1318 let mut app = StatusApp::new(&repos);
1319 app.search = "alpha".to_string();
1320 app.rebuild_view();
1321 app.toggle_all_candidates();
1322
1323 assert!(app.selected[0]);
1324 assert!(
1325 !app.selected[3],
1326 "delta was filtered out and must not be armed"
1327 );
1328 }
1329
1330 #[test]
1331 fn the_header_separates_what_is_prunable_now_from_everything() {
1332 let repos = sample();
1336 let (ready, total) = reclaimable_split(&repos);
1337 assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1338 assert_eq!(total, 11_500, "every registered repository");
1339 }
1340
1341 #[test]
1342 fn a_filter_never_changes_the_header_totals() {
1343 let repos = sample();
1346 let mut app = StatusApp::new(&repos);
1347 let before = reclaimable_split(app.repos);
1348
1349 app.filter = Filter::Problems;
1350 app.rebuild_view();
1351 assert_eq!(app.view.len(), 1);
1352 assert_eq!(reclaimable_split(app.repos), before);
1353 }
1354
1355 #[test]
1356 fn space_checks_a_repository_that_is_only_too_recently_worked_in() {
1357 let repos = sample();
1361 let mut app = StatusApp::new(&repos);
1362 app.mode = ViewMode::PruneSelect;
1363 app.table_state.select(Some(1)); app.toggle_current();
1366 assert!(
1367 app.selected[1],
1368 "an active repository is the user's to pick"
1369 );
1370 assert!(app.notice.is_none());
1371
1372 app.toggle_current();
1373 assert!(!app.selected[1], "and the same key unchecks it");
1374 }
1375
1376 #[test]
1377 fn space_refuses_what_a_prune_could_not_deliver_and_says_why() {
1378 let repos = vec![
1379 entry("/code/ignored", SkipReason::Ignored, 500, 90),
1380 entry("/code/empty", SkipReason::NoBloat, 0, 90),
1381 entry("/code/gone", SkipReason::PathMissing, 0, 90),
1382 entry(
1383 "/code/broken",
1384 SkipReason::ConfigError("bad key".into()),
1385 0,
1386 90,
1387 ),
1388 ];
1389 let mut app = StatusApp::new(&repos);
1390 app.mode = ViewMode::PruneSelect;
1391
1392 for i in 0..repos.len() {
1393 app.table_state.select(Some(i));
1394 app.toggle_current();
1395 assert!(!app.selected[i], "row {i} must stay unchecked");
1396 assert!(
1397 app.notice.is_some(),
1398 "row {i} refused a keypress without explaining itself"
1399 );
1400 }
1401
1402 app.table_state.select(Some(0));
1404 app.toggle_current();
1405 assert!(app.notice.as_deref().unwrap().contains("[i]"));
1406 }
1407
1408 #[test]
1409 fn an_active_repository_with_nothing_to_reclaim_is_still_refused() {
1410 let repos = vec![entry("/code/bare", SkipReason::Active, 0, 1)];
1412 let mut app = StatusApp::new(&repos);
1413 app.mode = ViewMode::PruneSelect;
1414 app.table_state.select(Some(0));
1415
1416 app.toggle_current();
1417 assert!(!app.selected[0]);
1418 }
1419
1420 #[test]
1421 fn the_footer_counts_every_row_space_would_accept() {
1422 let repos = sample();
1425 let app = StatusApp::new(&repos);
1426 assert_eq!(app.candidate_count(), 2, "alpha + delta");
1427 assert_eq!(
1428 app.selectable_count(),
1429 3,
1430 "alpha + delta + beta, the active one with bloat"
1431 );
1432 }
1433
1434 #[test]
1435 fn toggle_all_leaves_active_repositories_alone() {
1436 let repos = sample();
1439 let mut app = StatusApp::new(&repos);
1440 app.toggle_all_candidates();
1441
1442 assert!(app.selected[0], "alpha is a candidate");
1443 assert!(app.selected[3], "delta is a candidate");
1444 assert!(!app.selected[1], "beta is active and must be opted into");
1445 }
1446}