1use std::io;
12use std::time::Duration;
13
14use anyhow::{Context, Result};
15use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
16use ratatui::prelude::*;
17use ratatui::widgets::*;
18
19use crate::engine::{RepoStatusEntry, SkipReason};
20use crate::output::format_bytes;
21use crate::tui::Tui;
22
23enum ViewMode {
25 Browse,
27 PruneSelect,
29}
30
31struct StatusApp<'a> {
32 repos: &'a [RepoStatusEntry],
33 table_state: TableState,
34 selected: Vec<bool>,
36 mode: ViewMode,
37 confirmed_indices: Option<Vec<usize>>,
39 pub should_reload: bool,
41}
42
43impl<'a> StatusApp<'a> {
44 fn new(repos: &'a [RepoStatusEntry]) -> Self {
45 let selected = vec![false; repos.len()];
46 let mut table_state = TableState::default();
47 table_state.select(Some(0));
48 Self {
49 repos,
50 table_state,
51 selected,
52 mode: ViewMode::Browse,
53 confirmed_indices: None,
54 should_reload: false,
55 }
56 }
57
58 fn move_up(&mut self) {
59 let i = match self.table_state.selected() {
60 Some(i) => {
61 if i == 0 {
62 self.repos.len().saturating_sub(1)
63 } else {
64 i - 1
65 }
66 }
67 None => 0,
68 };
69 self.table_state.select(Some(i));
70 }
71
72 fn move_down(&mut self) {
73 let i = match self.table_state.selected() {
74 Some(i) => {
75 if i >= self.repos.len().saturating_sub(1) {
76 0
77 } else {
78 i + 1
79 }
80 }
81 None => 0,
82 };
83 self.table_state.select(Some(i));
84 }
85
86 fn toggle_current(&mut self) {
87 if let Some(i) = self.table_state.selected() {
88 if matches!(self.repos[i].reason, SkipReason::Candidate) {
90 self.selected[i] = !self.selected[i];
91 }
92 }
93 }
94
95 fn toggle_all_candidates(&mut self) {
96 let any_candidate_selected = self
97 .repos
98 .iter()
99 .enumerate()
100 .any(|(i, r)| matches!(r.reason, SkipReason::Candidate) && self.selected[i]);
101
102 for (i, repo) in self.repos.iter().enumerate() {
103 if matches!(repo.reason, SkipReason::Candidate) {
104 self.selected[i] = !any_candidate_selected;
105 }
106 }
107 }
108
109 fn confirm_prune(&mut self) {
110 let indices: Vec<usize> = self
111 .selected
112 .iter()
113 .enumerate()
114 .filter(|&(_, s)| *s)
115 .map(|(i, _)| i)
116 .collect();
117 self.confirmed_indices = Some(indices);
118 }
119
120 fn selected_bytes(&self) -> u64 {
121 self.repos
122 .iter()
123 .enumerate()
124 .filter(|(i, _)| self.selected[*i])
125 .map(|(_, r)| r.reclaimable_bytes)
126 .sum()
127 }
128
129 fn selected_count(&self) -> usize {
130 self.selected.iter().filter(|&&s| s).count()
131 }
132
133 fn candidate_count(&self) -> usize {
134 self.repos
135 .iter()
136 .filter(|r| matches!(r.reason, SkipReason::Candidate))
137 .count()
138 }
139}
140
141pub fn render_status_tui(
149 repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
150) -> Result<Option<Vec<usize>>> {
151 loop {
152 let repos = repos_loader();
153
154 if repos.is_empty() {
155 return Ok(None);
156 }
157
158 let mut app = StatusApp::new(&repos);
159 {
160 let mut tui = Tui::new()?;
163 tui.drain_stale_input(Duration::from_millis(100));
164 run_status_loop(&mut tui.terminal, &mut app)?;
165 }
166
167 if app.should_reload {
168 continue;
170 }
171
172 return Ok(app.confirmed_indices);
173 }
174}
175
176fn run_status_loop(
177 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
178 app: &mut StatusApp,
179) -> Result<()> {
180 loop {
181 terminal.draw(|frame| render_ui(frame, app))?;
182
183 if event::poll(Duration::from_millis(100))? {
184 if let Event::Key(key) = event::read()? {
185 if key.kind == KeyEventKind::Release {
186 continue;
187 }
188 if key.modifiers.contains(KeyModifiers::CONTROL)
191 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
192 {
193 return Ok(());
194 }
195 match key.code {
196 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
197 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
198 KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
199 KeyCode::End | KeyCode::Char('G') => {
200 app.table_state
201 .select(Some(app.repos.len().saturating_sub(1)));
202 }
203 KeyCode::PageUp => {
204 let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
205 app.table_state.select(Some(i));
206 }
207 KeyCode::PageDown => {
208 let i = (app.table_state.selected().unwrap_or(0) + 10)
209 .min(app.repos.len().saturating_sub(1));
210 app.table_state.select(Some(i));
211 }
212 KeyCode::Char(' ') => {
213 if matches!(app.mode, ViewMode::PruneSelect) {
214 app.toggle_current();
215 }
216 }
217 KeyCode::Char('a') | KeyCode::Char('A') => {
218 if matches!(app.mode, ViewMode::PruneSelect) {
219 app.toggle_all_candidates();
220 }
221 }
222 KeyCode::Char('p') | KeyCode::Char('P') => {
223 app.mode = ViewMode::PruneSelect;
224 for (i, repo) in app.repos.iter().enumerate() {
226 if matches!(repo.reason, SkipReason::Candidate) {
227 app.selected[i] = true;
228 }
229 }
230 }
231 KeyCode::Char('i') | KeyCode::Char('I') => {
232 if let Some(idx) = app.table_state.selected() {
234 let repo = &app.repos[idx];
235 let mut per_repo =
240 crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
241 .map_err(|e| anyhow::anyhow!(e))
242 .with_context(|| {
243 format!(
244 "Could not toggle ignore for {}",
245 crate::output::clean_path(&repo.path)
246 )
247 })?
248 .unwrap_or_default();
249 per_repo.ignore = !per_repo.ignore;
250 per_repo.save_to_repo(&repo.path).with_context(|| {
255 format!(
256 "Could not write the config for {}",
257 crate::output::clean_path(&repo.path)
258 )
259 })?;
260
261 let legacy_ignore =
264 repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
265 if legacy_ignore.exists() {
266 std::fs::remove_file(&legacy_ignore).with_context(|| {
267 format!(
268 "Could not remove {}",
269 crate::output::clean_path(&legacy_ignore)
270 )
271 })?;
272 }
273
274 app.should_reload = true;
276 return Ok(());
277 }
278 }
279 KeyCode::Enter => {
280 if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
281 app.confirm_prune();
282 return Ok(());
283 }
284 }
285 KeyCode::Esc => {
286 if matches!(app.mode, ViewMode::PruneSelect) {
287 app.mode = ViewMode::Browse;
289 for s in &mut app.selected {
290 *s = false;
291 }
292 } else {
293 return Ok(());
294 }
295 }
296 KeyCode::Char('q') => return Ok(()),
297 _ => {}
298 }
299 }
300 }
301 }
302}
303
304fn reason_color(reason: &SkipReason) -> Color {
305 match reason {
306 SkipReason::Candidate => Color::Green,
307 SkipReason::Active => Color::Cyan,
308 SkipReason::Ignored => Color::DarkGray,
309 SkipReason::NoBloat => Color::Blue,
310 SkipReason::PathMissing => Color::Red,
311 SkipReason::ConfigError(_) => Color::Yellow,
313 }
314}
315
316fn render_ui(frame: &mut Frame, app: &StatusApp) {
317 let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
318
319 let outer = Layout::default()
320 .direction(Direction::Vertical)
321 .constraints([
322 Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), ])
326 .split(frame.area());
327
328 let mode_label = if is_prune_mode {
330 Span::styled(
331 " PRUNE-SELECT MODE ",
332 Style::default()
333 .bg(Color::Yellow)
334 .fg(Color::Black)
335 .add_modifier(Modifier::BOLD),
336 )
337 } else {
338 Span::styled(
339 " BROWSE MODE ",
340 Style::default()
341 .bg(Color::Cyan)
342 .fg(Color::Black)
343 .add_modifier(Modifier::BOLD),
344 )
345 };
346
347 let header_line = Line::from(vec![
348 Span::styled(
349 " dev-prune ",
350 Style::default()
351 .fg(Color::Black)
352 .bg(Color::Green)
353 .add_modifier(Modifier::BOLD),
354 ),
355 Span::raw(" "),
356 mode_label,
357 Span::styled(
358 format!(
359 " {} repos | {} candidates | {} reclaimable",
360 app.repos.len(),
361 app.candidate_count(),
362 format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
363 ),
364 Style::default().fg(Color::DarkGray),
365 ),
366 ]);
367
368 let header_widget =
369 Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
370 Style::default().fg(if is_prune_mode {
371 Color::Yellow
372 } else {
373 Color::Cyan
374 }),
375 ));
376 frame.render_widget(header_widget, outer[0]);
377
378 let col_headers = Row::new(vec![
380 Cell::from(if is_prune_mode { "Sel" } else { "#" })
381 .style(Style::default().add_modifier(Modifier::BOLD)),
382 Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
383 Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
384 Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
385 Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
386 Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
387 Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
388 ])
389 .height(1)
390 .bottom_margin(1)
391 .style(Style::default().bg(Color::Rgb(20, 25, 40)));
392
393 let rows: Vec<Row> = app
394 .repos
395 .iter()
396 .enumerate()
397 .map(|(i, repo)| {
398 let is_selected = app.selected[i];
399 let color = reason_color(&repo.reason);
400
401 let sel_cell = if is_prune_mode {
402 if matches!(repo.reason, SkipReason::Candidate) {
403 if is_selected {
404 Cell::from("[x]").style(
405 Style::default()
406 .fg(Color::Green)
407 .add_modifier(Modifier::BOLD),
408 )
409 } else {
410 Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
411 }
412 } else {
413 Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
414 }
415 } else {
416 Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
417 };
418
419 let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
420 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
421
422 let reason_str = repo.reason.to_string();
423 let adapters_str = if repo.adapters.is_empty() {
424 "—".to_string()
425 } else {
426 repo.adapters.join(", ")
427 };
428 let bloat_str = if repo.reclaimable_bytes > 0 {
429 format_bytes(repo.reclaimable_bytes)
430 } else {
431 "—".to_string()
432 };
433 let activity_str = repo
434 .last_activity
435 .map(|d| d.format("%Y-%m-%d").to_string())
436 .unwrap_or_else(|| "—".to_string());
437 let pruned_str = repo
438 .entry
439 .last_pruned_at
440 .map(|d| d.format("%Y-%m-%d").to_string())
441 .unwrap_or_else(|| "Never".to_string());
442
443 let row_style = if is_selected {
444 Style::default().bg(Color::Rgb(20, 50, 30))
445 } else {
446 Style::default()
447 };
448
449 Row::new(vec![
450 sel_cell,
451 Cell::from(path_str).style(Style::default().fg(Color::White)),
452 Cell::from(reason_str).style(Style::default().fg(color)),
453 Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
454 Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
455 Cell::from(activity_str).style(Style::default().fg(Color::Gray)),
456 Cell::from(pruned_str).style(Style::default().fg(Color::Gray)),
457 ])
458 .style(row_style)
459 })
460 .collect();
461
462 let table = Table::new(
463 rows,
464 [
465 Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
473 )
474 .header(col_headers)
475 .block(
476 Block::default()
477 .title(" Registered Repositories ")
478 .borders(Borders::ALL)
479 .border_style(Style::default().fg(Color::Gray)),
480 )
481 .row_highlight_style(
482 Style::default()
483 .bg(Color::Rgb(30, 40, 70))
484 .add_modifier(Modifier::BOLD),
485 )
486 .highlight_symbol("▶ ");
487
488 frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
489
490 let footer_lines = if is_prune_mode {
492 vec![
493 Line::from(vec![
494 Span::styled("Selected: ", Style::default().fg(Color::Gray)),
495 Span::styled(
496 format!(
497 "{} of {} candidates ({})",
498 app.selected_count(),
499 app.candidate_count(),
500 format_bytes(app.selected_bytes())
501 ),
502 Style::default()
503 .fg(Color::Yellow)
504 .add_modifier(Modifier::BOLD),
505 ),
506 ]),
507 Line::from(vec![
508 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
509 Span::raw(" Navigate "),
510 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
511 Span::raw(" Jump "),
512 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
513 Span::raw(" Toggle "),
514 Span::styled("[a]", Style::default().fg(Color::Cyan)),
515 Span::raw(" Toggle All "),
516 Span::styled(
517 "[Enter]",
518 Style::default()
519 .fg(Color::Green)
520 .add_modifier(Modifier::BOLD),
521 ),
522 Span::raw(" Prune Selected "),
523 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
524 Span::raw(" Back to Browse "),
525 Span::styled("[q]", Style::default().fg(Color::Red)),
526 Span::raw(" Quit"),
527 ]),
528 Line::from(vec![]),
529 ]
530 } else {
531 vec![
532 Line::from(vec![
533 Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
534 Span::styled("■ Candidate", Style::default().fg(Color::Green)),
535 Span::raw(" "),
536 Span::styled("■ Active", Style::default().fg(Color::Cyan)),
537 Span::raw(" "),
538 Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
539 Span::raw(" "),
540 Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
541 Span::raw(" "),
542 Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
543 ]),
544 Line::from(vec![
545 Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
546 Span::raw(" Navigate "),
547 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
548 Span::raw(" Jump "),
549 Span::styled(
550 "[p]",
551 Style::default()
552 .fg(Color::Yellow)
553 .add_modifier(Modifier::BOLD),
554 ),
555 Span::raw(" Prune-Select Mode "),
556 Span::styled(
557 "[i]",
558 Style::default()
559 .fg(Color::Magenta)
560 .add_modifier(Modifier::BOLD),
561 ),
562 Span::raw(" Toggle Ignore "),
563 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
564 Span::raw(" Quit"),
565 ]),
566 Line::from(vec![
567 Span::styled(
568 "[i] ",
569 Style::default()
570 .fg(Color::Magenta)
571 .add_modifier(Modifier::BOLD),
572 ),
573 Span::styled(
574 "toggles `ignore` in `.devprune.json` and updates `.gitignore` — refreshes instantly.",
575 Style::default().fg(Color::DarkGray),
576 ),
577 ]),
578 ]
579 };
580
581 let footer =
582 Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
583 Style::default().fg(if is_prune_mode {
584 Color::Yellow
585 } else {
586 Color::Green
587 }),
588 ));
589 frame.render_widget(footer, outer[2]);
590}
591
592pub fn render_status_plain(repos: &[RepoStatusEntry]) {
596 use crate::output;
597
598 output::print_header("dev-prune status");
599 println!(
600 "\n {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
601 "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
602 );
603 println!(" {}", "─".repeat(118));
604
605 let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
606 for (i, repo) in repos.iter().enumerate() {
607 let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
608 let reason = repo.reason.to_string();
609 let adapters = if repo.adapters.is_empty() {
610 "—".to_string()
611 } else {
612 repo.adapters.join("+")
613 };
614 let bloat = if repo.reclaimable_bytes > 0 {
615 format_bytes(repo.reclaimable_bytes)
616 } else {
617 "—".to_string()
618 };
619 let activity = repo
620 .last_activity
621 .map(|d| d.format("%Y-%m-%d").to_string())
622 .unwrap_or_else(|| "—".to_string());
623 let pruned = repo
624 .entry
625 .last_pruned_at
626 .map(|d| d.format("%Y-%m-%d").to_string())
627 .unwrap_or_else(|| "Never".to_string());
628
629 println!(
630 " {:>3} {:<35} {:<22} {:<12} {:<12} {:<13} {:<13}",
631 i + 1,
632 path_str,
633 reason,
634 adapters,
635 bloat,
636 activity,
637 pruned
638 );
639 }
640 println!(" {}", "─".repeat(118));
641
642 let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
643 let candidates = repos
644 .iter()
645 .filter(|r| matches!(r.reason, SkipReason::Candidate))
646 .count();
647 output::print_info(&format!(
648 "Total: {} repos | {} candidates | {} reclaimable",
649 repos.len(),
650 candidates,
651 format_bytes(total)
652 ));
653}
654
655#[cfg(test)]
658mod tests {
659 use ratatui::style::Color;
660
661 use crate::engine::SkipReason;
662
663 use super::reason_color;
664
665 #[test]
666 fn test_row_style_logic() {
667 assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
668 assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
669 assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
671 assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
672 }
673}