use std::{ collections::BTreeMap, net::SocketAddr, path::{Path, PathBuf}, time::Duration, }; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use futures::{FutureExt, StreamExt}; use jocalsend::{JocalService, ReceiveDialog, ReceiveRequest, TransferEvent, error::Result}; use julid::Julid; use log::{LevelFilter, debug, error, warn}; use ratatui::{ Frame, widgets::{ListState, TableState, WidgetRef}, }; use ratatui_explorer::FileExplorer; use simsearch::{SearchOptions, SimSearch}; use tokio::sync::mpsc::UnboundedReceiver; use tui_input::{Input, backend::crossterm::EventHandler}; pub mod widgets; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Peer { pub alias: String, pub fingerprint: String, pub addr: SocketAddr, } #[derive(Clone)] struct FileFinder { explorer: FileExplorer, fuzzy: SimSearch, working_dir: Option, input: Input, } fn searcher() -> SimSearch { SimSearch::new_with( SearchOptions::new() .stop_words(vec![std::path::MAIN_SEPARATOR_STR.to_string()]) .stop_whitespace(false), ) } pub struct App { pub service: JocalService, pub events: EventStream, pub peers: Vec, pub peer_state: ListState, pub receive_requests: BTreeMap, screen: Vec, receiving_state: TableState, // for getting messages back from the web server or web client about things we've done; the // other end is held by the service event_listener: UnboundedReceiver, file_finder: FileFinder, text: Option, input: Input, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CurrentScreen { Main, Sending(SendingScreen), Receiving, Stopping, Logging, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SendingScreen { Files(FileMode), Peers, Text, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileMode { Picking, Fuzzy, } impl App { pub fn new(service: JocalService, event_listener: UnboundedReceiver) -> Self { App { service, event_listener, screen: vec![CurrentScreen::Main], file_finder: FileFinder::new().expect("could not create file explorer"), text: None, events: Default::default(), peers: Default::default(), peer_state: Default::default(), receive_requests: Default::default(), receiving_state: Default::default(), input: Default::default(), } } pub async fn handle_events(&mut self) -> Result<()> { tokio::select! { event = self.events.next().fuse() => { if let Some(Ok(evt)) = event { match evt { Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key_event(key, evt).await, Event::Mouse(_) => {} Event::Resize(_, _) => {} _ => {} } } } transfer_event = self.event_listener.recv().fuse() => { if let Some(event) = transfer_event { debug!("got transferr event {event:?}"); match event { TransferEvent::ReceiveRequest { id, request } => { self.receive_requests.insert(id, request); } TransferEvent::Cancelled(id) | TransferEvent::Received(id) => { self.receive_requests.remove(&id); } } } } _ = tokio::time::sleep(Duration::from_millis(200)) => {} } Ok(()) } pub fn input(&mut self) -> &mut Input { &mut self.input } pub fn files(&mut self) -> &mut FileExplorer { &mut self.file_finder.explorer } pub fn text(&mut self) -> &mut Option { &mut self.text } pub fn screen(&self) -> CurrentScreen { *self.screen.last().unwrap() } pub fn screen_mut(&mut self) -> &mut CurrentScreen { self.screen.last_mut().unwrap() } async fn handle_key_event(&mut self, key_event: KeyEvent, event: crossterm::event::Event) { let code = key_event.code; let mode = self.screen.last_mut().unwrap(); match mode { CurrentScreen::Main | CurrentScreen::Logging | CurrentScreen::Receiving | CurrentScreen::Sending(SendingScreen::Files(FileMode::Picking)) | CurrentScreen::Sending(SendingScreen::Peers) => match code { KeyCode::Char('q') => self.exit().await, KeyCode::Char('s') => self.send(), KeyCode::Char('r') => self.recv(), KeyCode::Char('l') => self.logs(), KeyCode::Char('m') => self.main(), KeyCode::Esc => self.pop(), _ => match mode { CurrentScreen::Main => { if let KeyCode::Char('d') = code { self.service.refresh_peers().await } } CurrentScreen::Logging => match code { KeyCode::Left => change_log_level(-1), KeyCode::Right => change_log_level(1), _ => {} }, CurrentScreen::Receiving => match code { KeyCode::Up => self.receiving_state.select_previous(), KeyCode::Down => self.receiving_state.select_next(), KeyCode::Char('a') => self.accept(), KeyCode::Char('d') => self.deny(), _ => {} }, CurrentScreen::Sending(sending_screen) => match sending_screen { // we can only be in picking mode SendingScreen::Files(fmode) => match code { KeyCode::Char('t') => *sending_screen = SendingScreen::Text, KeyCode::Tab => *sending_screen = SendingScreen::Peers, KeyCode::Enter => self.chdir_or_send_file().await, KeyCode::Char('/') => { *fmode = FileMode::Fuzzy; } _ => self.file_finder.handle(&event).unwrap_or_default(), }, SendingScreen::Peers => match code { KeyCode::Tab => { *sending_screen = SendingScreen::Files(FileMode::Picking) } KeyCode::Char('t') => *sending_screen = SendingScreen::Text, KeyCode::Enter => self.send_content().await, KeyCode::Up => self.peer_state.select_previous(), KeyCode::Down => self.peer_state.select_next(), _ => {} }, SendingScreen::Text => unreachable!(), }, CurrentScreen::Stopping => unreachable!(), }, }, // we only need to deal with sending text now or doing fuzzy matching CurrentScreen::Sending(sending_screen) => match sending_screen { SendingScreen::Text => match code { KeyCode::Tab => *sending_screen = SendingScreen::Peers, KeyCode::Enter => self.send_text().await, KeyCode::Esc => { self.text = None; self.input.reset(); *sending_screen = SendingScreen::Files(FileMode::Picking); } _ => { if let Some(changed) = self.input.handle_event(&event) && changed.value { if self.input.value().is_empty() { self.text = None; } else { self.text = Some(self.input.to_string()); } } } }, SendingScreen::Files(fmode) => { if *fmode == FileMode::Fuzzy { match code { KeyCode::Tab => *sending_screen = SendingScreen::Peers, KeyCode::Enter => self.chdir_or_send_file().await, KeyCode::Esc => { self.file_finder.reset_fuzzy(); *fmode = FileMode::Picking; } KeyCode::Up | KeyCode::Down => { if let Err(e) = self.file_finder.handle(&event) { log::error!("error selecting file: {e:?}"); } } _ => { self.file_finder.index(); if let Some(changed) = self.file_finder.input.handle_event(&event) && changed.value { let id = self .file_finder .fuzzy .search(self.file_finder.input.value()) .first() .copied() .unwrap_or(0); self.file_finder.explorer.set_selected_idx(id); } } } } } SendingScreen::Peers => unreachable!(), }, CurrentScreen::Stopping => {} } } pub fn draw(&mut self, frame: &mut Frame) { frame.render_widget(self, frame.area()); } pub async fn exit(&mut self) { self.screen.push(CurrentScreen::Stopping); self.service.stop().await; } pub fn send(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Sending(_)) => {} _ => self .screen .push(CurrentScreen::Sending(SendingScreen::Files( FileMode::Picking, ))), } } pub fn recv(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Receiving) => {} _ => self.screen.push(CurrentScreen::Receiving), } } pub fn logs(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Logging) => {} _ => self.screen.push(CurrentScreen::Logging), } } pub fn pop(&mut self) { self.screen.pop(); if self.screen.last().is_none() { self.screen.push(CurrentScreen::Main); } } pub fn main(&mut self) { let last = self.screen.last(); match last { Some(CurrentScreen::Main) => {} _ => self.screen.push(CurrentScreen::Main), } } // accept a content receive request fn accept(&mut self) { let Some(idx) = self.receiving_state.selected() else { return; }; // keys are sorted, so we can use the table selection index let keys: Vec<_> = self.receive_requests.keys().collect(); let Some(key) = keys.get(idx) else { warn!("could not get id from selection index {idx}"); return; }; let Some(req) = self.receive_requests.get(key) else { return; }; if let Err(e) = req.tx.send(ReceiveDialog::Approve) { error!("got error sending upload confirmation: {e:?}"); }; } // reject an content receive request fn deny(&mut self) { let Some(idx) = self.receiving_state.selected() else { return; }; // keys are sorted, so we can use the table selection index let keys: Vec<_> = self.receive_requests.keys().cloned().collect(); let Some(key) = keys.get(idx) else { warn!("could not get id from selection index {idx}"); return; }; let Some(req) = self.receive_requests.get(key).cloned() else { return; }; if let Err(e) = req.tx.send(ReceiveDialog::Deny) { error!("got error sending upload confirmation: {e:?}"); }; self.receive_requests.remove(key); } async fn chdir_or_send_file(&mut self) { let file = self.file_finder.explorer.current().path().clone(); if file.is_dir() && let Err(e) = self.file_finder.set_cwd(&file) { error!("could not list directory {file:?}: {e}"); return; } else if file.is_dir() { return; } let Some(peer_idx) = self.peer_state.selected() else { warn!("no peer selected to send to"); return; }; let Some(peer) = self.peers.get(peer_idx) else { warn!("invalid peer index {peer_idx}"); return; }; if file.is_file() { debug!("sending {file:?}"); if let Err(e) = self.service.send_file(&peer.fingerprint, file).await { error!("got error sending content: {e:?}"); } } } // send content to selected peer, or change directories in the file explorer async fn send_text(&mut self) { debug!("sending text"); let Some(peer_idx) = self.peer_state.selected() else { debug!("no peer selected to send to"); return; }; let Some(peer) = self.peers.get(peer_idx) else { warn!("invalid peer index {peer_idx}"); return; }; let Some(text) = &self.text else { debug!("no text to send"); return; }; if let Err(e) = self.service.send_text(&peer.fingerprint, text).await { error!("got error sending \"{text}\" to {}: {e:?}", peer.alias); } } async fn send_content(&mut self) { if self.text.is_some() { self.send_text().await; } else { self.chdir_or_send_file().await; } } } impl FileFinder { pub fn new() -> Result { Ok(Self { explorer: FileExplorer::new()?, fuzzy: searcher(), working_dir: None, input: Default::default(), }) } pub fn handle(&mut self, event: &Event) -> Result<()> { self.index(); Ok(self.explorer.handle(event)?) } pub fn cwd(&self) -> &Path { self.explorer.cwd() } pub fn set_cwd(&mut self, cwd: &Path) -> Result<()> { self.explorer.set_cwd(cwd)?; self.index(); Ok(()) } pub fn widget(&self) -> impl WidgetRef { self.explorer.widget() } pub fn reset_fuzzy(&mut self) { self.clear_fuzzy(); self.input.reset(); } fn clear_fuzzy(&mut self) { self.fuzzy = searcher(); } fn index(&mut self) { if let Some(owd) = self.working_dir.as_ref() && owd == self.cwd() { return; } self.working_dir = Some(self.cwd().to_path_buf()); self.clear_fuzzy(); for (i, f) in self.explorer.files().iter().enumerate() { self.fuzzy.insert(i, f.name()); } } } fn change_log_level(delta: isize) { let level = log::max_level() as isize; let max = log::LevelFilter::max() as isize; let level = (level + delta).clamp(0, max) as usize; // levelfilter is repr(usize) so this is safe let level = unsafe { std::mem::transmute::(level) }; log::set_max_level(level); }