diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..7cfa730 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,325 @@ +#[cfg(feature = "tui")] +use crossterm::{ + event::{self, Event, KeyCode}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +#[cfg(feature = "tui")] +use rand::Rng; +#[cfg(feature = "tui")] +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + widgets::{Block, Borders, List, ListItem, ListState}, +}; +#[cfg(feature = "tui")] +use std::{ + collections::HashMap, + env, + io::stdout, + path::PathBuf, + sync::{Arc, Mutex, mpsc::channel}, + thread, + time::Duration, +}; + +#[cfg(feature = "tui")] +use crate::{downloader, md5_utils, mirrors, wget_list}; + +#[cfg(feature = "tui")] +fn init_environment() -> (PathBuf, String) { + match env::var("LFS") { + Ok(lfs) => ( + PathBuf::from(lfs).join("sources"), + "Using LFS environment path.".into(), + ), + Err(_) => { + let mut rng = rand::rng(); + let random_number: u32 = rng.random_range(1000..=9999); + let tmp_path = format!("/tmp/lfs_{}", random_number); + ( + PathBuf::from(&tmp_path).join("sources"), + format!("Using temporary path {}", tmp_path), + ) + } + } +} + +#[cfg(feature = "tui")] +fn prepare_wget_list() -> Vec { + wget_list::get_wget_list() + .unwrap_or_default() + .lines() + .map(|s| s.to_string()) + .collect() +} + +#[cfg(feature = "tui")] +fn prepare_md5_map() -> HashMap { + let mut map = HashMap::new(); + if let Ok(md5_content) = md5_utils::get_md5sums() { + for line in md5_content.lines() { + let mut parts = line.split_whitespace(); + if let (Some(hash), Some(filename)) = (parts.next(), parts.next()) { + map.insert(filename.to_string(), hash.to_string()); + } + } + } + map +} + +#[cfg(feature = "tui")] +pub fn tui_menu() -> Result<(), Box> { + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen)?; + enable_raw_mode()?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = (|| -> Result<(), Box> { + let menu_items = [ + "🌱 Init environment", + "🌐 Select mirror", + "📦 Download packages", + "🔍 Check status", + "❌ Exit", + ]; + let mut state = ListState::default(); + state.select(Some(0)); + + let mut lfs_sources: Option = None; + let mut mirrors_list: Vec = Vec::new(); + let mut selected_mirror: Option = None; + let log_messages: Arc>> = Arc::new(Mutex::new(Vec::new())); + let progress_state: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + let (tx, rx) = channel::(); + + loop { + while let Ok(msg) = rx.try_recv() { + log_messages.lock().unwrap().push(msg); + } + + terminal.draw(|f| { + let size = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + vec![Constraint::Length(3); menu_items.len()] + .into_iter() + .chain(vec![Constraint::Min(5)]) + .collect::>(), + ) + .split(size); + + // Render menu items + for (i, item) in menu_items.iter().enumerate() { + let style = if Some(i) == state.selected() { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default() + }; + let list_item = ListItem::new(*item).style(style); + f.render_widget( + List::new(vec![list_item]).block(Block::default().borders(Borders::ALL)), + chunks[i], + ); + } + + // Render logs & progress + let logs = log_messages.lock().unwrap(); + let mut combined_logs: Vec = logs + .iter() + .rev() + .take(chunks.last().unwrap().height as usize - 2) + .map(|l| ListItem::new(l.clone())) + .collect(); + + let progress = progress_state.lock().unwrap(); + for (file, status) in progress.iter() { + combined_logs.push(ListItem::new(format!("{}: {}", file, status))); + } + + f.render_widget( + List::new(combined_logs) + .block(Block::default().title("Logs").borders(Borders::ALL)), + *chunks.last().unwrap(), + ); + })?; + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Down => state + .select(state.selected().map(|i| (i + 1).min(menu_items.len() - 1))), + KeyCode::Up => state.select(state.selected().map(|i| i.saturating_sub(1))), + KeyCode::Enter => match state.selected() { + Some(0) => { + // Init environment + let (path, msg) = init_environment(); + lfs_sources = Some(path); + log_messages.lock().unwrap().push(msg); + } + Some(1) => { + // Mirror selection + if mirrors_list.is_empty() { + mirrors_list = mirrors::fetch_mirrors().unwrap_or_else(|_| { + vec![ + "ftp.fau.de".to_string(), + "mirror.kernel.org".to_string(), + "mirror.example.org".to_string(), + ] + }); + } + + let mut mirror_state = ListState::default(); + mirror_state.select(Some(0)); + + loop { + terminal.draw(|f| { + let size = f.area(); + let mirror_items: Vec = mirrors_list + .iter() + .map(|m| ListItem::new(m.clone())) + .collect(); + f.render_widget( + List::new(mirror_items) + .block( + Block::default() + .title("Select Mirror") + .borders(Borders::ALL), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .fg(Color::White), + ), + size, + ); + })?; + + if let Event::Key(k) = event::read()? { + match k.code { + KeyCode::Down => mirror_state.select( + mirror_state + .selected() + .map(|i| (i + 1).min(mirrors_list.len() - 1)), + ), + KeyCode::Up => mirror_state.select( + mirror_state + .selected() + .map(|i| i.saturating_sub(1)), + ), + KeyCode::Enter => { + if let Some(idx) = mirror_state.selected() { + selected_mirror = + Some(mirrors_list[idx].clone()); + log_messages.lock().unwrap().push(format!( + "Selected mirror: {}", + mirrors_list[idx] + )); + } + break; + } + KeyCode::Esc => break, + _ => {} + } + } + } + } + Some(2) => { + // Download packages + if let Some(ref path) = lfs_sources { + let mirror: Option = selected_mirror.clone(); // Correct Option + log_messages.lock().unwrap().push(format!( + "Using mirror: {}", + mirror.clone().unwrap_or_else(|| "default mirror".into()) + )); + + let path_clone = path.clone(); + let log_clone = Arc::clone(&log_messages); + let tx_clone = tx.clone(); + let wget_list = prepare_wget_list(); + let md5_map = prepare_md5_map(); + + if wget_list.is_empty() { + log_clone + .lock() + .unwrap() + .push("⚠️ No packages to download!".into()); + continue; + } + + // Initialize progress + { + let mut prog = progress_state.lock().unwrap(); + prog.clear(); + for file in &wget_list { + prog.insert(file.clone(), "Pending".into()); + } + } + + // Spawn download thread + let progress_clone = Arc::clone(&progress_state); + thread::spawn(move || { + for file in wget_list { + progress_clone + .lock() + .unwrap() + .insert(file.clone(), "Downloading...".into()); + let result = downloader::download_files( + &file, + &path_clone, + mirror.clone(), + Some(&md5_map), + ); + let status = match result { + Ok(_) => "✅ Done", + Err(_) => "❌ Failed", + }; + progress_clone + .lock() + .unwrap() + .insert(file.clone(), status.to_string()); + let _ = tx_clone.send(format!("{} {}", status, file)); + } + let _ = tx_clone.send("🎉 All downloads complete!".into()); + }); + + log_messages + .lock() + .unwrap() + .push("⬇️ Download started...".into()); + } else { + log_messages + .lock() + .unwrap() + .push("⚠️ Initialize environment first!".into()); + } + } + Some(3) => log_messages + .lock() + .unwrap() + .push("🔍 Status check (TODO)".into()), + Some(4) => break, + Some(_) | None => break, + }, + KeyCode::Esc => break, + _ => {} + } + } + } + } + + Ok(()) + })(); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +}