Skip to main content

reth_cli_commands/download/
tui.rs

1use crate::download::{
2    download_command,
3    manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest},
4    DownloadProgress, SelectionPreset,
5};
6use crossterm::{
7    event::{self, Event, KeyCode},
8    execute,
9    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::{
12    backend::CrosstermBackend,
13    layout::{Constraint, Direction, Layout},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
17    Frame, Terminal,
18};
19use reth_prune_types::{MINIMUM_DISTANCE, MINIMUM_UNWIND_SAFE_DISTANCE};
20use std::{
21    collections::BTreeMap,
22    io,
23    time::{Duration, Instant},
24};
25
26/// Result of the interactive component selector.
27pub struct SelectorOutput {
28    /// User-confirmed selections with per-component ranges.
29    pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
30    /// Last preset action used in the TUI, if any.
31    pub preset: Option<SelectionPreset>,
32}
33
34/// All distance presets. Groups filter this to only valid options.
35const DISTANCE_PRESETS: [ComponentSelection; 6] = [
36    ComponentSelection::None,
37    ComponentSelection::Distance(MINIMUM_DISTANCE),
38    ComponentSelection::Distance(MINIMUM_UNWIND_SAFE_DISTANCE),
39    ComponentSelection::Distance(100_000),
40    ComponentSelection::Distance(1_000_000),
41    ComponentSelection::All,
42];
43
44/// Presets for components that require the minimum pruning distance (receipts).
45const RECEIPTS_PRESETS: [ComponentSelection; 5] = [
46    ComponentSelection::Distance(MINIMUM_DISTANCE),
47    ComponentSelection::Distance(MINIMUM_UNWIND_SAFE_DISTANCE),
48    ComponentSelection::Distance(100_000),
49    ComponentSelection::Distance(1_000_000),
50    ComponentSelection::All,
51];
52
53/// Presets for components that require the minimum unwind-safe history.
54const HISTORY_PRESETS: [ComponentSelection; 4] = [
55    ComponentSelection::Distance(MINIMUM_UNWIND_SAFE_DISTANCE),
56    ComponentSelection::Distance(100_000),
57    ComponentSelection::Distance(1_000_000),
58    ComponentSelection::All,
59];
60
61/// A display group bundles one or more component types into a single TUI row.
62struct DisplayGroup {
63    /// Display name shown in the TUI.
64    name: &'static str,
65    /// Underlying component types this group controls.
66    types: Vec<SnapshotComponentType>,
67    /// Whether this group is required and locked to All.
68    required: bool,
69    /// Valid presets for this group. Components with minimum distance requirements
70    /// exclude presets that would produce invalid prune configs.
71    presets: Vec<ComponentSelection>,
72}
73
74/// Adds a configured preset to the ordered list of generic TUI choices.
75fn presets_with_configured(
76    presets: &[ComponentSelection],
77    configured: ComponentSelection,
78) -> Vec<ComponentSelection> {
79    let mut presets = presets.to_vec();
80    if presets.contains(&configured) {
81        return presets;
82    }
83
84    let insert_at = match configured {
85        ComponentSelection::None => 0,
86        ComponentSelection::Distance(distance) => presets
87            .iter()
88            .position(|selection| {
89                matches!(selection, ComponentSelection::Distance(other) if *other > distance) ||
90                    matches!(selection, ComponentSelection::All)
91            })
92            .unwrap_or(presets.len()),
93        ComponentSelection::Since(_) => presets
94            .iter()
95            .position(|selection| matches!(selection, ComponentSelection::All))
96            .unwrap_or(presets.len()),
97        ComponentSelection::All => presets.len(),
98    };
99    presets.insert(insert_at, configured);
100    presets
101}
102
103/// Build the display groups from available components in the manifest.
104fn build_groups(
105    manifest: &SnapshotManifest,
106    minimal_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
107) -> Vec<DisplayGroup> {
108    let has = |ty: SnapshotComponentType| manifest.component(ty).is_some();
109
110    let mut groups = Vec::new();
111
112    if has(SnapshotComponentType::State) {
113        groups.push(DisplayGroup {
114            name: "State (mdbx)",
115            types: vec![SnapshotComponentType::State],
116            required: true,
117            presets: DISTANCE_PRESETS.to_vec(),
118        });
119    }
120
121    if has(SnapshotComponentType::Headers) {
122        groups.push(DisplayGroup {
123            name: "Headers",
124            types: vec![SnapshotComponentType::Headers],
125            required: true,
126            presets: DISTANCE_PRESETS.to_vec(),
127        });
128    }
129
130    if has(SnapshotComponentType::Transactions) {
131        groups.push(DisplayGroup {
132            name: "Transactions",
133            types: vec![SnapshotComponentType::Transactions],
134            required: false,
135            presets: presets_with_configured(
136                &HISTORY_PRESETS,
137                minimal_preset
138                    .get(&SnapshotComponentType::Transactions)
139                    .copied()
140                    .unwrap_or(ComponentSelection::None),
141            ),
142        });
143    }
144
145    if has(SnapshotComponentType::Receipts) {
146        groups.push(DisplayGroup {
147            name: "Receipts",
148            types: vec![SnapshotComponentType::Receipts],
149            required: false,
150            presets: presets_with_configured(
151                &RECEIPTS_PRESETS,
152                minimal_preset
153                    .get(&SnapshotComponentType::Receipts)
154                    .copied()
155                    .unwrap_or(ComponentSelection::None),
156            ),
157        });
158    }
159
160    // Bundle account + storage changesets as "State History"
161    let has_acc = has(SnapshotComponentType::AccountChangesets);
162    let has_stor = has(SnapshotComponentType::StorageChangesets);
163    if has_acc || has_stor {
164        let mut types = Vec::new();
165        if has_acc {
166            types.push(SnapshotComponentType::AccountChangesets);
167        }
168        if has_stor {
169            types.push(SnapshotComponentType::StorageChangesets);
170        }
171        let configured = minimal_preset.get(&types[0]).copied().unwrap_or(ComponentSelection::None);
172        groups.push(DisplayGroup {
173            name: "State History",
174            types,
175            required: false,
176            presets: presets_with_configured(&HISTORY_PRESETS, configured),
177        });
178    }
179
180    groups
181}
182
183struct SelectorApp {
184    manifest: SnapshotManifest,
185    minimal_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
186    full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
187    /// Display groups shown in the TUI.
188    groups: Vec<DisplayGroup>,
189    /// Current selection for each group.
190    selections: Vec<ComponentSelection>,
191    /// Last preset action invoked by user.
192    preset: Option<SelectionPreset>,
193    /// Current cursor position.
194    cursor: usize,
195    /// List state for ratatui.
196    list_state: ListState,
197}
198
199impl SelectorApp {
200    fn new(
201        manifest: SnapshotManifest,
202        minimal_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
203        full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
204    ) -> Self {
205        let groups = build_groups(&manifest, &minimal_preset);
206
207        // Default to the minimal preset (matches --minimal prune config)
208        let selections = groups
209            .iter()
210            .map(|g| minimal_preset.get(&g.types[0]).copied().unwrap_or(ComponentSelection::None))
211            .collect();
212
213        let mut list_state = ListState::default();
214        list_state.select(Some(0));
215
216        Self {
217            manifest,
218            minimal_preset,
219            full_preset,
220            groups,
221            selections,
222            preset: Some(SelectionPreset::Minimal),
223            cursor: 0,
224            list_state,
225        }
226    }
227
228    fn cycle_right(&mut self) {
229        if let Some(group) = self.groups.get(self.cursor) {
230            if group.required {
231                return;
232            }
233            let presets = &group.presets;
234            let current = self.selections[self.cursor];
235            let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
236            self.selections[self.cursor] = presets[(idx + 1) % presets.len()];
237            self.preset = None;
238        }
239    }
240
241    fn cycle_left(&mut self) {
242        if let Some(group) = self.groups.get(self.cursor) {
243            if group.required {
244                return;
245            }
246            let presets = &group.presets;
247            let current = self.selections[self.cursor];
248            let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
249            self.selections[self.cursor] = presets[(idx + presets.len() - 1) % presets.len()];
250            self.preset = None;
251        }
252    }
253
254    fn select_all(&mut self) {
255        self.selections.fill(ComponentSelection::All);
256        self.preset = Some(SelectionPreset::Archive);
257    }
258
259    fn select_minimal(&mut self) {
260        for (i, group) in self.groups.iter().enumerate() {
261            self.selections[i] = self
262                .minimal_preset
263                .get(&group.types[0])
264                .copied()
265                .unwrap_or(ComponentSelection::None);
266        }
267        self.preset = Some(SelectionPreset::Minimal);
268    }
269
270    fn select_full(&mut self) {
271        for (i, group) in self.groups.iter().enumerate() {
272            let mut selection = self
273                .minimal_preset
274                .get(&group.types[0])
275                .copied()
276                .unwrap_or(ComponentSelection::None);
277            for ty in &group.types {
278                if let Some(sel) = self.full_preset.get(ty).copied() {
279                    selection = sel;
280                    break;
281                }
282            }
283            self.selections[i] = selection;
284        }
285        self.preset = Some(SelectionPreset::Full);
286    }
287
288    fn move_up(&mut self) {
289        if self.cursor > 0 {
290            self.cursor -= 1;
291        } else {
292            self.cursor = self.groups.len().saturating_sub(1);
293        }
294        self.list_state.select(Some(self.cursor));
295    }
296
297    fn move_down(&mut self) {
298        if self.cursor < self.groups.len() - 1 {
299            self.cursor += 1;
300        } else {
301            self.cursor = 0;
302        }
303        self.list_state.select(Some(self.cursor));
304    }
305
306    /// Build the flat component→selection map from grouped selections.
307    fn selection_map(&self) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
308        let mut map = BTreeMap::new();
309        for (group, sel) in self.groups.iter().zip(&self.selections) {
310            for ty in &group.types {
311                map.insert(*ty, *sel);
312            }
313        }
314        map
315    }
316
317    /// Size for a single group, summing all component types in the group.
318    fn group_size(&self, group_idx: usize) -> u64 {
319        let sel = self.selections[group_idx];
320        let distance = match sel {
321            ComponentSelection::None => return 0,
322            ComponentSelection::All => None,
323            ComponentSelection::Distance(d) => Some(d),
324            ComponentSelection::Since(block) => Some(self.manifest.block - block + 1),
325        };
326        self.groups[group_idx]
327            .types
328            .iter()
329            .map(|ty| self.manifest.size_for_distance(*ty, distance))
330            .sum()
331    }
332
333    fn total_selected_size(&self) -> u64 {
334        (0..self.groups.len()).map(|i| self.group_size(i)).sum()
335    }
336}
337
338/// Runs the interactive component selector TUI.
339pub fn run_selector(
340    manifest: SnapshotManifest,
341    minimal_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
342    full_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
343) -> eyre::Result<SelectorOutput> {
344    enable_raw_mode()?;
345    let mut stdout = io::stdout();
346    execute!(stdout, EnterAlternateScreen)?;
347    let backend = CrosstermBackend::new(stdout);
348    let mut terminal = Terminal::new(backend)?;
349
350    let mut app = SelectorApp::new(manifest, minimal_preset.clone(), full_preset.clone());
351    let result = event_loop(&mut terminal, &mut app);
352
353    disable_raw_mode()?;
354    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
355    terminal.show_cursor()?;
356
357    result
358}
359
360fn event_loop(
361    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
362    app: &mut SelectorApp,
363) -> eyre::Result<SelectorOutput> {
364    let tick_rate = Duration::from_millis(100);
365    let mut last_tick = Instant::now();
366
367    loop {
368        terminal.draw(|f| render(f, app))?;
369
370        let timeout =
371            tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
372
373        if crossterm::event::poll(timeout)? &&
374            let Event::Key(key) = event::read()? &&
375            key.kind == event::KeyEventKind::Press
376        {
377            match key.code {
378                KeyCode::Char('q') | KeyCode::Esc => {
379                    eyre::bail!("Download cancelled by user");
380                }
381                KeyCode::Enter => {
382                    return Ok(SelectorOutput {
383                        selections: app.selection_map(),
384                        preset: app.preset,
385                    });
386                }
387                KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => app.cycle_right(),
388                KeyCode::Left | KeyCode::Char('h') => app.cycle_left(),
389                KeyCode::Char('a') => app.select_all(),
390                KeyCode::Char('f') => app.select_full(),
391                KeyCode::Char('m') => app.select_minimal(),
392                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
393                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
394                _ => {}
395            }
396        }
397
398        if last_tick.elapsed() >= tick_rate {
399            last_tick = Instant::now();
400        }
401    }
402}
403
404fn format_selection(sel: &ComponentSelection) -> String {
405    match sel {
406        ComponentSelection::All => "All".to_string(),
407        ComponentSelection::Distance(d) => format!("Last {d} blocks"),
408        ComponentSelection::Since(block) => format!("Since block {block}"),
409        ComponentSelection::None => "None".to_string(),
410    }
411}
412
413fn render(f: &mut Frame<'_>, app: &mut SelectorApp) {
414    let chunks = Layout::default()
415        .direction(Direction::Vertical)
416        .constraints([
417            Constraint::Length(3), // Header
418            Constraint::Min(8),    // Component list
419            Constraint::Length(3), // Footer
420        ])
421        .split(f.area());
422
423    // Header
424    let block_info = if app.manifest.block > 0 {
425        format!(" (block {})", app.manifest.block)
426    } else {
427        String::new()
428    };
429    let header = Paragraph::new(format!(" Select snapshot components to download{}", block_info))
430        .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
431        .block(Block::default().borders(Borders::ALL).title(download_command()));
432    f.render_widget(header, chunks[0]);
433
434    // Component list
435    let items: Vec<ListItem<'_>> = app
436        .groups
437        .iter()
438        .enumerate()
439        .map(|(i, group)| {
440            let sel = &app.selections[i];
441            let sel_str = format_selection(sel);
442
443            let size = app.group_size(i);
444            let size_str =
445                if size > 0 { DownloadProgress::format_size(size) } else { String::new() };
446
447            let required = if group.required { " (required)" } else { "" };
448
449            let at_max = *sel == *group.presets.last().unwrap_or(&ComponentSelection::All);
450            let at_min = *sel == group.presets[0];
451            let arrows = if group.required {
452                "   "
453            } else if at_max {
454                "◂  "
455            } else if at_min {
456                "  ▸"
457            } else {
458                "◂ ▸"
459            };
460
461            let style = if group.required {
462                Style::default().fg(Color::DarkGray)
463            } else if matches!(sel, ComponentSelection::None) {
464                Style::default().fg(Color::White)
465            } else {
466                Style::default().fg(Color::Green)
467            };
468
469            ListItem::new(Line::from(vec![
470                Span::styled(format!(" {:<22}", group.name), style),
471                Span::styled(
472                    format!("{arrows} {:<12}", sel_str),
473                    style.add_modifier(Modifier::BOLD),
474                ),
475                Span::styled(format!("{:>10}", size_str), style.add_modifier(Modifier::DIM)),
476                Span::styled(required.to_string(), Style::default().fg(Color::DarkGray)),
477            ]))
478        })
479        .collect();
480
481    let total_str = DownloadProgress::format_size(app.total_selected_size());
482    let list = List::new(items)
483        .block(
484            Block::default()
485                .borders(Borders::ALL)
486                .title(format!("Components — Total: {total_str}")),
487        )
488        .highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::DarkGray))
489        .highlight_symbol("▸ ");
490    f.render_stateful_widget(list, chunks[1], &mut app.list_state);
491
492    // Footer
493    let footer = Paragraph::new(
494        " [←/→] adjust  [m] minimal  [f] full  [a] archive  [Enter] confirm  [Esc] cancel",
495    )
496    .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
497    .block(Block::default().borders(Borders::ALL));
498    f.render_widget(footer, chunks[2]);
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn configured_distance_is_an_ordered_tui_choice() {
507        let configured = ComponentSelection::Distance(64_864);
508        let presets = presets_with_configured(&HISTORY_PRESETS, configured);
509
510        assert_eq!(
511            presets,
512            vec![
513                ComponentSelection::Distance(MINIMUM_UNWIND_SAFE_DISTANCE),
514                configured,
515                ComponentSelection::Distance(100_000),
516                ComponentSelection::Distance(1_000_000),
517                ComponentSelection::All,
518            ]
519        );
520    }
521}