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            // 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                    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    // 3. Footer summary
262    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                // Directories, not repositories: one monorepo contributes a row per
274                // ecosystem, so "3 of 5 repos" would be wrong on exactly the layout
275                // this tool is built for.
276                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}