Files
ZeroSend/src/ui/tui.rs
T

674 lines
26 KiB
Rust

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<PeerInfo>,
pub peer_list_state: ListState,
pub transfers: Vec<TransferProgress>,
pub logs: Vec<String>,
pub modal: ModalState,
pub should_quit: bool,
}
pub async fn run_tui(
discovery: DiscoveryService,
server: TransferServer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 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::<String>();
let (transfer_update_tx, mut transfer_update_rx) = mpsc::unbounded_channel::<TransferProgress>();
// 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<PeerInfo> = 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<TransferClient>,
my_info: &crate::network::DiscoveryBeacon,
discovery: &DiscoveryService,
log_tx: mpsc::UnboundedSender<String>,
transfer_tx: mpsc::UnboundedSender<TransferProgress>,
) {
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<ListItem> = 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<ListItem> = 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<Line> = 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::<Vec<_>>()
.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]
}