reth_cli_commands/download/
tui.rs1use 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
26pub struct SelectorOutput {
28 pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
30 pub preset: Option<SelectionPreset>,
32}
33
34const 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
44const 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
53const 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
61struct DisplayGroup {
63 name: &'static str,
65 types: Vec<SnapshotComponentType>,
67 required: bool,
69 presets: Vec<ComponentSelection>,
72}
73
74fn 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
103fn 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 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 groups: Vec<DisplayGroup>,
189 selections: Vec<ComponentSelection>,
191 preset: Option<SelectionPreset>,
193 cursor: usize,
195 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 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 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 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
338pub 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), Constraint::Min(8), Constraint::Length(3), ])
421 .split(f.area());
422
423 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 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 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}