use std::io;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::prelude::*;
use ratatui::widgets::*;
use crate::engine::PruneResult;
use crate::output;
use crate::tui::Tui;
#[derive(Debug, Clone)]
pub struct SelectableCandidate {
pub candidate: PruneResult,
pub selected: bool,
}
pub fn select_candidates_tui(candidates: &[PruneResult]) -> Result<Vec<PruneResult>> {
if candidates.is_empty() {
return Ok(Vec::new());
}
let mut items: Vec<SelectableCandidate> = candidates
.iter()
.map(|c| SelectableCandidate {
candidate: c.clone(),
selected: true, })
.collect();
let mut list_state = ListState::default();
list_state.select(Some(0));
let mut tui = Tui::new()?;
tui.drain_stale_input(Duration::from_millis(300));
let confirmed = run_selection_loop(&mut tui.terminal, &mut items, &mut list_state)?;
if confirmed {
let selected_results = items
.into_iter()
.filter(|item| item.selected)
.map(|item| item.candidate)
.collect();
Ok(selected_results)
} else {
Ok(Vec::new())
}
}
fn run_selection_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
items: &mut [SelectableCandidate],
list_state: &mut ListState,
) -> Result<bool> {
loop {
terminal.draw(|frame| {
render_ui(frame, items, list_state);
})?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Release {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
{
return Ok(false);
}
let last = items.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
let i = match list_state.selected() {
Some(i) => {
if i == 0 {
items.len() - 1
} else {
i - 1
}
}
None => 0,
};
list_state.select(Some(i));
}
KeyCode::Down | KeyCode::Char('j') => {
let i = match list_state.selected() {
Some(i) => {
if i >= items.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
list_state.select(Some(i));
}
KeyCode::Home | KeyCode::Char('g') => list_state.select(Some(0)),
KeyCode::End | KeyCode::Char('G') => list_state.select(Some(last)),
KeyCode::PageUp => {
let i = list_state.selected().unwrap_or(0).saturating_sub(10);
list_state.select(Some(i));
}
KeyCode::PageDown => {
let i = (list_state.selected().unwrap_or(0) + 10).min(last);
list_state.select(Some(i));
}
KeyCode::Char(' ') => {
if let Some(i) = list_state.selected() {
items[i].selected = !items[i].selected;
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
let all_selected = items.iter().all(|item| item.selected);
for item in items.iter_mut() {
item.selected = !all_selected;
}
}
KeyCode::Enter => {
return Ok(true);
}
KeyCode::Esc | KeyCode::Char('q') => {
return Ok(false);
}
_ => {}
}
}
}
}
fn render_ui(frame: &mut Frame, items: &[SelectableCandidate], list_state: &mut ListState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(4), ])
.split(frame.area());
let header_text = Line::from(vec![
Span::styled(
" dev-prune ",
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" Select Repositories to Prune ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!("(v{})", crate::constants::VERSION),
Style::default().fg(Color::DarkGray),
),
]);
let header = Paragraph::new(header_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(header, chunks[0]);
let highlighted = list_state.selected();
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let checkbox = if item.selected {
Span::styled(
"[x] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled("[ ] ", Style::default().fg(Color::DarkGray))
};
let repo_path = output::clean_path(&item.candidate.repo_path);
let size_str = output::format_bytes(item.candidate.size_freed);
let content = Line::from(vec![
checkbox,
Span::styled(
output::pad_display(&repo_path, 40),
if highlighted == Some(i) {
Style::default().fg(Color::White)
} else {
Style::default()
},
),
Span::styled(" → ", Style::default().fg(Color::DarkGray)),
Span::styled(
output::pad_display(&item.candidate.bloat_dir, 15),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("({:>10}) ", size_str),
Style::default().fg(Color::Green),
),
Span::styled(
format!("[{}]", item.candidate.adapter_name),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(content)
})
.collect();
let list = List::new(list_items)
.block(
Block::default()
.title(" Prune Candidates ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(30, 40, 60))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
frame.render_stateful_widget(list, chunks[1], list_state);
let selected_count = items.iter().filter(|i| i.selected).count();
let selected_bytes: u64 = items
.iter()
.filter(|i| i.selected)
.map(|i| i.candidate.size_freed)
.sum();
let footer_text = vec![
Line::from(vec![
Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{} of {} directories", selected_count, items.len()),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" | "),
Span::styled("Reclaimable Space: ", Style::default().fg(Color::DarkGray)),
Span::styled(
output::format_bytes(selected_bytes),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("Controls: ", Style::default().fg(Color::DarkGray)),
Span::styled("[↑/↓/k/j]", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate "),
Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" Jump "),
Span::styled("[Space]", Style::default().fg(Color::Cyan)),
Span::raw(" Toggle "),
Span::styled("[a]", Style::default().fg(Color::Cyan)),
Span::raw(" Toggle All "),
Span::styled(
"[Enter]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Prune Selected "),
Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
Span::raw(" Cancel"),
]),
];
let footer = Paragraph::new(footer_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
frame.render_widget(footer, chunks[2]);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_selectable_candidate_struct() {
let res = PruneResult {
repo_path: PathBuf::from("/test/repo"),
adapter_name: "npm".to_string(),
bloat_dir: "node_modules".to_string(),
size_freed: 1024,
shared_bytes: 0,
runtime: None,
status: crate::engine::PruneStatus::SkippedDryRun,
};
let selectable = SelectableCandidate {
candidate: res.clone(),
selected: true,
};
assert!(selectable.selected);
assert_eq!(selectable.candidate.size_freed, 1024);
}
}