1use std::io;
10use std::time::Duration;
11
12use anyhow::Result;
13use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
14use ratatui::prelude::*;
15use ratatui::widgets::*;
16
17use crate::engine::PruneResult;
18use crate::output;
19use crate::tui::Tui;
20
21#[derive(Debug, Clone)]
23pub struct SelectableCandidate {
24 pub candidate: PruneResult,
25 pub selected: bool,
26}
27
28pub fn select_candidates_tui(candidates: &[PruneResult]) -> Result<Vec<PruneResult>> {
31 if candidates.is_empty() {
32 return Ok(Vec::new());
33 }
34
35 let mut items: Vec<SelectableCandidate> = candidates
36 .iter()
37 .map(|c| SelectableCandidate {
38 candidate: c.clone(),
39 selected: true, })
41 .collect();
42
43 let mut list_state = ListState::default();
44 list_state.select(Some(0));
45
46 let mut tui = Tui::new()?;
49 tui.drain_stale_input(Duration::from_millis(300));
50
51 let confirmed = run_selection_loop(&mut tui.terminal, &mut items, &mut list_state)?;
52 if confirmed {
53 let selected_results = items
54 .into_iter()
55 .filter(|item| item.selected)
56 .map(|item| item.candidate)
57 .collect();
58 Ok(selected_results)
59 } else {
60 Ok(Vec::new())
62 }
63}
64
65fn run_selection_loop(
66 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
67 items: &mut [SelectableCandidate],
68 list_state: &mut ListState,
69) -> Result<bool> {
70 loop {
71 terminal.draw(|frame| {
72 render_ui(frame, items, list_state);
73 })?;
74
75 if event::poll(Duration::from_millis(100))?
76 && let Event::Key(key) = event::read()?
77 {
78 if key.kind == KeyEventKind::Release {
80 continue;
81 }
82
83 if key.modifiers.contains(KeyModifiers::CONTROL)
86 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
87 {
88 return Ok(false);
89 }
90
91 let last = items.len().saturating_sub(1);
92 match key.code {
93 KeyCode::Up | KeyCode::Char('k') => {
94 let i = match list_state.selected() {
95 Some(i) => {
96 if i == 0 {
97 items.len() - 1
98 } else {
99 i - 1
100 }
101 }
102 None => 0,
103 };
104 list_state.select(Some(i));
105 }
106 KeyCode::Down | KeyCode::Char('j') => {
107 let i = match list_state.selected() {
108 Some(i) => {
109 if i >= items.len() - 1 {
110 0
111 } else {
112 i + 1
113 }
114 }
115 None => 0,
116 };
117 list_state.select(Some(i));
118 }
119 KeyCode::Home | KeyCode::Char('g') => list_state.select(Some(0)),
120 KeyCode::End | KeyCode::Char('G') => list_state.select(Some(last)),
121 KeyCode::PageUp => {
122 let i = list_state.selected().unwrap_or(0).saturating_sub(10);
123 list_state.select(Some(i));
124 }
125 KeyCode::PageDown => {
126 let i = (list_state.selected().unwrap_or(0) + 10).min(last);
127 list_state.select(Some(i));
128 }
129 KeyCode::Char(' ') => {
130 if let Some(i) = list_state.selected() {
131 items[i].selected = !items[i].selected;
132 }
133 }
134 KeyCode::Char('a') | KeyCode::Char('A') => {
135 let all_selected = items.iter().all(|item| item.selected);
136 for item in items.iter_mut() {
137 item.selected = !all_selected;
138 }
139 }
140 KeyCode::Enter => {
141 return Ok(true);
142 }
143 KeyCode::Esc | KeyCode::Char('q') => {
144 return Ok(false);
145 }
146 _ => {}
147 }
148 }
149 }
150}
151
152fn render_ui(frame: &mut Frame, items: &[SelectableCandidate], list_state: &mut ListState) {
153 let chunks = Layout::default()
154 .direction(Direction::Vertical)
155 .constraints([
156 Constraint::Length(3), Constraint::Min(5), Constraint::Length(4), ])
160 .split(frame.area());
161
162 let header_text = Line::from(vec![
164 Span::styled(
165 " dev-prune ",
166 Style::default()
167 .bg(Color::Cyan)
168 .fg(Color::Black)
169 .add_modifier(Modifier::BOLD),
170 ),
171 Span::styled(
172 " Select Repositories to Prune ",
173 Style::default().add_modifier(Modifier::BOLD),
176 ),
177 Span::styled(
178 format!("(v{})", crate::constants::VERSION),
179 Style::default().fg(Color::DarkGray),
180 ),
181 ]);
182 let header = Paragraph::new(header_text).block(
183 Block::default()
184 .borders(Borders::ALL)
185 .border_style(Style::default().fg(Color::Cyan)),
186 );
187 frame.render_widget(header, chunks[0]);
188
189 let highlighted = list_state.selected();
194
195 let list_items: Vec<ListItem> = items
196 .iter()
197 .enumerate()
198 .map(|(i, item)| {
199 let checkbox = if item.selected {
200 Span::styled(
201 "[x] ",
202 Style::default()
203 .fg(Color::Green)
204 .add_modifier(Modifier::BOLD),
205 )
206 } else {
207 Span::styled("[ ] ", Style::default().fg(Color::DarkGray))
208 };
209
210 let repo_path = output::clean_path(&item.candidate.repo_path);
214 let size_str = output::format_bytes(item.candidate.size_freed);
215
216 let content = Line::from(vec![
217 checkbox,
218 Span::styled(
219 output::pad_display(&repo_path, 40),
220 if highlighted == Some(i) {
221 Style::default().fg(Color::White)
222 } else {
223 Style::default()
224 },
225 ),
226 Span::styled(" → ", Style::default().fg(Color::DarkGray)),
232 Span::styled(
233 output::pad_display(&item.candidate.bloat_dir, 15),
234 Style::default().fg(Color::Cyan),
235 ),
236 Span::styled(
237 format!("({:>10}) ", size_str),
238 Style::default().fg(Color::Green),
239 ),
240 Span::styled(
241 format!("[{}]", item.candidate.adapter_name),
242 Style::default().fg(Color::DarkGray),
243 ),
244 ]);
245
246 ListItem::new(content)
247 })
248 .collect();
249
250 let list = List::new(list_items)
251 .block(
252 Block::default()
253 .title(" Prune Candidates ")
254 .borders(Borders::ALL)
255 .border_style(Style::default().fg(Color::DarkGray)),
256 )
257 .highlight_style(
258 Style::default()
259 .bg(Color::Rgb(30, 40, 60))
260 .add_modifier(Modifier::BOLD),
261 )
262 .highlight_symbol("▶ ");
263
264 frame.render_stateful_widget(list, chunks[1], list_state);
265
266 let selected_count = items.iter().filter(|i| i.selected).count();
268 let selected_bytes: u64 = items
269 .iter()
270 .filter(|i| i.selected)
271 .map(|i| i.candidate.size_freed)
272 .sum();
273
274 let footer_text = vec![
275 Line::from(vec![
276 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
277 Span::styled(
278 format!("{} of {} directories", selected_count, items.len()),
282 Style::default().add_modifier(Modifier::BOLD),
283 ),
284 Span::raw(" | "),
285 Span::styled("Reclaimable Space: ", Style::default().fg(Color::DarkGray)),
286 Span::styled(
287 output::format_bytes(selected_bytes),
288 Style::default()
289 .fg(Color::Green)
290 .add_modifier(Modifier::BOLD),
291 ),
292 ]),
293 Line::from(vec![
294 Span::styled("Controls: ", Style::default().fg(Color::DarkGray)),
295 Span::styled("[↑/↓/k/j]", Style::default().fg(Color::Cyan)),
296 Span::raw(" Navigate "),
297 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
298 Span::raw(" Jump "),
299 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
300 Span::raw(" Toggle "),
301 Span::styled("[a]", Style::default().fg(Color::Cyan)),
302 Span::raw(" Toggle All "),
303 Span::styled(
304 "[Enter]",
305 Style::default()
306 .fg(Color::Green)
307 .add_modifier(Modifier::BOLD),
308 ),
309 Span::raw(" Prune Selected "),
310 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
311 Span::raw(" Cancel"),
312 ]),
313 ];
314
315 let footer = Paragraph::new(footer_text).block(
316 Block::default()
317 .borders(Borders::ALL)
318 .border_style(Style::default().fg(Color::Green)),
319 );
320 frame.render_widget(footer, chunks[2]);
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use std::path::PathBuf;
327
328 #[test]
329 fn test_selectable_candidate_struct() {
330 let res = PruneResult {
331 repo_path: PathBuf::from("/test/repo"),
332 adapter_name: "npm".to_string(),
333 bloat_dir: "node_modules".to_string(),
334 size_freed: 1024,
335 shared_bytes: 0,
336 runtime: None,
337 status: crate::engine::PruneStatus::SkippedDryRun,
338 };
339 let selectable = SelectableCandidate {
340 candidate: res.clone(),
341 selected: true,
342 };
343 assert!(selectable.selected);
344 assert_eq!(selectable.candidate.size_freed, 1024);
345 }
346}