Skip to main content

dev_prune/tui/
selection_view.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Interactive TUI candidate selection view.
5//
6// Provides a terminal UI for users to selectively check/uncheck
7// repositories before executing a prune pass.
8
9use 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/// Candidate item for the selection list.
22#[derive(Debug, Clone)]
23pub struct SelectableCandidate {
24    pub candidate: PruneResult,
25    pub selected: bool,
26}
27
28/// Renders an interactive TUI list allowing the user to toggle which candidates to prune.
29/// Returns the list of candidates that the user selected for deletion.
30pub 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, // Default all selected
40        })
41        .collect();
42
43    let mut list_state = ListState::default();
44    list_state.select(Some(0));
45
46    // The guard owns raw mode, the alternate screen and the panic hook, and puts all
47    // three back on every exit path — including the `?` below.
48    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        // User cancelled with ESC / q
61        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                // Ignore KeyRelease events (common on Windows console)
78                if key.kind == KeyEventKind::Release {
79                    continue;
80                }
81
82                // Raw mode delivers Ctrl-C as a key event rather than a signal, so
83                // without this the one key everybody reaches for to escape does nothing.
84                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), // Header banner
157            Constraint::Min(5),    // Interactive candidate list
158            Constraint::Length(4), // Footer & space calculation summary
159        ])
160        .split(frame.area());
161
162    // 1. Header
163    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    // 2. List items
190    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            // `clean_path`, not `display()`: every other surface abbreviates the home
205            // directory, and a full path here pushes the size and adapter columns off
206            // the edge of an ordinary terminal.
207            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    // 3. Footer summary
252    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                // Directories, not repositories: one monorepo contributes a row per
264                // ecosystem, so "3 of 5 repos" would be wrong on exactly the layout
265                // this tool is built for.
266                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}