Compare commits

..

2 commits

Author SHA1 Message Date
Joe Ardent
c8621da2f0 add sending screen and keybinds 2025-08-03 12:16:38 -07:00
Joe Ardent
ce87f62317 bikeshed the type names a little, add file picker widget crate 2025-08-03 11:32:12 -07:00
7 changed files with 183 additions and 72 deletions

43
Cargo.lock generated
View file

@ -457,6 +457,18 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "educe"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417"
dependencies = [
"enum-ordinalize",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -472,6 +484,26 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "enum-ordinalize"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5"
dependencies = [
"enum-ordinalize-derive",
]
[[package]]
name = "enum-ordinalize-derive"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "env_filter" name = "env_filter"
version = "0.1.3" version = "0.1.3"
@ -1135,6 +1167,7 @@ dependencies = [
"mime_guess", "mime_guess",
"network-interface", "network-interface",
"ratatui", "ratatui",
"ratatui-explorer",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@ -1623,6 +1656,16 @@ dependencies = [
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]
[[package]]
name = "ratatui-explorer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5135951c1bf2ef7ce25b46c53eacf45f1a3e6b283669229008d6744a9ca1332"
dependencies = [
"educe",
"ratatui",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.13" version = "0.5.13"

View file

@ -18,6 +18,7 @@ mime = "0.3"
mime_guess = "2" mime_guess = "2"
network-interface = { version = "2", features = ["serde"] } network-interface = { version = "2", features = ["serde"] }
ratatui = "0.29" ratatui = "0.29"
ratatui-explorer = "0.2.1"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -2,7 +2,7 @@ use std::{collections::BTreeMap, net::SocketAddr, time::Duration};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind};
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use joecalsend::{JoecalService, JoecalUploadRequest, TransferEvent, UploadDialog, error::Result}; use joecalsend::{JoecalService, ReceiveDialog, ReceiveRequest, TransferEvent, error::Result};
use julid::Julid; use julid::Julid;
use log::{LevelFilter, debug, error, warn}; use log::{LevelFilter, debug, error, warn};
use ratatui::{Frame, widgets::TableState}; use ratatui::{Frame, widgets::TableState};
@ -18,7 +18,7 @@ pub struct App {
pub events: EventStream, pub events: EventStream,
// addr -> (alias, fingerprint) // addr -> (alias, fingerprint)
pub peers: Peers, pub peers: Peers,
pub uploads: BTreeMap<Julid, JoecalUploadRequest>, pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
upload_state: TableState, upload_state: TableState,
// for getting messages back from the web server or web client about things we've done; the // for getting messages back from the web server or web client about things we've done; the
// other end is held by the service // other end is held by the service
@ -28,12 +28,18 @@ pub struct App {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentScreen { pub enum CurrentScreen {
Main, Main,
Sending, Sending(SendingScreen),
Receiving, Receiving,
Stopping, Stopping,
Logging, Logging,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendingScreen {
Files,
Peers,
}
impl App { impl App {
pub fn new(service: JoecalService, event_listener: UnboundedReceiver<TransferEvent>) -> Self { pub fn new(service: JoecalService, event_listener: UnboundedReceiver<TransferEvent>) -> Self {
App { App {
@ -42,7 +48,7 @@ impl App {
screen: vec![CurrentScreen::Main], screen: vec![CurrentScreen::Main],
events: Default::default(), events: Default::default(),
peers: Default::default(), peers: Default::default(),
uploads: Default::default(), receive_requests: Default::default(),
upload_state: Default::default(), upload_state: Default::default(),
} }
} }
@ -65,11 +71,11 @@ impl App {
if let Some(event) = transfer_event { if let Some(event) = transfer_event {
debug!("got transferr event {event:?}"); debug!("got transferr event {event:?}");
match event { match event {
TransferEvent::UploadRequest { id, request } => { TransferEvent::ReceiveRequest { id, request } => {
self.uploads.insert(id, request); self.receive_requests.insert(id, request);
} }
TransferEvent::Cancelled(id) | TransferEvent::Received(id) => { TransferEvent::Cancelled(id) | TransferEvent::Received(id) => {
self.uploads.remove(&id); self.receive_requests.remove(&id);
} }
} }
} }
@ -81,7 +87,7 @@ impl App {
} }
pub fn handle_key_event(&mut self, key_event: KeyEvent) { pub fn handle_key_event(&mut self, key_event: KeyEvent) {
match self.screen.last().unwrap() { match self.screen.last_mut().unwrap() {
CurrentScreen::Logging => match key_event.code { CurrentScreen::Logging => match key_event.code {
KeyCode::Esc => self.pop(), KeyCode::Esc => self.pop(),
KeyCode::Left => change_log_level(-1), KeyCode::Left => change_log_level(-1),
@ -98,6 +104,22 @@ impl App {
KeyCode::Char('q') => self.exit(), KeyCode::Char('q') => self.exit(),
_ => {} _ => {}
}, },
CurrentScreen::Sending(s) => match s {
SendingScreen::Files => match key_event.code {
KeyCode::Esc => self.pop(),
KeyCode::Char('q') => self.exit(),
KeyCode::Tab => *s = SendingScreen::Peers,
KeyCode::Enter => todo!("send the selected file or enter directory"),
_ => todo!("have the file picker handle it"),
},
SendingScreen::Peers => match key_event.code {
KeyCode::Esc => self.pop(),
KeyCode::Char('q') => self.exit(),
KeyCode::Tab => *s = SendingScreen::Files,
KeyCode::Enter => todo!("send to the selected peer"),
_ => {}
},
},
_ => match key_event.code { _ => match key_event.code {
KeyCode::Char('q') => self.exit(), KeyCode::Char('q') => self.exit(),
KeyCode::Char('s') => self.send(), KeyCode::Char('s') => self.send(),
@ -114,15 +136,15 @@ impl App {
return; return;
}; };
// keys are sorted, so we can use the table selection index // keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.uploads.keys().collect(); let keys: Vec<_> = self.receive_requests.keys().collect();
let Some(key) = keys.get(idx) else { let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}"); warn!("could not get id from selection index {idx}");
return; return;
}; };
let Some(req) = self.uploads.get(key) else { let Some(req) = self.receive_requests.get(key) else {
return; return;
}; };
if let Err(e) = req.tx.send(UploadDialog::UploadConfirm) { if let Err(e) = req.tx.send(ReceiveDialog::Approve) {
error!("got error sending upload confirmation: {e:?}"); error!("got error sending upload confirmation: {e:?}");
}; };
} }
@ -132,18 +154,18 @@ impl App {
return; return;
}; };
// keys are sorted, so we can use the table selection index // keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.uploads.keys().cloned().collect(); let keys: Vec<_> = self.receive_requests.keys().cloned().collect();
let Some(key) = keys.get(idx) else { let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}"); warn!("could not get id from selection index {idx}");
return; return;
}; };
let Some(req) = self.uploads.get(key).cloned() else { let Some(req) = self.receive_requests.get(key).cloned() else {
return; return;
}; };
if let Err(e) = req.tx.send(UploadDialog::UploadDeny) { if let Err(e) = req.tx.send(ReceiveDialog::Deny) {
error!("got error sending upload confirmation: {e:?}"); error!("got error sending upload confirmation: {e:?}");
}; };
self.uploads.remove(key); self.receive_requests.remove(key);
} }
pub fn draw(&mut self, frame: &mut Frame) { pub fn draw(&mut self, frame: &mut Frame) {
@ -157,8 +179,10 @@ impl App {
pub fn send(&mut self) { pub fn send(&mut self) {
let last = self.screen.last(); let last = self.screen.last();
match last { match last {
Some(CurrentScreen::Sending) => {} Some(CurrentScreen::Sending(_)) => {}
_ => self.screen.push(CurrentScreen::Sending), _ => self
.screen
.push(CurrentScreen::Sending(SendingScreen::Files)),
} }
} }

View file

@ -1,6 +1,6 @@
use std::sync::LazyLock; use std::sync::LazyLock;
use joecalsend::JoecalUploadRequest; use joecalsend::ReceiveRequest;
use log::LevelFilter; use log::LevelFilter;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
@ -12,7 +12,7 @@ use ratatui::{
}; };
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState}; use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
use super::{App, CurrentScreen, Peers}; use super::{App, CurrentScreen, Peers, SendingScreen};
static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| { static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![ Line::from(vec![
@ -42,7 +42,7 @@ static LOGGING_MENU: LazyLock<Line> = LazyLock::new(|| {
]) ])
}); });
static UPLOADS_MENU: LazyLock<Line> = LazyLock::new(|| { static CONTENT_RECEIVE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![ Line::from(vec![
" Select Previous ".into(), " Select Previous ".into(),
"<UP>".blue().bold(), "<UP>".blue().bold(),
@ -59,11 +59,48 @@ static UPLOADS_MENU: LazyLock<Line> = LazyLock::new(|| {
]) ])
}); });
static CONTENT_SEND_FILE_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Select ".into(),
"<ENTER>".blue().bold(),
" Parent Dir ".into(),
"<LEFT>".blue().bold(),
" Child Dir ".into(),
"<RIGHT>".blue().bold(),
" Peers ".into(),
"<TAB>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
static CONTENT_SEND_PEERS_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Select Previous ".into(),
"<UP>".blue().bold(),
" Select Next ".into(),
"<DOWN>".blue().bold(),
" Select ".into(),
"<ENTER>".blue().bold(),
" Files ".into(),
"<TAB>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
});
impl Widget for &mut App { impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let main_layout = let main_layout = Layout::vertical([Constraint::Min(5), Constraint::Min(3)]);
Layout::vertical([Constraint::Min(5), Constraint::Min(10), Constraint::Min(3)]); let [top, bottom] = main_layout.areas(area);
let [top, _middle, bottom] = main_layout.areas(area);
let footer_layout = let footer_layout =
Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]); Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]);
@ -77,42 +114,68 @@ impl Widget for &mut App {
let subscreen_margin = Margin::new(2, 4); let subscreen_margin = Margin::new(2, 4);
let mode = self.screen.last().unwrap(); let current_screen = self.screen.last().unwrap();
let ups: Vec<_> = self.uploads.values().collect();
match mode { match current_screen {
CurrentScreen::Main => { CurrentScreen::Main => {
main_page(*mode, &MAIN_MENU, area, buf); let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(*current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf); logger(header_right.inner(header_margin), buf);
let peers = PeersWidget { peers: &self.peers }; let peers = PeersWidget { peers: &self.peers };
peers.render(footer_right.inner(footer_margin), buf); peers.render(footer_right.inner(footer_margin), buf);
NetworkInfoWidget.render(footer_left.inner(footer_margin), buf); NetworkInfoWidget.render(footer_left.inner(footer_margin), buf);
upload_requests( receive_requests(
&ups, &rx_reqs,
&mut self.upload_state, &mut self.upload_state,
header_left.inner(header_margin), header_left.inner(header_margin),
buf, buf,
); );
} }
CurrentScreen::Logging => { CurrentScreen::Logging => {
main_page(*mode, &LOGGING_MENU, area, buf); outer_frame(*current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf); logger(area.inner(subscreen_margin), buf);
} }
CurrentScreen::Receiving => { CurrentScreen::Receiving => {
main_page(*mode, &UPLOADS_MENU, area, buf); let rx_reqs: Vec<_> = self.receive_requests.values().collect();
upload_requests( outer_frame(*current_screen, &CONTENT_RECEIVE_MENU, area, buf);
&ups, receive_requests(
&rx_reqs,
&mut self.upload_state, &mut self.upload_state,
area.inner(subscreen_margin), area.inner(subscreen_margin),
buf, buf,
); );
} }
CurrentScreen::Sending(s) => match s {
SendingScreen::Files => {
outer_frame(*current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
}
SendingScreen::Peers => {
outer_frame(*current_screen, &CONTENT_SEND_PEERS_MENU, area, buf)
}
},
_ => { _ => {
main_page(*mode, &MAIN_MENU, area, buf); outer_frame(*current_screen, &MAIN_MENU, area, buf);
} }
} }
} }
} }
fn outer_frame(screen: CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) {
let title = Line::from(" Joecalsend ".bold());
let block = Block::bordered()
.title(title.centered())
.title_bottom(menu.clone().centered())
.border_set(border::THICK);
let current_screen = format!("{screen:?}",);
let text = Text::from(Line::from(current_screen.yellow()));
Paragraph::new(text)
.centered()
.block(block)
.render(area, buf);
}
fn logger(area: Rect, buf: &mut Buffer) { fn logger(area: Rect, buf: &mut Buffer) {
let title = Line::from(log::max_level().as_str()); let title = Line::from(log::max_level().as_str());
let logger = TuiLoggerWidget::default() let logger = TuiLoggerWidget::default()
@ -128,24 +191,8 @@ fn logger(area: Rect, buf: &mut Buffer) {
logger.render(area, buf); logger.render(area, buf);
} }
fn main_page(screen: CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) { fn receive_requests(
let title = Line::from(" Joecalsend ".bold()); requests: &[&ReceiveRequest],
let block = Block::bordered()
.title(title.centered())
.title_bottom(menu.clone().centered())
.border_set(border::THICK);
let current_screen = format!("{screen:?}",);
let text = Text::from(Line::from(current_screen.yellow()));
Paragraph::new(text)
.centered()
.block(block)
.render(area, buf);
}
fn upload_requests(
requests: &[&JoecalUploadRequest],
state: &mut TableState, state: &mut TableState,
area: Rect, area: Rect,
buf: &mut Buffer, buf: &mut Buffer,

View file

@ -39,26 +39,23 @@ pub enum Listeners {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum UploadDialog { pub enum ReceiveDialog {
UploadDeny, Approve,
UploadConfirm, Deny,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TransferEvent { pub enum TransferEvent {
Received(Julid), Received(Julid),
Cancelled(Julid), Cancelled(Julid),
UploadRequest { ReceiveRequest { id: Julid, request: ReceiveRequest },
id: Julid,
request: JoecalUploadRequest,
},
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct JoecalUploadRequest { pub struct ReceiveRequest {
pub alias: String, pub alias: String,
pub files: HashMap<String, FileMetadata>, pub files: HashMap<String, FileMetadata>,
pub tx: UnboundedSender<UploadDialog>, pub tx: UnboundedSender<ReceiveDialog>,
} }
/// Contains the main network and backend state for an application session. /// Contains the main network and backend state for an application session.
@ -83,7 +80,7 @@ impl JoecalService {
) -> crate::error::Result<Self> { ) -> crate::error::Result<Self> {
let socket = UdpSocket::bind(LISTENING_SOCKET_ADDR).await?; let socket = UdpSocket::bind(LISTENING_SOCKET_ADDR).await?;
socket.set_multicast_loop_v4(true)?; socket.set_multicast_loop_v4(true)?;
socket.set_multicast_ttl_v4(2)?; // one hop out from localnet socket.set_multicast_ttl_v4(8)?; // 8 hops out from localnet
socket.join_multicast_v4(MULTICAST_IP, Ipv4Addr::from_bits(0))?; socket.join_multicast_v4(MULTICAST_IP, Ipv4Addr::from_bits(0))?;
Ok(Self { Ok(Self {

View file

@ -62,15 +62,15 @@ async fn start_and_run(
.insert(addr.to_owned(), (alias, fingerprint.to_owned())); .insert(addr.to_owned(), (alias, fingerprint.to_owned()));
}); });
let mut stale_uploads = Vec::with_capacity(app.uploads.len()); let mut stale_uploads = Vec::with_capacity(app.receive_requests.len());
let now = chrono::Utc::now().timestamp_millis() as u64; let now = chrono::Utc::now().timestamp_millis() as u64;
for (id, request) in app.uploads.iter() { for (id, request) in app.receive_requests.iter() {
if request.tx.is_closed() || (now - id.timestamp()) > 60_000 { if request.tx.is_closed() || (now - id.timestamp()) > 60_000 {
stale_uploads.push(*id); stale_uploads.push(*id);
} }
} }
for id in stale_uploads { for id in stale_uploads {
app.uploads.remove(&id); app.receive_requests.remove(&id);
} }
} }

View file

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
use crate::{ use crate::{
JoecalService, JoecalUploadRequest, TransferEvent, UploadDialog, JoecalService, ReceiveDialog, ReceiveRequest, TransferEvent,
error::{LocalSendError, Result}, error::{LocalSendError, Result},
models::{Device, FileMetadata}, models::{Device, FileMetadata},
}; };
@ -198,7 +198,7 @@ pub async fn register_prepare_upload(
let id = Julid::new(); let id = Julid::new();
let (tx, mut rx) = unbounded_channel(); let (tx, mut rx) = unbounded_channel();
let request = JoecalUploadRequest { let request = ReceiveRequest {
alias: req.info.alias.clone(), alias: req.info.alias.clone(),
files: req.files.clone(), files: req.files.clone(),
tx, tx,
@ -206,7 +206,7 @@ pub async fn register_prepare_upload(
match service match service
.transfer_event_tx .transfer_event_tx
.send(TransferEvent::UploadRequest { id, request }) .send(TransferEvent::ReceiveRequest { id, request })
{ {
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@ -215,14 +215,13 @@ pub async fn register_prepare_upload(
} }
} }
let confirmation = rx.recv().await; let Some(confirmation) = rx.recv().await else {
let Some(confirmation) = confirmation else {
// the frontend must have dropped the tx before trying to send a reply back // the frontend must have dropped the tx before trying to send a reply back
warn!("could not read content receive response from the frontend");
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}; };
if confirmation != UploadDialog::UploadConfirm { if confirmation != ReceiveDialog::Approve {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }