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            && let Event::Key(key) = event::read()?
77        {
78            // Ignore KeyRelease events (common on Windows console)
79            if key.kind == KeyEventKind::Release {
80                continue;
81            }
82
83            // Raw mode delivers Ctrl-C as a key event rather than a signal, so
84            // without this the one key everybody reaches for to escape does nothing.
85            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), // 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            // Default foreground, not white: this sits on the terminal's own
174            // background, and white text vanishes on a light theme.
175            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    // 2. List items
190    // The highlighted row gets a fixed dark-blue background, so its path needs
191    // explicitly light text; every other row sits on the terminal's own background,
192    // where only the default foreground is readable on both light and dark themes.
193    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            // `clean_path`, not `display()`: every other surface abbreviates the home
211            // directory, and a full path here pushes the size and adapter columns off
212            // the edge of an ordinary terminal.
213            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                // One meaning per colour, and the row reads left to right: cyan is
227                // the directory about to be deleted, green is the space it gives back,
228                // and the arrow and the adapter tag are furniture. Four hues on one
229                // line — yellow, cyan, magenta and white — made none of them mean
230                // anything.
231                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    // 3. Footer summary
267    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                // Directories, not repositories: one monorepo contributes a row per
279                // ecosystem, so "3 of 5 repos" would be wrong on exactly the layout
280                // this tool is built for.
281                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}