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()
174 .fg(Color::White)
175 .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 list_items: Vec<ListItem> = items
191 .iter()
192 .map(|item| {
193 let checkbox = if item.selected {
194 Span::styled(
195 "[x] ",
196 Style::default()
197 .fg(Color::Green)
198 .add_modifier(Modifier::BOLD),
199 )
200 } else {
201 Span::styled("[ ] ", Style::default().fg(Color::DarkGray))
202 };
203
204 let repo_path = output::clean_path(&item.candidate.repo_path);
208 let size_str = output::format_bytes(item.candidate.size_freed);
209
210 let content = Line::from(vec![
211 checkbox,
212 Span::styled(
213 format!("{:<40}", repo_path),
214 Style::default().fg(Color::White),
215 ),
216 Span::raw(" → "),
217 Span::styled(
218 format!("{:<15}", item.candidate.bloat_dir),
219 Style::default().fg(Color::Yellow),
220 ),
221 Span::styled(
222 format!("({:>10}) ", size_str),
223 Style::default().fg(Color::Cyan),
224 ),
225 Span::styled(
226 format!("[{}]", item.candidate.adapter_name),
227 Style::default().fg(Color::Magenta),
228 ),
229 ]);
230
231 ListItem::new(content)
232 })
233 .collect();
234
235 let list = List::new(list_items)
236 .block(
237 Block::default()
238 .title(" Prune Candidates ")
239 .borders(Borders::ALL)
240 .border_style(Style::default().fg(Color::Gray)),
241 )
242 .highlight_style(
243 Style::default()
244 .bg(Color::Rgb(30, 40, 60))
245 .add_modifier(Modifier::BOLD),
246 )
247 .highlight_symbol("▶ ");
248
249 frame.render_stateful_widget(list, chunks[1], list_state);
250
251 let selected_count = items.iter().filter(|i| i.selected).count();
253 let selected_bytes: u64 = items
254 .iter()
255 .filter(|i| i.selected)
256 .map(|i| i.candidate.size_freed)
257 .sum();
258
259 let footer_text = vec![
260 Line::from(vec![
261 Span::styled("Selected: ", Style::default().fg(Color::Gray)),
262 Span::styled(
263 format!("{} of {} directories", selected_count, items.len()),
267 Style::default()
268 .fg(Color::Yellow)
269 .add_modifier(Modifier::BOLD),
270 ),
271 Span::raw(" | "),
272 Span::styled("Reclaimable Space: ", Style::default().fg(Color::Gray)),
273 Span::styled(
274 output::format_bytes(selected_bytes),
275 Style::default()
276 .fg(Color::Green)
277 .add_modifier(Modifier::BOLD),
278 ),
279 ]),
280 Line::from(vec![
281 Span::styled("Controls: ", Style::default().fg(Color::DarkGray)),
282 Span::styled("[↑/↓/k/j]", Style::default().fg(Color::Cyan)),
283 Span::raw(" Navigate "),
284 Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
285 Span::raw(" Jump "),
286 Span::styled("[Space]", Style::default().fg(Color::Cyan)),
287 Span::raw(" Toggle "),
288 Span::styled("[a]", Style::default().fg(Color::Cyan)),
289 Span::raw(" Toggle All "),
290 Span::styled(
291 "[Enter]",
292 Style::default()
293 .fg(Color::Green)
294 .add_modifier(Modifier::BOLD),
295 ),
296 Span::raw(" Prune Selected "),
297 Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
298 Span::raw(" Cancel"),
299 ]),
300 ];
301
302 let footer = Paragraph::new(footer_text).block(
303 Block::default()
304 .borders(Borders::ALL)
305 .border_style(Style::default().fg(Color::Green)),
306 );
307 frame.render_widget(footer, chunks[2]);
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use std::path::PathBuf;
314
315 #[test]
316 fn test_selectable_candidate_struct() {
317 let res = PruneResult {
318 repo_path: PathBuf::from("/test/repo"),
319 adapter_name: "npm".to_string(),
320 bloat_dir: "node_modules".to_string(),
321 size_freed: 1024,
322 shared_bytes: 0,
323 status: crate::engine::PruneStatus::SkippedDryRun,
324 };
325 let selectable = SelectableCandidate {
326 candidate: res.clone(),
327 selected: true,
328 };
329 assert!(selectable.selected);
330 assert_eq!(selectable.candidate.size_freed, 1024);
331 }
332}