use crate::network::{DiscoveryService, InterfaceInfo, PeerInfo}; use crate::transfer::{ PrepareTransferRequest, TransferClient, TransferProgress, TransferServer, TransferState, }; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use human_bytes::human_bytes; use ratatui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap, }, Frame, Terminal, }; use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::mpsc; pub enum ModalState { None, ConfirmTransfer(PrepareTransferRequest), InputFilePath { peer_url: String, peer_name: String, input: String, }, InputDirectIp { input: String, }, } pub struct AppState { pub my_name: String, pub iface: InterfaceInfo, pub peers: Vec, pub peer_list_state: ListState, pub transfers: Vec, pub logs: Vec, pub modal: ModalState, pub should_quit: bool, } pub async fn run_tui( discovery: DiscoveryService, server: TransferServer, ) -> Result<(), Box> { // 1. Setup Terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let server_state = server.state(); let my_info = discovery.my_info().clone(); let iface = discovery.selected_interface().clone(); let mut app = AppState { my_name: my_info.name.clone(), iface, peers: Vec::new(), peer_list_state: ListState::default(), transfers: Vec::new(), logs: vec![ "⚡ ZeroSend initialized".to_string(), format!("📡 Listening on interface {} ({})", my_info.ip, my_info.net_type), "🔍 UDP Peer Discovery active".to_string(), ], modal: ModalState::None, should_quit: false, }; let (log_tx, mut log_rx) = mpsc::unbounded_channel::(); let (transfer_update_tx, mut transfer_update_rx) = mpsc::unbounded_channel::(); // Listen for server incoming transfer requests let mut req_rx = server_state.incoming_request_events.subscribe(); let mut trans_rx = server_state.transfer_events.subscribe(); let client = Arc::new(TransferClient::new()); let peers_store = discovery.peers(); // Event Loop let mut last_tick = Instant::now(); let tick_rate = Duration::from_millis(100); while !app.should_quit { // Refresh peer list from discovery { let peers_map = peers_store.read().await; let mut list: Vec = peers_map.values().cloned().collect(); list.sort_by(|a, b| b.is_online.cmp(&a.is_online).then_with(|| a.name.cmp(&b.name))); app.peers = list; if !app.peers.is_empty() && app.peer_list_state.selected().is_none() { app.peer_list_state.select(Some(0)); } } // Receive logs while let Ok(msg) = log_rx.try_recv() { app.logs.push(msg); if app.logs.len() > 100 { app.logs.remove(0); } } // Receive transfer updates from client or server while let Ok(t) = transfer_update_rx.try_recv() { if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) { app.transfers[pos] = t; } else { app.transfers.push(t); } } while let Ok(t) = trans_rx.try_recv() { if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) { app.transfers[pos] = t; } else { app.transfers.push(t); } } // Check for incoming confirmation requests while let Ok(req) = req_rx.try_recv() { app.logs.push(format!( "📥 Transfer request from {} ({} files)", req.sender_name, req.files.len() )); app.modal = ModalState::ConfirmTransfer(req); } // Draw UI terminal.draw(|f| draw_ui(f, &mut app))?; // Handle Keys let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { handle_key_event( key.code, &mut app, &server, &client, &my_info, &discovery, log_tx.clone(), transfer_update_tx.clone(), ) .await; } } } if last_tick.elapsed() >= tick_rate { last_tick = Instant::now(); } } // Restore terminal disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } async fn handle_key_event( code: KeyCode, app: &mut AppState, server: &TransferServer, client: &Arc, my_info: &crate::network::DiscoveryBeacon, discovery: &DiscoveryService, log_tx: mpsc::UnboundedSender, transfer_tx: mpsc::UnboundedSender, ) { match &mut app.modal { ModalState::ConfirmTransfer(req) => { let session_id = req.session_id.clone(); match code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Enter => { let _ = server.accept_transfer(&session_id).await; let _ = log_tx.send(format!("✅ Accepted transfer from {}", req.sender_name)); app.modal = ModalState::None; } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Esc => { let _ = server.reject_transfer(&session_id).await; let _ = log_tx.send(format!("❌ Rejected transfer from {}", req.sender_name)); app.modal = ModalState::None; } _ => {} } } ModalState::InputFilePath { peer_url, peer_name, input } => { match code { KeyCode::Enter => { let path_str = input.trim().to_string(); if !path_str.is_empty() { let path = PathBuf::from(path_str.clone()); let peer_url = peer_url.clone(); let peer_name = peer_name.clone(); let client_clone = client.clone(); let my_info_clone = my_info.clone(); let log_tx_clone = log_tx.clone(); let transfer_tx_clone = transfer_tx.clone(); tokio::spawn(async move { let _ = log_tx_clone.send(format!("📤 Sending {:?} to {}...", path, peer_name)); let res = client_clone .send_files(&peer_url, &peer_name, &my_info_clone, &[path], true, 0, None, move |progress| { let _ = transfer_tx_clone.send(progress); }) .await; match res { Ok(_) => { let _ = log_tx_clone.send(format!("🎉 Sent successfully to {}!", peer_name)); } Err(e) => { let _ = log_tx_clone.send(format!("⚠️ Send failed to {}: {}", peer_name, e)); } } }); } app.modal = ModalState::None; } KeyCode::Esc => { app.modal = ModalState::None; } KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) => { input.push(c); } _ => {} } } ModalState::InputDirectIp { input } => { match code { KeyCode::Enter => { let ip_str = input.trim().to_string(); if !ip_str.is_empty() { let client_clone = client.clone(); let log_tx_clone = log_tx.clone(); let disc_peers = discovery.peers(); tokio::spawn(async move { let _ = log_tx_clone.send(format!("🔍 Pinging direct node at {}...", ip_str)); match client_clone.ping_peer(&ip_str, 53318).await { Ok(beacon) => { let _ = log_tx_clone.send(format!("✨ Found peer: {} ({})", beacon.name, beacon.ip)); let mut p_map = disc_peers.write().await; p_map.insert( beacon.peer_id.clone(), PeerInfo { peer_id: beacon.peer_id, name: beacon.name, os: beacon.os, ip: beacon.ip, transfer_port: beacon.transfer_port, net_type: beacon.net_type, version: beacon.version, last_seen: chrono::Utc::now(), is_online: true, ping_ms: None, shared_folders: beacon.shared_folders, }, ); } Err(e) => { let _ = log_tx_clone.send(format!("❌ Direct ping error: {}", e)); } } }); } app.modal = ModalState::None; } KeyCode::Esc => { app.modal = ModalState::None; } KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) => { input.push(c); } _ => {} } } ModalState::None => { match code { KeyCode::Char('q') | KeyCode::Char('Q') => { app.should_quit = true; } KeyCode::Up | KeyCode::Char('k') => { if !app.peers.is_empty() { let i = match app.peer_list_state.selected() { Some(i) => { if i == 0 { app.peers.len() - 1 } else { i - 1 } } None => 0, }; app.peer_list_state.select(Some(i)); } } KeyCode::Down | KeyCode::Char('j') => { if !app.peers.is_empty() { let i = match app.peer_list_state.selected() { Some(i) => { if i >= app.peers.len() - 1 { 0 } else { i + 1 } } None => 0, }; app.peer_list_state.select(Some(i)); } } KeyCode::Char('s') | KeyCode::Char('S') => { if let Some(i) = app.peer_list_state.selected() { if let Some(peer) = app.peers.get(i) { app.modal = ModalState::InputFilePath { peer_url: peer.transfer_url(), peer_name: peer.name.clone(), input: String::new(), }; } } } KeyCode::Char('d') | KeyCode::Char('D') => { app.modal = ModalState::InputDirectIp { input: String::new(), }; } _ => {} } } } } fn draw_ui(f: &mut Frame, app: &mut AppState) { let size = f.area(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(10), // Body Constraint::Length(3), // Footer / Shortcuts ]) .split(size); // 1. Header let header_text = vec![ Span::styled("⚡ ZeroSend ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::raw("| Node: "), Span::styled(&app.my_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw(" | Interface: "), Span::styled( format!("{} ({})", app.iface.ip, app.iface.net_type.as_str()), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), Span::raw(" | Port: "), Span::styled("53318", Style::default().fg(Color::Magenta)), ]; let header = Paragraph::new(Line::from(header_text)) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Cyan)), ) .alignment(Alignment::Left); f.render_widget(header, chunks[0]); // 2. Body (Left: Peers, Right: Transfers & Logs) let body_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) .split(chunks[1]); // Left Panel: Discovered Peers let items: Vec = app .peers .iter() .map(|p| { let status_icon = if p.is_online { "🟢" } else { "⚪" }; let lines = vec![ Line::from(vec![ Span::raw(format!("{} ", status_icon)), Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), Span::styled(format!(" [{}]", p.net_type), Style::default().fg(Color::Blue)), ]), Line::from(vec![ Span::raw(" IP: "), Span::styled(format!("{}:{}", p.ip, p.transfer_port), Style::default().fg(Color::Gray)), Span::raw(" | OS: "), Span::styled(&p.os, Style::default().fg(Color::DarkGray)), ]), ]; ListItem::new(lines) }) .collect(); let peers_block = Block::default() .title(" 📡 Discovered Peers (ZeroTier / VPN) ") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Blue)); let peers_list = List::new(items) .block(peers_block) .highlight_style( Style::default() .bg(Color::Rgb(30, 58, 138)) .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) .highlight_symbol("▶ "); f.render_stateful_widget(peers_list, body_chunks[0], &mut app.peer_list_state); // Right Panel: Transfers (Top) & Logs (Bottom) let right_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .split(body_chunks[1]); // Transfers List let transfer_block = Block::default() .title(" 📦 Active & Recent Transfers ") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Green)); if app.transfers.is_empty() { let no_transfers = Paragraph::new("No active transfers. Press [S] to send a file.") .style(Style::default().fg(Color::DarkGray)) .block(transfer_block) .alignment(Alignment::Center); f.render_widget(no_transfers, right_chunks[0]); } else { let transfer_items: Vec = app .transfers .iter() .map(|t| { let direction = if t.is_incoming { "📥 IN" } else { "📤 OUT" }; let speed = if t.speed_bps > 0 { format!(" ({}/s)", human_bytes(t.speed_bps as f64)) } else { String::new() }; let state_str = match &t.state { TransferState::PendingConfirmation => "⏳ Waiting confirmation...".to_string(), TransferState::InProgress => format!("{:.1}%{}", t.percentage(), speed), TransferState::Completed => "✅ Completed".to_string(), TransferState::Failed(e) => format!("❌ Failed: {}", e), TransferState::Rejected => "⛔ Rejected".to_string(), TransferState::Paused => "⏸ Paused".to_string(), TransferState::Canceled => "🛑 Canceled".to_string(), }; let lines = vec![ Line::from(vec![ Span::styled(format!("[{}] ", direction), Style::default().fg(Color::Magenta)), Span::styled(&t.current_file_name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), Span::raw(format!(" -> {}", t.peer_name)), ]), Line::from(vec![ Span::raw(" "), Span::styled( format!("{}/{}", human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64)), Style::default().fg(Color::Cyan), ), Span::raw(" | "), Span::styled(state_str, Style::default().fg(Color::Yellow)), ]), ]; ListItem::new(lines) }) .collect(); let trans_list = List::new(transfer_items).block(transfer_block); f.render_widget(trans_list, right_chunks[0]); } // Activity Logs let logs_block = Block::default() .title(" 📝 Activity Log ") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Gray)); let log_lines: Vec = app .logs .iter() .rev() .take(10) .rev() .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White)))) .collect(); let logs_widget = Paragraph::new(log_lines) .block(logs_block) .wrap(Wrap { trim: true }); f.render_widget(logs_widget, right_chunks[1]); // 3. Footer / Shortcuts let shortcuts = Line::from(vec![ Span::styled("[S] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw("Send File "), Span::styled("[D] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw("Direct IP "), Span::styled("[↑/↓] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw("Select Peer "), Span::styled("[Q] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::raw("Quit"), ]); let footer = Paragraph::new(shortcuts) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::DarkGray)), ) .alignment(Alignment::Center); f.render_widget(footer, chunks[2]); // Modals match &app.modal { ModalState::ConfirmTransfer(req) => { let area = centered_rect(60, 30, size); f.render_widget(Clear, area); let block = Block::default() .title(" 📥 Incoming File Transfer ") .borders(Borders::ALL) .border_type(BorderType::Double) .border_style(Style::default().fg(Color::Yellow)); let files_summary = req .files .iter() .map(|f| format!(" - {} ({})", f.name, human_bytes(f.size as f64))) .collect::>() .join("\n"); let text = vec![ Line::from(vec![ Span::styled(&req.sender_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::raw(format!(" wants to send you {} file(s) ({})", req.files.len(), human_bytes(req.total_size as f64))), ]), Line::from(""), Line::from(files_summary), Line::from(""), Line::from(vec![ Span::styled("[Y/A] Accept", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(" | "), Span::styled("[N/R] Reject", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), ]), ]; let modal = Paragraph::new(text) .block(block) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); f.render_widget(modal, area); } ModalState::InputFilePath { peer_name, input, .. } => { let area = centered_rect(65, 25, size); f.render_widget(Clear, area); let block = Block::default() .title(format!(" 📤 Send File to {} ", peer_name)) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Cyan)); let text = vec![ Line::from("Enter absolute or relative path to file/folder:"), Line::from(""), Line::from(vec![ Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), Span::styled("█", Style::default().fg(Color::Cyan)), ]), Line::from(""), Line::from(vec![ Span::styled("[Enter] Send", Style::default().fg(Color::Green)), Span::raw(" "), Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)), ]), ]; let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); f.render_widget(modal, area); } ModalState::InputDirectIp { input } => { let area = centered_rect(60, 25, size); f.render_widget(Clear, area); let block = Block::default() .title(" 🔍 Connect Direct IP (Tailscale / Custom) ") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Magenta)); let text = vec![ Line::from("Enter peer IP address (e.g. 10.147.20.55 or 100.80.x.x):"), Line::from(""), Line::from(vec![ Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), Span::styled("█", Style::default().fg(Color::Magenta)), ]), Line::from(""), Line::from(vec![ Span::styled("[Enter] Connect", Style::default().fg(Color::Green)), Span::raw(" "), Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)), ]), ]; let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); f.render_widget(modal, area); } ModalState::None => {} } } /// Helper function to create centered modal rectangle fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(popup_layout[1])[1] }