1use std::collections::HashMap;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc;
15use std::time::Duration;
16
17use anyhow::{Context, Result};
18use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
19use ratatui::prelude::*;
20use ratatui::widgets::*;
21
22use crate::constants;
23use crate::engine::{RepoStatusEntry, SkipReason};
24use crate::output::format_bytes;
25use crate::tui::Tui;
26
27enum ViewMode {
29 Browse,
31 PruneSelect,
33}
34
35#[derive(Clone, Copy, PartialEq, Eq)]
42enum SortKey {
43 Default,
44 Size,
46 Activity,
48 Name,
50}
51
52impl SortKey {
53 fn next(self) -> Self {
54 match self {
55 SortKey::Default => SortKey::Size,
56 SortKey::Size => SortKey::Activity,
57 SortKey::Activity => SortKey::Name,
58 SortKey::Name => SortKey::Default,
59 }
60 }
61
62 fn label(self) -> &'static str {
63 match self {
64 SortKey::Default => "relevance",
65 SortKey::Size => "size ↓",
66 SortKey::Activity => "idle longest ↓",
67 SortKey::Name => "name ↑",
68 }
69 }
70}
71
72#[derive(Clone, Copy, PartialEq, Eq)]
74enum Filter {
75 All,
76 Candidates,
78 WithBloat,
81 Problems,
84}
85
86impl Filter {
87 fn next(self) -> Self {
88 match self {
89 Filter::All => Filter::Candidates,
90 Filter::Candidates => Filter::WithBloat,
91 Filter::WithBloat => Filter::Problems,
92 Filter::Problems => Filter::All,
93 }
94 }
95
96 fn label(self) -> &'static str {
97 match self {
98 Filter::All => "all",
99 Filter::Candidates => "candidates",
100 Filter::WithBloat => "has bloat",
101 Filter::Problems => "problems",
102 }
103 }
104
105 fn accepts(self, repo: &RepoStatusEntry) -> bool {
106 match self {
107 Filter::All => true,
108 Filter::Candidates => matches!(repo.reason, SkipReason::Candidate),
109 Filter::WithBloat => repo.reclaimable_bytes > 0,
110 Filter::Problems => matches!(
111 repo.reason,
112 SkipReason::PathMissing | SkipReason::ConfigError(_)
113 ),
114 }
115 }
116}
117
118struct StatusApp<'a> {
119 repos: &'a [RepoStatusEntry],
120 view: Vec<usize>,
127 table_state: TableState,
128 selected: Vec<bool>,
130 mode: ViewMode,
131 sort: SortKey,
132 filter: Filter,
133 search: String,
135 searching: bool,
137 confirmed_indices: Option<Vec<usize>>,
139 notice: Option<String>,
141 pub should_reload: bool,
143 pub show_inspector: bool,
147 inspector_drawn: bool,
150 details: HashMap<usize, InspectorDetail>,
154 inflight: Option<usize>,
157 detail_tx: mpsc::Sender<(usize, InspectorDetail)>,
158 detail_rx: mpsc::Receiver<(usize, InspectorDetail)>,
159 pending_ignore: Option<(usize, bool)>,
163}
164
165const HISTOGRAM_WEEKS: usize = 26;
168
169const LARGEST_ENTRIES: usize = 5;
171
172struct InspectorDetail {
174 weekly_commits: Option<Vec<u32>>,
176 largest: Vec<(String, u64)>,
179}
180
181fn refusal(repo: &RepoStatusEntry) -> Option<String> {
193 match &repo.reason {
194 SkipReason::Candidate | SkipReason::Active if repo.reclaimable_bytes > 0 => None,
195 SkipReason::Candidate | SkipReason::Active | SkipReason::NoBloat => {
196 Some("nothing to reclaim here".to_string())
197 }
198 SkipReason::Ignored => Some("ignored — press [i] to un-ignore it".to_string()),
199 SkipReason::PathMissing => {
200 Some("path is missing — `devp unlink --missing` clears it".to_string())
201 }
202 SkipReason::ConfigError(e) => Some(format!("config error: {e}")),
203 }
204}
205
206impl<'a> StatusApp<'a> {
207 fn new(repos: &'a [RepoStatusEntry]) -> Self {
208 let selected = vec![false; repos.len()];
209 let mut table_state = TableState::default();
210 table_state.select(Some(0));
211 let (detail_tx, detail_rx) = mpsc::channel();
212 let mut app = Self {
213 repos,
214 view: Vec::new(),
215 table_state,
216 selected,
217 mode: ViewMode::Browse,
218 sort: SortKey::Default,
219 filter: Filter::All,
220 search: String::new(),
221 searching: false,
222 confirmed_indices: None,
223 notice: None,
224 should_reload: false,
225 show_inspector: true,
226 inspector_drawn: false,
227 details: HashMap::new(),
228 inflight: None,
229 detail_tx,
230 detail_rx,
231 pending_ignore: None,
232 };
233 app.rebuild_view();
234 app
235 }
236
237 fn rebuild_view(&mut self) {
244 let anchor = self.cursor_repo();
245 let needle = self.search.to_lowercase();
246
247 let mut view: Vec<usize> = (0..self.repos.len())
248 .filter(|&i| {
249 let repo = &self.repos[i];
250 if !self.filter.accepts(repo) {
251 return false;
252 }
253 if needle.is_empty() {
254 return true;
255 }
256 repo.path.to_string_lossy().to_lowercase().contains(&needle)
259 || repo
260 .adapters
261 .iter()
262 .any(|a| a.to_lowercase().contains(&needle))
263 })
264 .collect();
265
266 match self.sort {
267 SortKey::Default => {}
270 SortKey::Size => view.sort_by(|&a, &b| {
271 self.repos[b]
272 .reclaimable_bytes
273 .cmp(&self.repos[a].reclaimable_bytes)
274 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
275 }),
276 SortKey::Activity => view.sort_by(|&a, &b| {
279 let key = |i: usize| {
280 self.repos[i]
281 .last_activity
282 .map(|t| (0, t))
283 .unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
284 };
285 key(a)
286 .cmp(&key(b))
287 .then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
288 }),
289 SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
290 }
291
292 self.view = view;
293 let row = anchor
294 .and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
295 .unwrap_or(0);
296 self.table_state
299 .select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
300 }
301
302 fn cursor_repo(&self) -> Option<usize> {
304 self.view.get(self.table_state.selected()?).copied()
305 }
306
307 fn move_up(&mut self) {
308 let i = match self.table_state.selected() {
309 Some(i) => {
310 if i == 0 {
311 self.view.len().saturating_sub(1)
312 } else {
313 i - 1
314 }
315 }
316 None => 0,
317 };
318 self.table_state.select(Some(i));
319 }
320
321 fn move_down(&mut self) {
322 let i = match self.table_state.selected() {
323 Some(i) => {
324 if i >= self.view.len().saturating_sub(1) {
325 0
326 } else {
327 i + 1
328 }
329 }
330 None => 0,
331 };
332 self.table_state.select(Some(i));
333 }
334
335 fn toggle_current(&mut self) {
336 let Some(i) = self.cursor_repo() else {
337 return;
338 };
339 match refusal(&self.repos[i]) {
340 Some(why) => self.notice = Some(why),
344 None => {
345 self.selected[i] = !self.selected[i];
346 self.notice = None;
347 }
348 }
349 }
350
351 fn toggle_all_candidates(&mut self) {
362 let visible: Vec<usize> = self
363 .view
364 .iter()
365 .copied()
366 .filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
367 .collect();
368 let any_selected = visible.iter().any(|&i| self.selected[i]);
369 for i in visible {
370 self.selected[i] = !any_selected;
371 }
372 }
373
374 fn confirm_prune(&mut self) {
375 let indices: Vec<usize> = self
376 .selected
377 .iter()
378 .enumerate()
379 .filter(|&(_, s)| *s)
380 .map(|(i, _)| i)
381 .collect();
382 self.confirmed_indices = Some(indices);
383 }
384
385 fn selected_bytes(&self) -> u64 {
386 self.repos
387 .iter()
388 .enumerate()
389 .filter(|(i, _)| self.selected[*i])
390 .map(|(_, r)| r.reclaimable_bytes)
391 .sum()
392 }
393
394 fn selected_count(&self) -> usize {
395 self.selected.iter().filter(|&&s| s).count()
396 }
397
398 fn candidate_count(&self) -> usize {
399 self.repos
400 .iter()
401 .filter(|r| matches!(r.reason, SkipReason::Candidate))
402 .count()
403 }
404
405 fn selectable_count(&self) -> usize {
407 self.repos.iter().filter(|r| refusal(r).is_none()).count()
408 }
409
410 fn pump_details(&mut self) {
414 while let Ok((idx, detail)) = self.detail_rx.try_recv() {
415 self.details.insert(idx, detail);
416 if self.inflight == Some(idx) {
417 self.inflight = None;
418 }
419 }
420 if !self.inspector_drawn || self.inflight.is_some() {
421 return;
422 }
423 let Some(idx) = self.cursor_repo() else {
424 return;
425 };
426 let repo = &self.repos[idx];
427 if self.details.contains_key(&idx) || matches!(repo.reason, SkipReason::PathMissing) {
428 return;
429 }
430 let path = repo.path.clone();
431 let dirs: Vec<(String, PathBuf)> = repo
432 .bloat_dirs
433 .iter()
434 .map(|b| (b.name.clone(), b.path.clone()))
435 .collect();
436 let tx = self.detail_tx.clone();
437 self.inflight = Some(idx);
438 std::thread::spawn(move || {
439 let detail = InspectorDetail {
440 weekly_commits: weekly_commits(&path),
441 largest: largest_entries(&dirs, LARGEST_ENTRIES),
442 };
443 let _ = tx.send((idx, detail));
446 });
447 }
448}
449
450fn weekly_commits(repo: &Path) -> Option<Vec<u32>> {
453 let output = crate::scanner::git::git_in(repo)
454 .args([
455 "log",
456 &format!("--since={HISTOGRAM_WEEKS}.weeks"),
457 "--format=%ct",
458 ])
459 .output()
460 .ok()?;
461 if !output.status.success() {
462 return None;
463 }
464 let stamps: Vec<i64> = String::from_utf8_lossy(&output.stdout)
465 .lines()
466 .filter_map(|l| l.trim().parse().ok())
467 .collect();
468 Some(bucket_by_week(
469 &stamps,
470 chrono::Utc::now().timestamp(),
471 HISTOGRAM_WEEKS,
472 ))
473}
474
475fn bucket_by_week(stamps: &[i64], now: i64, weeks: usize) -> Vec<u32> {
479 const WEEK: i64 = 7 * 24 * 60 * 60;
480 let mut buckets = vec![0u32; weeks];
481 for &t in stamps {
482 if t > now {
483 continue;
484 }
485 let age = ((now - t) / WEEK) as usize;
486 if age < weeks {
487 buckets[weeks - 1 - age] += 1;
488 }
489 }
490 buckets
491}
492
493fn sparkline(counts: &[u32]) -> String {
496 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
497 let max = counts.iter().copied().max().unwrap_or(0);
498 counts
499 .iter()
500 .map(|&c| {
501 if c == 0 {
502 '·'
503 } else {
504 let step = ((c as u64 * (BARS.len() as u64 - 1)) / max as u64) as usize;
505 BARS[step]
506 }
507 })
508 .collect()
509}
510
511fn largest_entries(dirs: &[(String, PathBuf)], n: usize) -> Vec<(String, u64)> {
515 let mut entries: Vec<(String, u64)> = Vec::new();
516 for (name, dir) in dirs {
517 let Ok(read) = std::fs::read_dir(dir) else {
518 continue;
519 };
520 for entry in read.flatten() {
521 let size = match entry.file_type() {
522 Ok(t) if t.is_dir() => crate::adapters::dir_size(&entry.path()),
523 Ok(t) if t.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0),
524 _ => 0,
525 };
526 entries.push((
527 format!("{name}/{}", entry.file_name().to_string_lossy()),
528 size,
529 ));
530 }
531 }
532 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
533 entries.truncate(n);
534 entries
535}
536
537fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
548 let ready = repos
549 .iter()
550 .filter(|r| matches!(r.reason, SkipReason::Candidate))
551 .map(|r| r.reclaimable_bytes)
552 .sum();
553 let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
554 (ready, total)
555}
556
557fn tools_summary(repos: &[RepoStatusEntry]) -> Vec<(&'static str, u64, usize)> {
561 let mut totals: Vec<(&'static str, u64, usize)> = Vec::new();
562 for repo in repos {
563 for tool in &repo.tools {
564 match totals.iter_mut().find(|(name, _, _)| *name == tool.name) {
565 Some((_, bytes, count)) => {
566 *bytes += tool.size_bytes;
567 *count += 1;
568 }
569 None => totals.push((tool.name, tool.size_bytes, 1)),
570 }
571 }
572 }
573 totals
574}
575
576pub fn render_status_tui(
587 repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
588) -> Result<Option<Vec<PathBuf>>> {
589 loop {
590 let repos = repos_loader();
591
592 if repos.is_empty() {
593 return Ok(None);
594 }
595
596 let mut app = StatusApp::new(&repos);
597 {
598 let mut tui = Tui::new()?;
601 tui.drain_stale_input(Duration::from_millis(100));
602 run_status_loop(&mut tui.terminal, &mut app)?;
603 }
604
605 if app.should_reload {
606 continue;
608 }
609
610 return Ok(app
613 .confirmed_indices
614 .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
615 }
616}
617
618fn run_status_loop(
619 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
620 app: &mut StatusApp,
621) -> Result<()> {
622 loop {
623 app.pump_details();
624 terminal.draw(|frame| render_ui(frame, app))?;
625
626 if event::poll(Duration::from_millis(100))?
627 && let Event::Key(key) = event::read()?
628 {
629 if key.kind == KeyEventKind::Release {
630 continue;
631 }
632 if key.modifiers.contains(KeyModifiers::CONTROL)
635 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
636 {
637 return Ok(());
638 }
639 if let Some((idx, would_ignore)) = app.pending_ignore.take() {
642 if matches!(
643 key.code,
644 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
645 ) {
646 apply_ignore_toggle(&app.repos[idx], would_ignore)?;
647 app.should_reload = true;
648 return Ok(());
649 }
650 continue;
651 }
652 if app.searching {
656 match key.code {
657 KeyCode::Char(c) => {
658 app.search.push(c);
659 app.rebuild_view();
660 }
661 KeyCode::Backspace => {
662 app.search.pop();
663 app.rebuild_view();
664 }
665 KeyCode::Enter => app.searching = false,
669 KeyCode::Esc => {
670 app.searching = false;
671 app.search.clear();
672 app.rebuild_view();
673 }
674 _ => {}
675 }
676 continue;
677 }
678 match key.code {
679 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
680 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
681 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
682 KeyCode::End | KeyCode::Char('G') => {
683 app.table_state
684 .select(Some(app.view.len().saturating_sub(1)));
685 }
686 KeyCode::PageUp => {
687 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
688 app.table_state.select(Some(i));
689 }
690 KeyCode::PageDown => {
691 let i = (app.table_state.selected().unwrap_or(0) + 10)
692 .min(app.view.len().saturating_sub(1));
693 app.table_state.select(Some(i));
694 }
695 KeyCode::Char('s') | KeyCode::Char('S') => {
696 app.sort = app.sort.next();
697 app.rebuild_view();
698 }
699 KeyCode::Char('f') | KeyCode::Char('F') => {
700 app.filter = app.filter.next();
701 app.rebuild_view();
702 }
703 KeyCode::Char('/') => app.searching = true,
704 KeyCode::Char(' ') => {
705 if matches!(app.mode, ViewMode::PruneSelect) {
706 app.toggle_current();
707 }
708 }
709 KeyCode::Char('a') | KeyCode::Char('A') => {
710 if matches!(app.mode, ViewMode::PruneSelect) {
711 app.toggle_all_candidates();
712 }
713 }
714 KeyCode::Char('p') | KeyCode::Char('P') => {
715 app.mode = ViewMode::PruneSelect;
716 let mut armed = 0;
724 for i in app.view.clone() {
725 if matches!(app.repos[i].reason, SkipReason::Candidate) {
726 app.selected[i] = true;
727 armed += 1;
728 }
729 }
730 app.notice = (armed == 0).then(|| {
733 if app.view.iter().any(|&i| refusal(&app.repos[i]).is_none()) {
734 "No repository is past the idle threshold — press [Space] \
735 to select one you are still working in."
736 .to_string()
737 } else {
738 "Nothing here can be pruned — press [f] to widen the filter."
739 .to_string()
740 }
741 });
742 }
743 KeyCode::Char('i') | KeyCode::Char('I') => {
744 if let Some(idx) = app.cursor_repo()
746 && let Some(would_ignore) = ignore_toggle_for(&app.repos[idx])?
747 {
748 app.pending_ignore = Some((idx, would_ignore));
749 }
750 }
751 KeyCode::Enter => {
752 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
753 app.confirm_prune();
754 return Ok(());
755 }
756 }
757 KeyCode::Esc => {
758 if matches!(app.mode, ViewMode::PruneSelect) {
759 app.mode = ViewMode::Browse;
761 app.selected.fill(false);
762 app.notice = None;
763 } else {
764 return Ok(());
765 }
766 }
767 KeyCode::Tab => {
768 app.show_inspector = !app.show_inspector;
769 }
770 KeyCode::Char('q') => return Ok(()),
771 _ => {}
772 }
773 }
774 }
775}
776
777fn ignore_toggle_for(repo: &RepoStatusEntry) -> Result<Option<bool>> {
780 if matches!(repo.reason, SkipReason::PathMissing) {
784 return Ok(None);
785 }
786 let (layers, per_repo) = load_personal_config(repo)?;
787 if layers.source_of("ignore") == crate::config::ConfigSource::Project {
791 return Ok(None);
792 }
793 let legacy = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
796 Ok(Some(!(per_repo.ignore || legacy.exists())))
797}
798
799fn apply_ignore_toggle(repo: &RepoStatusEntry, ignore: bool) -> Result<()> {
801 let (_, mut per_repo) = load_personal_config(repo)?;
802 per_repo.ignore = ignore;
803 per_repo.save_to_repo(&repo.path).with_context(|| {
807 format!(
808 "Could not write the config for {}",
809 crate::output::clean_path(&repo.path)
810 )
811 })?;
812 let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
814 if legacy_ignore.exists() {
815 std::fs::remove_file(&legacy_ignore).with_context(|| {
816 format!(
817 "Could not remove {}",
818 crate::output::clean_path(&legacy_ignore)
819 )
820 })?;
821 }
822 Ok(())
823}
824
825fn load_personal_config(
831 repo: &RepoStatusEntry,
832) -> Result<(
833 crate::config::RepoConfigLayers,
834 crate::config::PerRepoConfig,
835)> {
836 let layers = crate::config::RepoConfigLayers::load(&repo.path)
837 .map_err(|e| anyhow::anyhow!(e))
838 .with_context(|| {
839 format!(
840 "Could not toggle ignore for {}",
841 crate::output::clean_path(&repo.path)
842 )
843 })?;
844 let per_repo = layers.personal_config().cloned().unwrap_or_default();
845 Ok((layers, per_repo))
846}
847
848fn reason_color(reason: &SkipReason) -> Color {
853 match reason {
854 SkipReason::Candidate => Color::Green,
855 SkipReason::Active => Color::Reset,
856 SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
857 SkipReason::PathMissing => Color::Red,
858 SkipReason::ConfigError(_) => Color::Yellow,
860 }
861}
862
863fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
864 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
865
866 let outer = Layout::default()
867 .direction(Direction::Vertical)
868 .constraints([
869 Constraint::Length(3), Constraint::Min(5), Constraint::Length(7), ])
875 .split(frame.area());
876
877 let mode_label = if is_prune_mode {
879 Span::styled(
880 " PRUNE-SELECT MODE ",
881 Style::default()
882 .bg(Color::Yellow)
883 .fg(Color::Black)
884 .add_modifier(Modifier::BOLD),
885 )
886 } else {
887 Span::styled(
888 " BROWSE MODE ",
889 Style::default()
890 .bg(Color::Cyan)
891 .fg(Color::Black)
892 .add_modifier(Modifier::BOLD),
893 )
894 };
895
896 let (ready, total) = reclaimable_split(app.repos);
897 let tools = tools_summary(app.repos);
898 let mut header_spans = vec![
899 Span::styled(
900 " dev-prune ",
901 Style::default()
902 .fg(Color::Black)
903 .bg(Color::Green)
904 .add_modifier(Modifier::BOLD),
905 ),
906 Span::raw(" "),
907 mode_label,
908 Span::styled(
909 format!(
910 " {} | {} candidates | ",
911 if app.view.len() == app.repos.len() {
915 format!("{} repos", app.repos.len())
916 } else {
917 format!("showing {} of {} repos", app.view.len(), app.repos.len())
918 },
919 app.candidate_count(),
920 ),
921 Style::default().fg(Color::DarkGray),
922 ),
923 Span::styled(
926 format!("{} ready now", format_bytes(ready)),
927 Style::default()
928 .fg(Color::Green)
929 .add_modifier(Modifier::BOLD),
930 ),
931 Span::styled(
932 format!(" | {} reclaimable in all", format_bytes(total)),
933 Style::default().fg(Color::DarkGray),
934 ),
935 ];
936 if !tools.is_empty() {
939 let summary = tools
940 .iter()
941 .map(|(name, bytes, count)| {
942 format!(
943 "{} {} in {} repo{}",
944 name,
945 format_bytes(*bytes),
946 count,
947 if *count == 1 { "" } else { "s" }
948 )
949 })
950 .collect::<Vec<_>>()
951 .join(", ");
952 header_spans.push(Span::styled(
953 format!(" | Tools: {summary}"),
954 Style::default().fg(Color::DarkGray),
955 ));
956 }
957 let header_line = Line::from(header_spans);
958
959 let header_widget =
960 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
961 Style::default().fg(if is_prune_mode {
962 Color::Yellow
963 } else {
964 Color::Cyan
965 }),
966 ));
967 frame.render_widget(header_widget, outer[0]);
968
969 let col_headers = Row::new(vec![
971 Cell::from(if is_prune_mode { "Sel" } else { "#" })
972 .style(Style::default().add_modifier(Modifier::BOLD)),
973 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
974 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
975 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
976 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
977 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
978 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
979 ])
980 .height(1)
981 .bottom_margin(1)
982 .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
986
987 let highlighted_row = app.table_state.selected();
991
992 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
996
997 let rows: Vec<Row> = app
998 .view
999 .iter()
1000 .enumerate()
1001 .map(|(row, &i)| {
1002 let repo = &app.repos[i];
1003 let is_selected = app.selected[i];
1004 let color = reason_color(&repo.reason);
1005
1006 let sel_cell = if is_prune_mode {
1007 if matches!(repo.reason, SkipReason::Candidate) {
1008 if is_selected {
1009 Cell::from("[x]").style(
1010 Style::default()
1011 .fg(Color::Green)
1012 .add_modifier(Modifier::BOLD),
1013 )
1014 } else {
1015 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
1016 }
1017 } else {
1018 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
1019 }
1020 } else {
1021 Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
1022 };
1023
1024 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1025
1026 let reason_str = repo.reason.to_string();
1027 let adapters_str = if repo.adapters.is_empty() {
1028 "—".to_string()
1029 } else {
1030 repo.adapters.join(", ")
1031 };
1032 let bloat_str = if repo.reclaimable_bytes > 0 {
1033 format_bytes(repo.reclaimable_bytes)
1034 } else {
1035 "—".to_string()
1036 };
1037 let bloat_color = if repo.reclaimable_bytes > 0 {
1040 Color::Green
1041 } else {
1042 Color::DarkGray
1043 };
1044 let activity_str = repo
1045 .last_activity
1046 .map(|d| d.format("%Y-%m-%d").to_string())
1047 .unwrap_or_else(|| "—".to_string());
1048 let pruned_str = repo
1049 .entry
1050 .last_pruned_at
1051 .map(|d| d.format("%Y-%m-%d").to_string())
1052 .unwrap_or_else(|| "Never".to_string());
1053
1054 let row_style = if is_selected {
1055 Style::default().bg(Color::Rgb(20, 50, 30))
1056 } else {
1057 Style::default()
1058 };
1059
1060 let on_dark_bg = is_selected || highlighted_row == Some(row);
1063 let path_style = if on_dark_bg {
1064 Style::default().fg(Color::White)
1065 } else {
1066 Style::default()
1067 };
1068 let date_color = if on_dark_bg {
1069 Color::Gray
1070 } else {
1071 Color::DarkGray
1072 };
1073
1074 Row::new(vec![
1075 sel_cell,
1076 Cell::from(path_str).style(path_style),
1077 Cell::from(reason_str).style(Style::default().fg(color)),
1078 Cell::from(adapters_str),
1079 Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
1080 Cell::from(activity_str).style(Style::default().fg(date_color)),
1081 Cell::from(pruned_str).style(Style::default().fg(date_color)),
1082 ])
1083 .style(row_style)
1084 })
1085 .collect();
1086
1087 let (table_area, inspector_area, compact) = if !app.show_inspector {
1091 (outer[1], None, false)
1092 } else if frame.area().width >= 110 {
1093 let horizontal = Layout::default()
1094 .direction(Direction::Horizontal)
1095 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1096 .split(outer[1]);
1097 (horizontal[0], Some(horizontal[1]), true)
1098 } else if outer[1].height >= 24 {
1099 let vertical = Layout::default()
1100 .direction(Direction::Vertical)
1101 .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
1102 .split(outer[1]);
1103 (vertical[0], Some(vertical[1]), false)
1104 } else {
1105 (outer[1], None, false)
1106 };
1107 app.inspector_drawn = inspector_area.is_some();
1108
1109 let table_constraints = if compact {
1110 vec![
1111 Constraint::Length(4), Constraint::Min(16), Constraint::Length(18), Constraint::Length(12), Constraint::Length(10), Constraint::Length(0), Constraint::Length(0), ]
1119 } else {
1120 vec![
1121 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ]
1129 };
1130
1131 let table = Table::new(rows, table_constraints)
1132 .header(col_headers)
1133 .block(
1134 Block::default()
1135 .title(" Registered Repositories ")
1136 .borders(Borders::ALL)
1137 .border_style(Style::default().fg(Color::DarkGray)),
1138 )
1139 .row_highlight_style(
1140 Style::default()
1141 .bg(Color::Rgb(30, 40, 70))
1142 .add_modifier(Modifier::BOLD),
1143 )
1144 .highlight_symbol("▶ ");
1145
1146 frame.render_stateful_widget(table, table_area, &mut app.table_state);
1150
1151 if let Some(insp_area) = inspector_area {
1152 render_inspector(frame, insp_area, app);
1153 }
1154
1155 let mut footer_lines = if is_prune_mode {
1157 vec![
1158 Line::from(vec![
1159 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
1160 Span::styled(
1165 format!(
1169 "{} of {} selectable ",
1170 app.selected_count(),
1171 app.selectable_count()
1172 ),
1173 Style::default().add_modifier(Modifier::BOLD),
1174 ),
1175 Span::styled(
1176 format!("({})", format_bytes(app.selected_bytes())),
1177 Style::default()
1178 .fg(Color::Green)
1179 .add_modifier(Modifier::BOLD),
1180 ),
1181 ]),
1182 Line::from(vec![
1183 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
1184 Span::raw(" Navigate "),
1185 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
1186 Span::raw(" Jump "),
1187 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
1188 Span::raw(" Toggle "),
1189 Span::styled("[a]", Style::default().fg(Color::Cyan)),
1190 Span::raw(" Toggle All "),
1191 Span::styled(
1192 "[Enter]",
1193 Style::default()
1194 .fg(Color::Green)
1195 .add_modifier(Modifier::BOLD),
1196 ),
1197 Span::raw(" Prune Selected "),
1198 Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
1199 Span::raw(" Back to Browse "),
1200 Span::styled("[q]", Style::default().fg(Color::Cyan)),
1201 Span::raw(" Quit"),
1202 ]),
1203 Line::from(match &app.notice {
1206 Some(msg) => vec![Span::styled(
1207 format!("⚠ {msg}"),
1208 Style::default().fg(Color::Yellow),
1209 )],
1210 None => vec![],
1211 }),
1212 ]
1213 } else {
1214 vec![
1215 Line::from(vec![
1216 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
1217 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
1218 Span::raw(" "),
1219 Span::styled("■ Active", Style::default().fg(Color::Reset)),
1220 Span::raw(" "),
1221 Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
1224 Span::raw(" "),
1225 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
1226 ]),
1227 Line::from(vec![
1228 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
1229 Span::raw(" Navigate "),
1230 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
1231 Span::raw(" Jump "),
1232 Span::styled(
1237 "[p]",
1238 Style::default()
1239 .fg(Color::Cyan)
1240 .add_modifier(Modifier::BOLD),
1241 ),
1242 Span::raw(" Prune-Select Mode "),
1243 Span::styled(
1244 "[Tab]",
1245 Style::default()
1246 .fg(Color::Cyan)
1247 .add_modifier(Modifier::BOLD),
1248 ),
1249 Span::raw(" Inspector "),
1250 Span::styled(
1251 "[i]",
1252 Style::default()
1253 .fg(Color::Cyan)
1254 .add_modifier(Modifier::BOLD),
1255 ),
1256 Span::raw(" Toggle Ignore "),
1257 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
1258 Span::raw(" Quit"),
1259 ]),
1260 Line::from(vec![
1261 Span::styled(
1262 "[i] ",
1263 Style::default()
1264 .fg(Color::Cyan)
1265 .add_modifier(Modifier::BOLD),
1266 ),
1267 Span::styled(
1268 "asks, then toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`). Inert where a committed `project.devprune.json` sets `ignore`; edit that file instead.",
1269 Style::default().fg(Color::DarkGray),
1270 ),
1271 ]),
1272 ]
1273 };
1274
1275 let mut state_line = vec![
1279 Span::styled(
1280 "[s]",
1281 Style::default()
1282 .fg(Color::Cyan)
1283 .add_modifier(Modifier::BOLD),
1284 ),
1285 Span::raw(" Sort: "),
1286 Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
1287 Span::raw(" "),
1288 Span::styled(
1289 "[f]",
1290 Style::default()
1291 .fg(Color::Cyan)
1292 .add_modifier(Modifier::BOLD),
1293 ),
1294 Span::raw(" Filter: "),
1295 Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
1296 Span::raw(" "),
1297 Span::styled(
1298 "[/]",
1299 Style::default()
1300 .fg(Color::Cyan)
1301 .add_modifier(Modifier::BOLD),
1302 ),
1303 Span::raw(" Search: "),
1304 ];
1305 if app.searching {
1306 state_line.push(Span::styled(
1309 format!("{}█", app.search),
1310 Style::default()
1311 .fg(Color::Yellow)
1312 .add_modifier(Modifier::BOLD),
1313 ));
1314 state_line.push(Span::styled(
1315 " (Enter to keep, Esc to clear)",
1316 Style::default().fg(Color::DarkGray),
1317 ));
1318 } else if app.search.is_empty() {
1319 state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
1320 } else {
1321 state_line.push(Span::styled(
1322 app.search.clone(),
1323 Style::default().fg(Color::Yellow),
1324 ));
1325 }
1326 if app.view.is_empty() {
1327 state_line.push(Span::styled(
1330 " no repositories match",
1331 Style::default().fg(Color::Red),
1332 ));
1333 }
1334 footer_lines.push(Line::from(state_line));
1335
1336 footer_lines.push(Line::from(Span::styled(
1340 constants::ATTRIBUTION_LINE,
1341 Style::default().fg(Color::DarkGray),
1342 )));
1343
1344 let footer =
1345 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
1346 Style::default().fg(if is_prune_mode {
1347 Color::Yellow
1348 } else {
1349 Color::Green
1350 }),
1351 ));
1352 frame.render_widget(footer, outer[2]);
1353
1354 if let Some((idx, would_ignore)) = app.pending_ignore {
1355 let repo = &app.repos[idx];
1356 let name = crate::engine::compute_display_name(&repo.path, &all_paths);
1357 let (title, question) = if would_ignore {
1358 (
1359 " Ignore repository ",
1360 format!("Ignore {name}? dev-prune leaves it alone until you un-ignore it."),
1361 )
1362 } else {
1363 (
1364 " Stop ignoring ",
1365 format!(
1366 "Stop ignoring {name}? It can be pruned again once it has been idle {} days.",
1367 repo.idle_days
1368 ),
1369 )
1370 };
1371 let text = vec![
1372 Line::raw(question),
1373 Line::raw(""),
1374 Line::from(vec![
1375 Span::styled(
1376 "[y]",
1377 Style::default()
1378 .fg(Color::Cyan)
1379 .add_modifier(Modifier::BOLD),
1380 ),
1381 Span::raw(if would_ignore {
1382 " Ignore "
1383 } else {
1384 " Stop ignoring "
1385 }),
1386 Span::styled("[n/Esc]", Style::default().fg(Color::Cyan)),
1387 Span::raw(" Cancel"),
1388 ]),
1389 ];
1390 let area = centered(frame.area(), 64, 7);
1391 frame.render_widget(Clear, area);
1392 frame.render_widget(
1393 Paragraph::new(text).wrap(Wrap { trim: false }).block(
1394 Block::default()
1395 .title(title)
1396 .borders(Borders::ALL)
1397 .border_style(Style::default().fg(Color::Yellow)),
1398 ),
1399 area,
1400 );
1401 }
1402}
1403
1404fn centered(area: Rect, width: u16, height: u16) -> Rect {
1406 let w = width.min(area.width);
1407 let h = height.min(area.height);
1408 Rect {
1409 x: area.x + (area.width - w) / 2,
1410 y: area.y + (area.height - h) / 2,
1411 width: w,
1412 height: h,
1413 }
1414}
1415
1416fn render_inspector(frame: &mut Frame, area: Rect, app: &StatusApp) {
1420 let highlighted_row = app.table_state.selected();
1421 let selected_repo = highlighted_row
1422 .and_then(|row| app.view.get(row))
1423 .map(|&idx| (idx, &app.repos[idx]));
1424
1425 let Some((idx, repo)) = selected_repo else {
1426 let empty = Paragraph::new(" No repository selected.").block(
1427 Block::default()
1428 .title(" Repository Inspector [Tab] ")
1429 .borders(Borders::ALL)
1430 .border_style(Style::default().fg(Color::DarkGray)),
1431 );
1432 frame.render_widget(empty, area);
1433 return;
1434 };
1435 let detail = app.details.get(&idx);
1437 let pending = detail.is_none() && !matches!(repo.reason, SkipReason::PathMissing);
1438
1439 let mut lines = Vec::new();
1440
1441 lines.push(Line::from(vec![
1443 Span::styled(" Path: ", Style::default().fg(Color::DarkGray)),
1444 Span::styled(
1445 crate::output::clean_path(&repo.path),
1446 Style::default()
1447 .fg(Color::White)
1448 .add_modifier(Modifier::BOLD),
1449 ),
1450 ]));
1451
1452 if let Some(ref id) = repo.entry.identity {
1453 let short = if id.len() > 10 {
1454 &id[..10]
1455 } else {
1456 id.as_str()
1457 };
1458 lines.push(Line::from(vec![
1459 Span::styled(" Git Identity: ", Style::default().fg(Color::DarkGray)),
1460 Span::styled(short, Style::default().fg(Color::Cyan)),
1461 ]));
1462 }
1463
1464 lines.push(Line::raw(""));
1465
1466 let color = reason_color(&repo.reason);
1468 lines.push(Line::from(vec![
1469 Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
1470 Span::styled(
1471 repo.reason.to_string(),
1472 Style::default().fg(color).add_modifier(Modifier::BOLD),
1473 ),
1474 ]));
1475
1476 lines.push(Line::from(vec![
1477 Span::styled(" Idle Threshold: ", Style::default().fg(Color::DarkGray)),
1478 Span::styled(
1479 format!("{} days", repo.idle_days),
1480 Style::default().fg(Color::White),
1481 ),
1482 ]));
1483
1484 let activity_str = repo
1485 .last_activity
1486 .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
1487 .unwrap_or_else(|| "Never".to_string());
1488 lines.push(Line::from(vec![
1489 Span::styled(" Last Activity: ", Style::default().fg(Color::DarkGray)),
1490 Span::styled(activity_str, Style::default().fg(Color::White)),
1491 ]));
1492
1493 let pruned_str = repo
1494 .entry
1495 .last_pruned_at
1496 .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
1497 .unwrap_or_else(|| "Never".to_string());
1498 lines.push(Line::from(vec![
1499 Span::styled(" Last Pruned: ", Style::default().fg(Color::DarkGray)),
1500 Span::styled(pruned_str, Style::default().fg(Color::White)),
1501 ]));
1502
1503 if repo.entry.total_freed_bytes > 0 {
1504 lines.push(Line::from(vec![
1505 Span::styled(" Lifetime Saved: ", Style::default().fg(Color::DarkGray)),
1506 Span::styled(
1507 format_bytes(repo.entry.total_freed_bytes),
1508 Style::default()
1509 .fg(Color::Green)
1510 .add_modifier(Modifier::BOLD),
1511 ),
1512 ]));
1513 }
1514
1515 let commits: Vec<Span> = match detail.map(|d| &d.weekly_commits) {
1518 Some(Some(weeks)) => vec![
1519 Span::styled(sparkline(weeks), Style::default().fg(Color::Cyan)),
1520 Span::styled(
1521 format!(" {} commits", weeks.iter().sum::<u32>()),
1522 Style::default().fg(Color::DarkGray),
1523 ),
1524 ],
1525 Some(None) => vec![Span::styled(
1526 "git would not read this history",
1527 Style::default().fg(Color::DarkGray),
1528 )],
1529 None if pending => vec![Span::styled(
1530 "reading git log…",
1531 Style::default().fg(Color::DarkGray),
1532 )],
1533 None => vec![Span::styled("—", Style::default().fg(Color::DarkGray))],
1534 };
1535 lines.push(Line::styled(
1536 format!(" Commits, last {HISTOGRAM_WEEKS} weeks:"),
1537 Style::default().fg(Color::DarkGray),
1538 ));
1539 let mut commit_line = vec![Span::raw(" ")];
1540 commit_line.extend(commits);
1541 lines.push(Line::from(commit_line));
1542
1543 lines.push(Line::raw(""));
1544
1545 let adapters_str = if repo.adapters.is_empty() {
1547 "—".to_string()
1548 } else {
1549 repo.adapters.join(", ")
1550 };
1551 lines.push(Line::from(vec![
1552 Span::styled(" Adapters: ", Style::default().fg(Color::DarkGray)),
1553 Span::styled(adapters_str, Style::default().fg(Color::Yellow)),
1554 ]));
1555
1556 lines.push(Line::raw(""));
1557
1558 lines.push(Line::from(vec![Span::styled(
1560 " Bloat Breakdown: ",
1561 Style::default()
1562 .fg(Color::White)
1563 .add_modifier(Modifier::UNDERLINED),
1564 )]));
1565
1566 if repo.bloat_dirs.is_empty() {
1567 lines.push(Line::styled(
1568 " (no bloat directories found)",
1569 Style::default().fg(Color::DarkGray),
1570 ));
1571 } else {
1572 for b in &repo.bloat_dirs {
1573 let shared_note = if b.shared_bytes > 0 {
1574 format!(" ({} store-shared)", format_bytes(b.shared_bytes))
1575 } else {
1576 String::new()
1577 };
1578 lines.push(Line::from(vec![
1579 Span::styled(
1580 format!(" • {:<16} ", b.name),
1581 Style::default().fg(Color::White),
1582 ),
1583 Span::styled(
1584 format_bytes(b.size_bytes),
1585 Style::default()
1586 .fg(Color::Green)
1587 .add_modifier(Modifier::BOLD),
1588 ),
1589 Span::styled(shared_note, Style::default().fg(Color::DarkGray)),
1590 ]));
1591 }
1592
1593 lines.push(Line::styled(
1596 " Largest inside:",
1597 Style::default().fg(Color::DarkGray),
1598 ));
1599 match detail {
1600 Some(d) if !d.largest.is_empty() => {
1601 for (name, size) in &d.largest {
1602 lines.push(Line::from(vec![
1603 Span::raw(format!(" {name} ")),
1604 Span::styled(format_bytes(*size), Style::default().fg(Color::Green)),
1605 ]));
1606 }
1607 }
1608 Some(_) => lines.push(Line::styled(
1609 " (empty)",
1610 Style::default().fg(Color::DarkGray),
1611 )),
1612 None => lines.push(Line::styled(
1613 " measuring…",
1614 Style::default().fg(Color::DarkGray),
1615 )),
1616 }
1617 }
1618
1619 if !repo.tools.is_empty() {
1620 lines.push(Line::raw(""));
1621 lines.push(Line::from(vec![Span::styled(
1622 " Tool Footprints: ",
1623 Style::default()
1624 .fg(Color::Magenta)
1625 .add_modifier(Modifier::UNDERLINED),
1626 )]));
1627 for t in &repo.tools {
1628 lines.push(Line::from(vec![
1629 Span::styled(
1630 format!(" • {:<16} ", t.name),
1631 Style::default().fg(Color::White),
1632 ),
1633 Span::styled(
1634 format_bytes(t.size_bytes),
1635 Style::default().fg(Color::Magenta),
1636 ),
1637 ]));
1638 }
1639 }
1640
1641 let block = Block::default()
1642 .title(" Repository Inspector [Tab] ")
1643 .borders(Borders::ALL)
1644 .border_style(Style::default().fg(Color::Cyan));
1645
1646 let paragraph = Paragraph::new(lines)
1649 .wrap(Wrap { trim: false })
1650 .block(block);
1651 frame.render_widget(paragraph, area);
1652}
1653
1654pub fn render_status_plain(repos: &[RepoStatusEntry]) {
1658 use crate::output;
1659 use colored::Colorize;
1660
1661 output::print_header("dev-prune status");
1662 println!(
1667 "\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
1668 "#",
1669 output::pad_display("Repository", 35),
1670 "Status / Reason",
1671 "Adapters",
1672 "Bloat",
1673 "Last Activity",
1674 "Last Pruned"
1675 );
1676 println!(" {}", "─".repeat(122));
1677
1678 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1679 for (i, repo) in repos.iter().enumerate() {
1680 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1681 let reason = repo.reason.to_string();
1682 let adapters = if repo.adapters.is_empty() {
1683 "—".to_string()
1684 } else {
1685 repo.adapters.join("+")
1686 };
1687 let bloat = if repo.reclaimable_bytes > 0 {
1688 format_bytes(repo.reclaimable_bytes)
1689 } else {
1690 "—".to_string()
1691 };
1692 let activity = repo
1693 .last_activity
1694 .map(|d| d.format("%Y-%m-%d").to_string())
1695 .unwrap_or_else(|| "—".to_string());
1696 let pruned = repo
1697 .entry
1698 .last_pruned_at
1699 .map(|d| d.format("%Y-%m-%d").to_string())
1700 .unwrap_or_else(|| "Never".to_string());
1701
1702 let reason_cell = format!("{reason:<22}");
1719 let reason_cell = match &repo.reason {
1720 SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
1721 SkipReason::Active => reason_cell,
1722 SkipReason::Ignored | SkipReason::NoBloat => {
1723 Colorize::dimmed(reason_cell.as_str()).to_string()
1724 }
1725 SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
1726 SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
1727 };
1728 let bloat_cell = if repo.reclaimable_bytes > 0 {
1731 Colorize::green(format!("{bloat:<12}").as_str()).to_string()
1732 } else {
1733 format!("{bloat:<12}")
1734 };
1735 println!(
1736 " {:>3} {} {} {:<12} {} {:<13} {:<13}",
1737 i + 1,
1738 output::pad_display(&path_str, 35),
1739 reason_cell,
1740 adapters,
1741 bloat_cell,
1742 activity,
1743 pruned
1744 );
1745 }
1746 println!(" {}", "─".repeat(122));
1747
1748 let (ready, total) = reclaimable_split(repos);
1749 let candidates = repos
1750 .iter()
1751 .filter(|r| matches!(r.reason, SkipReason::Candidate))
1752 .count();
1753 output::print_info(&format!(
1757 "Total: {} repos | {} candidates | {} ready now | {} reclaimable in all",
1758 repos.len(),
1759 candidates,
1760 output::format_bytes_styled(ready),
1761 output::format_bytes(total)
1762 ));
1763
1764 let shared: u64 = repos
1768 .iter()
1769 .flat_map(|r| &r.bloat_dirs)
1770 .map(|b| b.shared_bytes)
1771 .sum();
1772 if shared > 0 {
1773 output::print_info(&format!(
1774 "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
1775 node_modules does not free those bytes, the store keeps them.",
1776 format_bytes(shared)
1777 ));
1778 }
1779
1780 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
1784 let with_tools: Vec<_> = repos.iter().filter(|r| !r.tools.is_empty()).collect();
1785 if !with_tools.is_empty() {
1786 println!();
1787 output::print_info("Tools (report-only, never pruned):");
1788 for repo in with_tools {
1789 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
1790 for tool in &repo.tools {
1791 println!(
1792 " {} {:<20} {}",
1793 output::pad_display(&path_str, 35),
1794 tool.name,
1795 format_bytes(tool.size_bytes)
1796 );
1797 }
1798 }
1799 }
1800}
1801
1802#[cfg(test)]
1805mod tests {
1806 use ratatui::style::Color;
1807
1808 use crate::engine::SkipReason;
1809
1810 use super::*;
1811 use crate::engine::RepoStatusEntry;
1812
1813 #[test]
1814 fn test_row_style_logic() {
1815 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
1816 assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
1817 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
1819 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
1820 assert_eq!(
1821 reason_color(&SkipReason::ConfigError(String::new())),
1822 Color::Yellow
1823 );
1824 }
1825
1826 fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
1828 RepoStatusEntry {
1829 path: std::path::PathBuf::from(path),
1830 entry: crate::config::RepoEntry::new(),
1831 reason,
1832 adapters: vec!["uv".to_string()],
1833 bloat_dirs: Vec::new(),
1834 reclaimable_bytes: bytes,
1835 reclaimable_by_adapter: Vec::new(),
1836 last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
1837 idle_days: 30,
1838 tools: Vec::new(),
1839 }
1840 }
1841
1842 fn sample() -> Vec<RepoStatusEntry> {
1843 vec![
1844 entry("/code/alpha", SkipReason::Candidate, 500, 90),
1845 entry("/code/beta", SkipReason::Active, 9_000, 1),
1846 entry("/code/gamma", SkipReason::PathMissing, 0, 400),
1847 entry("/code/delta", SkipReason::Candidate, 2_000, 40),
1848 ]
1849 }
1850
1851 #[test]
1852 fn the_default_view_is_every_row_in_the_order_it_arrived() {
1853 let repos = sample();
1854 let app = StatusApp::new(&repos);
1855 assert_eq!(app.view, vec![0, 1, 2, 3]);
1856 }
1857
1858 #[test]
1859 fn sorting_by_size_puts_the_biggest_reclaim_first() {
1860 let repos = sample();
1861 let mut app = StatusApp::new(&repos);
1862 app.sort = SortKey::Size;
1863 app.rebuild_view();
1864 assert_eq!(app.view, vec![1, 3, 0, 2]);
1865 }
1866
1867 #[test]
1868 fn sorting_by_activity_puts_the_longest_untouched_first() {
1869 let repos = sample();
1870 let mut app = StatusApp::new(&repos);
1871 app.sort = SortKey::Activity;
1872 app.rebuild_view();
1873 assert_eq!(app.view, vec![2, 0, 3, 1]);
1874 }
1875
1876 #[test]
1877 fn filters_narrow_the_view_without_disturbing_the_selection() {
1878 let repos = sample();
1879 let mut app = StatusApp::new(&repos);
1880 app.mode = ViewMode::PruneSelect;
1881 app.selected[3] = true;
1882
1883 app.filter = Filter::Candidates;
1884 app.rebuild_view();
1885 assert_eq!(app.view, vec![0, 3]);
1886
1887 app.filter = Filter::Problems;
1888 app.rebuild_view();
1889 assert_eq!(app.view, vec![2]);
1890
1891 assert!(app.selected[3]);
1894 assert_eq!(app.selected_count(), 1);
1895 }
1896
1897 #[test]
1898 fn a_search_matches_the_path_and_the_adapters() {
1899 let repos = sample();
1900 let mut app = StatusApp::new(&repos);
1901
1902 app.search = "ELT".to_string();
1903 app.rebuild_view();
1904 assert_eq!(app.view, vec![3], "the match is case-insensitive");
1905
1906 app.search = "uv".to_string();
1907 app.rebuild_view();
1908 assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
1909
1910 app.search = "nothing-matches-this".to_string();
1911 app.rebuild_view();
1912 assert!(app.view.is_empty());
1913 assert_eq!(app.cursor_repo(), None);
1916 }
1917
1918 #[test]
1919 fn the_cursor_follows_its_repository_through_a_filter() {
1920 let repos = sample();
1921 let mut app = StatusApp::new(&repos);
1922 app.table_state.select(Some(3)); assert_eq!(app.cursor_repo(), Some(3));
1924
1925 app.filter = Filter::Candidates;
1926 app.rebuild_view();
1927 assert_eq!(app.cursor_repo(), Some(3));
1929 }
1930
1931 #[test]
1932 fn toggle_all_is_scoped_to_what_is_on_screen() {
1933 let repos = sample();
1934 let mut app = StatusApp::new(&repos);
1935 app.search = "alpha".to_string();
1936 app.rebuild_view();
1937 app.toggle_all_candidates();
1938
1939 assert!(app.selected[0]);
1940 assert!(
1941 !app.selected[3],
1942 "delta was filtered out and must not be armed"
1943 );
1944 }
1945
1946 #[test]
1947 fn the_header_separates_what_is_prunable_now_from_everything() {
1948 let repos = sample();
1952 let (ready, total) = reclaimable_split(&repos);
1953 assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
1954 assert_eq!(total, 11_500, "every registered repository");
1955 }
1956
1957 #[test]
1958 fn a_filter_never_changes_the_header_totals() {
1959 let repos = sample();
1962 let mut app = StatusApp::new(&repos);
1963 let before = reclaimable_split(app.repos);
1964
1965 app.filter = Filter::Problems;
1966 app.rebuild_view();
1967 assert_eq!(app.view.len(), 1);
1968 assert_eq!(reclaimable_split(app.repos), before);
1969 }
1970
1971 #[test]
1972 fn space_checks_a_repository_that_is_only_too_recently_worked_in() {
1973 let repos = sample();
1977 let mut app = StatusApp::new(&repos);
1978 app.mode = ViewMode::PruneSelect;
1979 app.table_state.select(Some(1)); app.toggle_current();
1982 assert!(
1983 app.selected[1],
1984 "an active repository is the user's to pick"
1985 );
1986 assert!(app.notice.is_none());
1987
1988 app.toggle_current();
1989 assert!(!app.selected[1], "and the same key unchecks it");
1990 }
1991
1992 #[test]
1993 fn space_refuses_what_a_prune_could_not_deliver_and_says_why() {
1994 let repos = vec![
1995 entry("/code/ignored", SkipReason::Ignored, 500, 90),
1996 entry("/code/empty", SkipReason::NoBloat, 0, 90),
1997 entry("/code/gone", SkipReason::PathMissing, 0, 90),
1998 entry(
1999 "/code/broken",
2000 SkipReason::ConfigError("bad key".into()),
2001 0,
2002 90,
2003 ),
2004 ];
2005 let mut app = StatusApp::new(&repos);
2006 app.mode = ViewMode::PruneSelect;
2007
2008 for i in 0..repos.len() {
2009 app.table_state.select(Some(i));
2010 app.toggle_current();
2011 assert!(!app.selected[i], "row {i} must stay unchecked");
2012 assert!(
2013 app.notice.is_some(),
2014 "row {i} refused a keypress without explaining itself"
2015 );
2016 }
2017
2018 app.table_state.select(Some(0));
2020 app.toggle_current();
2021 assert!(app.notice.as_deref().unwrap().contains("[i]"));
2022 }
2023
2024 #[test]
2025 fn an_active_repository_with_nothing_to_reclaim_is_still_refused() {
2026 let repos = vec![entry("/code/bare", SkipReason::Active, 0, 1)];
2028 let mut app = StatusApp::new(&repos);
2029 app.mode = ViewMode::PruneSelect;
2030 app.table_state.select(Some(0));
2031
2032 app.toggle_current();
2033 assert!(!app.selected[0]);
2034 }
2035
2036 #[test]
2037 fn the_footer_counts_every_row_space_would_accept() {
2038 let repos = sample();
2041 let app = StatusApp::new(&repos);
2042 assert_eq!(app.candidate_count(), 2, "alpha + delta");
2043 assert_eq!(
2044 app.selectable_count(),
2045 3,
2046 "alpha + delta + beta, the active one with bloat"
2047 );
2048 }
2049
2050 #[test]
2051 fn toggle_all_leaves_active_repositories_alone() {
2052 let repos = sample();
2055 let mut app = StatusApp::new(&repos);
2056 app.toggle_all_candidates();
2057
2058 assert!(app.selected[0], "alpha is a candidate");
2059 assert!(app.selected[3], "delta is a candidate");
2060 assert!(!app.selected[1], "beta is active and must be opted into");
2061 }
2062
2063 #[test]
2064 fn weeks_are_bucketed_oldest_first_and_the_window_is_strict() {
2065 const WEEK: i64 = 7 * 24 * 60 * 60;
2066 let now = 100 * WEEK;
2067 let stamps = [
2068 now, now - 10, now - WEEK - 1, now - 3 * WEEK, now + 60, ];
2074 assert_eq!(bucket_by_week(&stamps, now, 3), vec![0, 1, 2]);
2075 }
2076
2077 #[test]
2078 fn an_empty_week_is_a_dot_and_the_busiest_is_a_full_bar() {
2079 let line = sparkline(&[0, 1, 8, 4]);
2080 let chars: Vec<char> = line.chars().collect();
2081 assert_eq!(chars.len(), 4);
2082 assert_eq!(chars[0], '·');
2083 assert_ne!(chars[1], '·', "one commit must not look like none");
2084 assert_eq!(chars[2], '█');
2085 assert_eq!(sparkline(&[0, 0]), "··");
2086 }
2087
2088 #[test]
2089 fn largest_entries_are_sorted_labelled_and_truncated() {
2090 let dir = tempfile::tempdir().unwrap();
2091 let modules = dir.path().join("node_modules");
2092 std::fs::create_dir_all(modules.join("big")).unwrap();
2093 std::fs::write(modules.join("big").join("a.bin"), vec![0u8; 4096]).unwrap();
2094 std::fs::write(modules.join("small.txt"), vec![0u8; 10]).unwrap();
2095 std::fs::write(modules.join("mid.txt"), vec![0u8; 100]).unwrap();
2096
2097 let dirs = vec![("node_modules".to_string(), modules)];
2098 let top = largest_entries(&dirs, 2);
2099 let names: Vec<&str> = top.iter().map(|(n, _)| n.as_str()).collect();
2100 assert_eq!(names, ["node_modules/big", "node_modules/mid.txt"]);
2101 assert!(top[0].1 >= 4096);
2102 }
2103
2104 #[test]
2105 fn i_offers_to_stop_ignoring_a_repository_held_by_the_legacy_marker() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let mut repo = entry("/unused", SkipReason::Ignored, 0, 90);
2108 repo.path = dir.path().to_path_buf();
2109
2110 assert_eq!(ignore_toggle_for(&repo).unwrap(), Some(true));
2111
2112 std::fs::write(
2113 dir.path().join(crate::constants::DEVPRUNE_IGNORE_FILE),
2114 "{}",
2115 )
2116 .unwrap();
2117 assert_eq!(
2118 ignore_toggle_for(&repo).unwrap(),
2119 Some(false),
2120 "the marker means it is already ignored, so `i` must un-ignore"
2121 );
2122
2123 apply_ignore_toggle(&repo, false).unwrap();
2124 assert!(
2125 !dir.path()
2126 .join(crate::constants::DEVPRUNE_IGNORE_FILE)
2127 .exists()
2128 );
2129 assert_eq!(ignore_toggle_for(&repo).unwrap(), Some(true));
2130 }
2131
2132 #[test]
2133 fn i_has_nothing_to_offer_on_a_missing_path() {
2134 let repo = entry("/code/gone", SkipReason::PathMissing, 0, 90);
2135 assert_eq!(ignore_toggle_for(&repo).unwrap(), None);
2136 }
2137}