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 if let Event::Key(key) = event::read()? {
77 if key.kind == KeyEventKind::Release {
79 continue;
80 }
81
82 if key.modifiers.contains(KeyModifiers::CONTROL)
85 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
86 {
87 return Ok(false);
88 }
89
90 let last = items.len().saturating_sub(1);
91 match key.code {
92 KeyCode::Up | KeyCode::Char('k') => {
93 let i = match list_state.selected() {
94 Some(i) => {
95 if i == 0 {
96 items.len() - 1
97 } else {
98 i - 1
99 }
100 }
101 None => 0,
102 };
103 list_state.select(Some(i));
104 }
105 KeyCode::Down | KeyCode::Char('j') => {
106 let i = match list_state.selected() {
107 Some(i) => {
108 if i >= items.len() - 1 {
109 0
110 } else {
111 i + 1
112 }
113 }
114 None => 0,
115 };
116 list_state.select(Some(i));
117 }
118 KeyCode::Home | KeyCode::Char('g') => list_state.select(Some(0)),
119 KeyCode::End | KeyCode::Char('G') => list_state.select(Some(last)),
120 KeyCode::PageUp => {
121 let i = list_state.selected().unwrap_or(0).saturating_sub(10);
122 list_state.select(Some(i));
123 }
124 KeyCode::PageDown => {
125 let i = (list_state.selected().unwrap_or(0) + 10).min(last);
126 list_state.select(Some(i));
127 }
128 KeyCode::Char(' ') => {
129 if let Some(i) = list_state.selected() {
130 items[i].selected = !items[i].selected;
131 }
132 }
133 KeyCode::Char('a') | KeyCode::Char('A') => {
134 let all_selected = items.iter().all(|item| item.selected);
135 for item in items.iter_mut() {
136 item.selected = !all_selected;
137 }
138 }
139 KeyCode::Enter => {
140 return Ok(true);
141 }
142 KeyCode::Esc | KeyCode::Char('q') => {
143 return Ok(false);
144 }
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 format!("{:<40}", repo_path),
220 if highlighted == Some(i) {
221 Style::default().fg(Color::White)
222 } else {
223 Style::default()
224 },
225 ),
226 Span::raw(" → "),
227 Span::styled(
228 format!("{:<15}", item.candidate.bloat_dir),
229 Style::default().fg(Color::Yellow),
230 ),
231 Span::styled(
232 format!("({:>10}) ", size_str),
233 Style::default().fg(Color::Cyan),
234 ),
235 Span::styled(
236 format!("[{}]", item.candidate.adapter_name),
237 Style::default().fg(Color::Magenta),
238 ),
239 ]);
240
241 ListItem::new(content)
242 })
243 .collect();
244
245 let list = List::new(list_items)
246 .block(
247 Block::default()
248 .title(" Prune Candidates ")
249 .borders(Borders::ALL)
250 .border_style(Style::default().fg(Color::DarkGray)),
251 )
252 .highlight_style(
253 Style::default()
254 .bg(Color::Rgb(30, 40, 60))
255 .add_modifier(Modifier::BOLD),
256 )
257 .highlight_symbol("▶ ");
258
259 frame.render_stateful_widget(list, chunks[1], list_state);
260
261 let selected_count = items.iter().filter(|i| i.selected).count();
263 let selected_bytes: u64 = items
264 .iter()
265 .filter(|i| i.selected)
266 .map(|i| i.candidate.size_freed)
267 .sum();
268
269 let footer_text = vec![
270 Line::from(vec![
271 Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
272 Span::styled(
273 format!("{} of {} directories", selected_count, items.len()),
277 Style::default()
278 .fg(Color::Yellow)
279 .add_modifier(Modifier::BOLD),
280 ),
281 Span::raw(" | "),
282 Span::styled("Reclaimable Space: ", Style::default().fg(Color::DarkGray)),
283 Span::styled(
284 output::format_bytes(selected_bytes),
285 Style::default()
286 .fg(Color::Green)
287 .add_modifier(Modifier::BOLD),
288 ),
289 ]),
290 Line::from(vec![
291 Span::styled("Controls: ", Style::default().fg(Color::DarkGray)),
292 Span::styled("[↑/↓/k/j]", Style::default().fg(Color::Cyan)),
293 Span::raw(" Navigate "),
294 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
295 Span::raw(" Jump "),
296 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
297 Span::raw(" Toggle "),
298 Span::styled("[a]", Style::default().fg(Color::Cyan)),
299 Span::raw(" Toggle All "),
300 Span::styled(
301 "[Enter]",
302 Style::default()
303 .fg(Color::Green)
304 .add_modifier(Modifier::BOLD),
305 ),
306 Span::raw(" Prune Selected "),
307 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
308 Span::raw(" Cancel"),
309 ]),
310 ];
311
312 let footer = Paragraph::new(footer_text).block(
313 Block::default()
314 .borders(Borders::ALL)
315 .border_style(Style::default().fg(Color::Green)),
316 );
317 frame.render_widget(footer, chunks[2]);
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::path::PathBuf;
324
325 #[test]
326 fn test_selectable_candidate_struct() {
327 let res = PruneResult {
328 repo_path: PathBuf::from("/test/repo"),
329 adapter_name: "npm".to_string(),
330 bloat_dir: "node_modules".to_string(),
331 size_freed: 1024,
332 shared_bytes: 0,
333 status: crate::engine::PruneStatus::SkippedDryRun,
334 };
335 let selectable = SelectableCandidate {
336 candidate: res.clone(),
337 selected: true,
338 };
339 assert!(selectable.selected);
340 assert_eq!(selectable.candidate.size_freed, 1024);
341 }
342}