From be5dc6c064a9244fb42e3d0d947c9e6273c26242 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Fri, 24 Jul 2026 21:10:51 +0200 Subject: [PATCH] fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work Peer layer: - Stream peer messages with a partial-write queue instead of write_all, so a large SharedFileListResponse over the non-blocking socket is no longer truncated (third-party clients like SoulseekQt can now browse our shares). - Bound the peer-init handshake with a read timeout and handle each accepted connection on its own thread, so one stalled peer can't wedge the listener. - e2e: third-party direct/brokered/large-share browse + stalled-peer coverage. TUI: - Uploads share the downloads table columns (real Speed) and the info pane now renders details for a selected upload. - Download progress bar counts only in-progress transfers and hides when idle. - Always-visible unread badge (private messages + rooms) in the shortcuts bar, self-drawn blink so it works under tmux. - Persist the unread private-message count across restarts. --- Cargo.lock | 123 +++++++- soulseek-rs-lib/src/actor/peer_actor.rs | 79 ++++- soulseek-rs-lib/src/client/mod.rs | 79 ++++- soulseek-rs-lib/src/client/uploads.rs | 1 + soulseek-rs-lib/src/peer/listen.rs | 70 +++-- soulseek-rs-lib/src/types.rs | 3 + soulseek-rs-lib/tests/e2e.rs | 273 ++++++++++++++++++ soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/models/app_state.rs | 113 +++++++- soulseek-rs/src/models/rooms.rs | 9 +- soulseek-rs/src/persist/snapshot.rs | 95 +++++- soulseek-rs/src/persist/state.rs | 62 +++- soulseek-rs/src/ui/downloads.rs | 137 +++++++-- soulseek-rs/src/ui/main_tui/input.rs | 58 +++- soulseek-rs/src/ui/main_tui/mod.rs | 16 +- soulseek-rs/src/ui/main_tui/render.rs | 202 +++++++++---- soulseek-rs/src/ui/main_tui/rooms.rs | 11 +- soulseek-rs/src/ui/main_tui/search.rs | 36 ++- soulseek-rs/src/ui/main_tui/settings.rs | 3 + soulseek-rs/src/ui/panes/chat_pane.rs | 135 +++++++++ .../src/ui/panes/download_info_pane.rs | 121 +++++++- soulseek-rs/src/ui/panes/downloads_pane.rs | 11 +- soulseek-rs/src/ui/panes/mod.rs | 4 +- soulseek-rs/src/ui/panes/rooms_pane.rs | 20 +- 24 files changed, 1503 insertions(+), 159 deletions(-) create mode 100644 soulseek-rs/src/ui/panes/chat_pane.rs diff --git a/Cargo.lock b/Cargo.lock index 827ff2f..81f5275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -410,6 +419,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -433,6 +452,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -929,6 +960,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -1145,6 +1182,30 @@ dependencies = [ "itoa", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -2356,6 +2417,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2409,6 +2476,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" name = "soulseek-rs" version = "6.0.0" dependencies = [ + "chrono", "clap", "color-eyre", "directories", @@ -2828,7 +2896,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3071,12 +3139,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index f0d86bd..8b05a7a 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -80,8 +80,17 @@ pub struct PeerActor { /// Transfer tokens for uploads we are serving to this peer. A TransferResponse /// for one of these is our upload being accepted, not a download offer. serving_tokens: std::collections::HashSet, + /// Bytes queued for the peer that the socket would not take yet. The stream + /// is non-blocking (so socket reads never starve the mailbox), so a message + /// larger than the send buffer — a full share listing is easily megabytes — + /// only partly fits; the rest drains on later ticks. + pending_write: Vec, } +/// Cap on queued outbound bytes. A peer that stops reading must not be able to +/// grow this without bound; dropping that connection is the right answer. +const MAX_PENDING_WRITE: usize = 32 * 1024 * 1024; + impl PeerActor { #[must_use] pub fn new( @@ -115,6 +124,7 @@ impl PeerActor { disconnect_reported: false, id, serving_tokens: std::collections::HashSet::new(), + pending_write: Vec::new(), } } @@ -512,10 +522,10 @@ impl PeerActor { fn send_message(&mut self, message: Message) { let username = self.peer_username(); - let Some(stream) = self.stream.as_mut() else { + if self.stream.is_none() { error!("Cannot send message: stream is None"); return; - }; + } trace!( "[peer:{}] ➡ {:?}", @@ -530,7 +540,51 @@ impl PeerActor { .map_err(|e| e.to_string()) ); - if let Err(e) = stream.write_all(&message.get_buffer()) { + // Queue then drain, so messages always leave in order even when an + // earlier one is still partly buffered. + self.pending_write.extend_from_slice(&message.get_buffer()); + self.flush_pending_write(); + } + + /// Push as much of `pending_write` into the socket as it will take. A + /// non-blocking socket refuses the rest with `WouldBlock`; that is normal + /// backpressure, not a failure, so the remainder stays queued for the next + /// tick. Only a real I/O error disconnects. + fn flush_pending_write(&mut self) { + if self.pending_write.is_empty() { + return; + } + + let username = self.peer_username(); + let Some(stream) = self.stream.as_mut() else { + return; + }; + + let mut written = 0; + let outcome = loop { + match stream.write(&self.pending_write[written..]) { + Ok(0) => { + break Err(Error::new( + io::ErrorKind::WriteZero, + "peer stopped accepting data", + )); + } + Ok(n) => { + written += n; + if written == self.pending_write.len() { + break Ok(()); + } + } + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + break Ok(()); + } + Err(e) => break Err(e), + } + }; + self.pending_write.drain(..written); + + if let Err(e) = outcome { error!( "[peer:{}] Error writing message: {}. Disconnecting.", username, e @@ -539,12 +593,17 @@ impl PeerActor { return; } - if let Err(e) = stream.flush() { + // Backpressure is normal, but what a peer has still not taken after we + // pushed everything we could is a peer that is not reading at all. + if self.pending_write.len() > MAX_PENDING_WRITE { error!( - "[peer:{}] Error flushing stream: {}. Disconnecting.", - username, e + "[peer:{}] Outbound queue stuck above {} bytes. Disconnecting.", + username, MAX_PENDING_WRITE ); - self.disconnect_with_error(e); + self.disconnect_with_error(Error::new( + io::ErrorKind::WriteZero, + "peer is not draining our outbound queue", + )); } } @@ -785,7 +844,13 @@ impl Actor for PeerActor { } ConnectionState::Connected => { if self.stream.is_some() { + // a backlogged message drains one socket buffer + // per 100ms tick. Enough for a share listing; if a future + // message stream needs more throughput, wake the actor as + // soon as the socket is writable instead of on the tick. + self.flush_pending_write(); self.process_read(); + self.flush_pending_write(); } } ConnectionState::Disconnected => {} diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 3eed85a..f27989e 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -71,6 +71,30 @@ struct ActiveUpload { bytes_sent: Arc, cancel: Arc, status: crate::types::UploadStatus, + /// When streaming began, so a snapshot can report a transfer rate. + started: Instant, +} + +/// Transfer rate of an in-progress upload, in bytes per second. Finished, +/// cancelled and failed uploads report zero, matching how a download reports +/// its speed only while running. +/// +/// an average over the whole transfer rather than the rolling window +/// the download path samples. Enough to fill the Speed column; sample a window +/// if the figure ever needs to track sudden stalls. +fn upload_speed( + status: &crate::types::UploadStatus, + bytes_sent: u64, + started: Instant, +) -> f64 { + if !matches!(status, crate::types::UploadStatus::InProgress) { + return 0.0; + } + let elapsed = started.elapsed().as_secs_f64(); + if elapsed <= 0.0 { + return 0.0; + } + bytes_sent as f64 / elapsed } /// Build a `FileSearchResponse` for `query` against `shares`, or `None` if @@ -662,6 +686,12 @@ impl Client { } } + /// The username we log in as, for attributing our own messages. + #[must_use] + pub fn username(&self) -> &str { + &self.username + } + /// The directories whose files are currently shared with other peers. #[must_use] pub fn shared_directories(&self) -> Vec { @@ -692,13 +722,19 @@ impl Client { .into_iter() .map(|token| { let upload = &ctx.active_uploads[token]; + let bytes_sent = upload + .bytes_sent + .load(std::sync::atomic::Ordering::Relaxed); crate::types::UploadInfo { username: upload.username.clone(), filename: upload.filename.clone(), size: upload.size, - bytes_sent: upload - .bytes_sent - .load(std::sync::atomic::Ordering::Relaxed), + bytes_sent, + speed_bytes_per_sec: upload_speed( + &upload.status, + bytes_sent, + upload.started, + ), status: upload.status.clone(), } }) @@ -773,3 +809,40 @@ mod operations; mod rooms; mod search; mod uploads; + +#[cfg(test)] +mod tests { + use super::upload_speed; + use crate::types::UploadStatus; + use std::time::{Duration, Instant}; + + #[test] + fn upload_speed_is_reported_only_while_running() { + let two_seconds_ago = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("clock supports a 2s offset"); + + // 1 KiB over ~2s is ~512 B/s. + let rate = + upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); + assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); + + // A finished upload reports no rate, exactly as a finished download + // does, so the Speed column renders "-" rather than a stale figure. + for status in [ + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".to_string()), + ] { + let rate = upload_speed(&status, 1024, two_seconds_ago); + assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); + } + + // A just-started upload must not divide by a zero elapsed time. + let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); + assert!( + rate.is_finite() && rate.abs() < f64::EPSILON, + "unexpected rate {rate}" + ); + } +} diff --git a/soulseek-rs-lib/src/client/uploads.rs b/soulseek-rs-lib/src/client/uploads.rs index e395318..1d97daa 100644 --- a/soulseek-rs-lib/src/client/uploads.rs +++ b/soulseek-rs-lib/src/client/uploads.rs @@ -32,6 +32,7 @@ impl Client { bytes_sent: bytes_sent.clone(), cancel: cancel.clone(), status: UploadStatus::InProgress, + started: std::time::Instant::now(), }, ); drop(ctx); diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index a878c96..ed1e855 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -3,6 +3,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::mpsc::Sender; use std::sync::{Arc, RwLock}; use std::thread; +use std::time::Duration; use crate::client::{ClientContext, ClientOperation}; @@ -14,6 +15,9 @@ use crate::{DownloadStatus, debug, error, info, trace}; const PEER_INIT_MESSAGE_CODE: u8 = 1; +/// How long an accepted peer gets to send its peer-init handshake. +const PEER_INIT_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Clone)] struct ConnectionContext { client_sender: Sender, @@ -31,13 +35,29 @@ fn read_peer_init_message( stream: &mut TcpStream, reader: &mut MessageReader, ) -> io::Result { - loop { + // An untrusted peer gets a bounded handshake. Without this a peer that + // connects and stays silent parks this read forever, and one that hangs up + // spins it (a zero-byte read is EOF, which `read_from_socket` reports as + // success) — either way pinning a thread that owes us a peer init. + stream.set_read_timeout(Some(PEER_INIT_TIMEOUT))?; + let message = loop { + let buffered = reader.buffer_len(); reader.read_from_socket(stream)?; + if reader.buffer_len() == buffered { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "peer closed before sending a peer init", + )); + } if let Ok(Some(msg)) = reader.extract_message() { - return Ok(msg); + break msg; } - } + }; + + // Whatever this connection turns into reads on its own terms from here. + stream.set_read_timeout(None)?; + Ok(message) } fn parse_peer_init_message(mut message: Message) -> Option { @@ -285,11 +305,15 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) { let mut stream = stream; let mut reader = MessageReader::new(); - let Ok(message) = read_peer_init_message(&mut stream, &mut reader) else { - error!( - "[listener:{peer_ip}:{peer_port}] Failed to read peer init message" - ); - return; + // A peer that dials and then goes away is routine, not an error. + let message = match read_peer_init_message(&mut stream, &mut reader) { + Ok(message) => message, + Err(e) => { + debug!( + "[listener:{peer_ip}:{peer_port}] no peer init message: {e}" + ); + return; + } }; // A firewalled peer brokered through the server connects back with a @@ -330,22 +354,15 @@ fn handle_incoming_connection(stream: TcpStream, context: ConnectionContext) { peer, stream, reader, &context, &peer_ip, peer_port, ), - ConnectionType::F => { - thread::spawn(move || { - trace!( - "[listener:{peer_ip}:{peer_port}] handling file connection in thread" - ); - handle_file_connection( - peer, - stream, - reader, - init_data.token, - &context, - &peer_ip, - peer_port, - ); - }); - } + ConnectionType::F => handle_file_connection( + peer, + stream, + reader, + init_data.token, + &context, + &peer_ip, + peer_port, + ), ConnectionType::D => { debug!( "[listener:{peer_ip}:{peer_port}] connection type is D, not supported yet, closing connection. " @@ -384,7 +401,10 @@ impl Listen { }; let context = context.clone(); - handle_incoming_connection(stream, context); + // One thread per connection: the peer-init handshake blocks, and a + // peer that is slow to send one must not stop us accepting anybody + // else — a wedged accept loop makes us unreachable to every peer. + thread::spawn(move || handle_incoming_connection(stream, context)); } } } diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 6c50854..03f0a23 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -271,4 +271,7 @@ pub struct UploadInfo { pub size: u64, pub bytes_sent: u64, pub status: UploadStatus, + /// Transfer rate, so an upload row can fill the same Speed column a + /// download does. Zero unless the upload is in progress. + pub speed_bytes_per_sec: f64, } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index a1ccbc5..ef268b8 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -1433,3 +1433,276 @@ fn browse_a_firewalled_peer_via_broker() { let _ = std::fs::remove_dir_all(share_dir); } + +/// Browse `sharer_addr` exactly the way a third-party client (SoulseekQt, +/// Nicotine+) does: dial the peer's listener directly, announce with a +/// `PeerInit(P)`, ask for the share list (peer code 4) and read the +/// `SharedFileListResponse` (peer code 5) back off the same socket. +fn third_party_browse( + sharer_addr: &str, + username: &str, + read_delay: Duration, + timeout: Duration, +) -> std::io::Result> { + let mut p = connect_retry(sharer_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(timeout))?; + p.write_all(&peer_init_bytes(username, "P", 0))?; + p.write_all(&MessageFactory::build_get_share_file_list().get_buffer())?; + p.flush()?; + + // A real peer is not always ready to drain the socket the instant the + // response starts arriving; a slow reader must not cost us the listing. + std::thread::sleep(read_delay); + + let mut response = expect_code(&mut p, 5, timeout)?; + response.set_pointer(8); + Ok(soulseek_rs::message::peer::parse_shared_file_list( + &mut response, + )) +} + +// A third-party client browsing us over a direct connection — the scenario of +// running soulseek-rs and SoulseekQt side by side. The other two browse tests +// only prove soulseek-rs can talk to itself. +#[test] +fn a_third_party_client_browses_our_shares_directly() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("track.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_qt_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // The browsing client is a real logged-in user, as SoulseekQt would be. + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_qt_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_qt_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("third-party client should receive a SharedFileListResponse"); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|(name, _)| name == "track.flac")), + "the listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// A third-party client that cannot dial us directly browses us through the +// server broker: it asks the server to broker, we connect back with a +// PierceFirewall, and it browses over that connection. This is the path a +// second client on the same machine takes whenever the direct dial to our +// advertised (public) address does not come back to us. +#[test] +fn a_third_party_client_browses_our_shares_via_the_server_broker() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("brokered.flac"), b"xxxx") + .unwrap(); + + // The sharer does not listen at all, so the browse can only be brokered. + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.settings("e2e_qt_brok_sharer", "pw") + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + // The third-party client listens and advertises its port, then asks the + // server to broker a P connection from the sharer. + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("browser listener"); + let browser_port = listener.local_addr().unwrap().port(); + let server_addr = format!("{}:{}", server.host, server.port); + let mut srv = login_raw(&server_addr, "e2e_qt_brok_browser", "pw") + .expect("third-party client logs in"); + srv.write_all( + &MessageFactory::build_set_wait_port_message(browser_port).get_buffer(), + ) + .expect("set wait port"); + srv.flush().expect("flush wait port"); + std::thread::sleep(Duration::from_secs(1)); + + let token = 424_242_u32; + srv.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_qt_brok_sharer", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + srv.flush().expect("flush ConnectToPeer"); + + // The sharer must dial us back, quoting our correlation token. + listener.set_nonblocking(true).expect("non-blocking accept"); + let deadline = Instant::now() + Duration::from_secs(20); + let mut accepted = None; + while Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) => { + accepted = Some(stream); + break; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => panic!("accept failed: {e}"), + } + } + let mut p = accepted.expect("the sharer should connect back to us"); + p.set_nonblocking(false).expect("blocking peer socket"); + p.set_read_timeout(Some(Duration::from_secs(20))) + .expect("read timeout"); + + let mut pierce = read_framed(&mut p).expect("a PierceFirewall frame"); + assert_eq!( + pierce.get_message_code(), + 0, + "the brokered connect-back must start with a PierceFirewall" + ); + pierce.set_pointer(5); // length prefix (4) + int8 code (1) + assert_eq!( + pierce.read_int32(), + token, + "the PierceFirewall must quote the token we brokered with" + ); + + p.write_all(&MessageFactory::build_get_share_file_list().get_buffer()) + .expect("request the share list"); + p.flush().expect("flush share list request"); + + let mut response = expect_code(&mut p, 5, Duration::from_secs(20)) + .expect("a SharedFileListResponse over the brokered connection"); + response.set_pointer(8); + let directories = + soulseek_rs::message::peer::parse_shared_file_list(&mut response); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|(name, _)| name == "brokered.flac")), + "the brokered listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// Real peers connect to our listener and go away again (a dropped dial, a port +// scan, a client that gave up). None of that may cost us the listener: the next +// client to come along must still be able to browse. +#[test] +fn a_stalled_peer_connection_does_not_wedge_the_listener() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("still.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_wedge_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let sharer_addr = format!("127.0.0.1:{sharer_port}"); + + // A peer that connects and hangs up without ever sending a PeerInit. + drop( + connect_retry(&sharer_addr, Duration::from_secs(5)) + .expect("dial the listener"), + ); + // A peer that connects and then just sits there, saying nothing. + let _silent = connect_retry(&sharer_addr, Duration::from_secs(5)) + .expect("dial the listener"); + + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_wedge_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &sharer_addr, + "e2e_wedge_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("the listener must still serve browse requests"); + + assert!( + directories + .iter() + .any(|d| d.files.iter().any(|(name, _)| name == "still.flac")), + "the listing should include the shared file, got {directories:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// The same direct browse against a share big enough that the response cannot +// fit in one socket buffer. This is the realistic case: a shared music library +// serialises to hundreds of KB, and the payload is zlib-STORED (not actually +// compressed), so the listing goes out at full size. +#[test] +fn a_third_party_client_browses_a_large_share() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir().join("big"); + let _ = std::fs::remove_dir_all(&share_dir); + std::fs::create_dir_all(&share_dir).unwrap(); + // ~4000 entries with long names: well over any socket buffer once framed. + let padding = "x".repeat(150); + for i in 0..4000 { + std::fs::write(share_dir.join(format!("{padding}-{i:05}.flac")), b"z") + .unwrap(); + } + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_qt_big_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_qt_big_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_qt_big_browser", + Duration::from_secs(1), + Duration::from_secs(20), + ) + .expect("third-party client should receive the full large listing"); + + let file_count: usize = directories.iter().map(|d| d.files.len()).sum(); + assert_eq!( + file_count, 4000, + "the whole listing must arrive, not just what fit in one socket buffer" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 32334d4..63639f7 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -39,6 +39,7 @@ directories = "6" # + async-io + crypto-rust) instead of sync-secret-service, which links the # system libdbus and breaks builds without libdbus-1-dev. keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } [dev-dependencies] tempfile = "3" diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 5315f8d..1879c7c 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,4 +1,5 @@ use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; +use chrono::{DateTime, Local}; use ratatui::{layout::Rect, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; @@ -47,12 +48,14 @@ pub enum MessageDirection { Outgoing, } -/// A private message shown in the inbox popup. +/// A private message shown in the chat popup. pub struct ChatMessage { pub direction: MessageDirection, /// The other party: the sender for incoming, the recipient for outgoing. pub peer: String, pub text: String, + /// Local wall-clock time the message was sent or received. + pub at: DateTime, } #[allow(clippy::struct_excessive_bools)] @@ -93,6 +96,11 @@ pub struct AppState { pub show_messages: bool, /// Incoming private messages received while the inbox was closed. pub unread_messages: usize, + /// Conversation the chat popup is pinned to; unset follows the newest. + pub chat_peer: Option, + /// Compose buffer for the active conversation. + pub chat_input: String, + pub chat_composing: bool, // Browse users' shared files (one tab per user) pub browse: BrowseTabs, @@ -157,6 +165,9 @@ impl AppState { messages: Vec::new(), show_messages: false, unread_messages: 0, + chat_peer: None, + chat_input: String::new(), + chat_composing: false, browse: BrowseTabs::new(), show_browse: false, @@ -176,6 +187,62 @@ impl AppState { } } + /// Open the chat popup on a conversation with `peer`, ready to type. + pub fn open_chat(&mut self, peer: String) { + self.chat_peer = Some(peer); + self.chat_input.clear(); + self.chat_composing = true; + self.show_messages = true; + self.unread_messages = 0; + } + + /// The conversation the chat popup shows: the pinned peer, else whoever + /// was last talked to. + #[must_use] + pub fn active_chat_peer(&self) -> Option<&str> { + self.chat_peer + .as_deref() + .or_else(|| self.messages.last().map(|m| m.peer.as_str())) + } + + /// Conversation partners in first-contact order. Rescans every + /// message per frame; an inbox is never big enough to care. + #[must_use] + pub fn chat_peers(&self) -> Vec<&str> { + let mut peers: Vec<&str> = Vec::new(); + for message in &self.messages { + if !peers.contains(&message.peer.as_str()) { + peers.push(&message.peer); + } + } + // A conversation opened but not yet written to has no messages. + if let Some(peer) = self.chat_peer.as_deref() + && !peers.contains(&peer) + { + peers.push(peer); + } + peers + } + + /// Pin the chat popup to the next (or previous) conversation. + pub fn cycle_chat_peer(&mut self, forward: bool) { + let peers = self.chat_peers(); + if peers.is_empty() { + return; + } + let current = self + .active_chat_peer() + .and_then(|active| peers.iter().position(|p| *p == active)) + .unwrap_or(0); + let next = if forward { + (current + 1) % peers.len() + } else { + (current + peers.len() - 1) % peers.len() + }; + let peer = peers[next].to_string(); + self.chat_peer = Some(peer); + } + #[allow(dead_code)] #[must_use] pub fn get_selected_search(&self) -> Option<&SearchEntry> { @@ -195,3 +262,47 @@ impl Default for AppState { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn say(state: &mut AppState, peer: &str) { + state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: peer.to_string(), + text: "hi".to_string(), + at: Local::now(), + }); + } + + #[test] + fn chat_defaults_to_the_newest_conversation_and_cycles() { + let mut state = AppState::new(); + assert_eq!(state.active_chat_peer(), None); + + say(&mut state, "alice"); + say(&mut state, "bob"); + say(&mut state, "alice"); + assert_eq!(state.chat_peers(), vec!["alice", "bob"]); + assert_eq!(state.active_chat_peer(), Some("alice")); + + state.cycle_chat_peer(true); + assert_eq!(state.active_chat_peer(), Some("bob")); + state.cycle_chat_peer(true); // wraps + assert_eq!(state.active_chat_peer(), Some("alice")); + state.cycle_chat_peer(false); + assert_eq!(state.active_chat_peer(), Some("bob")); + } + + #[test] + fn opening_a_chat_pins_a_peer_with_no_messages_yet() { + let mut state = AppState::new(); + say(&mut state, "alice"); + state.open_chat("carol".to_string()); + assert_eq!(state.active_chat_peer(), Some("carol")); + assert!(state.show_messages && state.chat_composing); + assert_eq!(state.unread_messages, 0); + assert_eq!(state.chat_peers(), vec!["alice", "carol"]); + } +} diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 46464a7..f3cee71 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Local}; use soulseek_rs::types::{RoomEvent, RoomInfo}; /// One line in a room's chat log. A `None` username marks a system line @@ -6,19 +7,23 @@ use soulseek_rs::types::{RoomEvent, RoomInfo}; pub struct RoomLine { pub username: Option, pub text: String, + /// Local wall-clock time the line was appended. + pub at: DateTime, } impl RoomLine { - const fn chat(username: String, text: String) -> Self { + fn chat(username: String, text: String) -> Self { Self { username: Some(username), text, + at: Local::now(), } } - const fn system(text: String) -> Self { + fn system(text: String) -> Self { Self { username: None, text, + at: Local::now(), } } } diff --git a/soulseek-rs/src/persist/snapshot.rs b/soulseek-rs/src/persist/snapshot.rs index 2a0e0ac..69bf92e 100644 --- a/soulseek-rs/src/persist/snapshot.rs +++ b/soulseek-rs/src/persist/snapshot.rs @@ -2,15 +2,27 @@ //! parts of it). Live handles (channels, cancel flags) never leave the //! process; only plain data goes to disk. -use super::state::PersistedDownload; -use crate::models::{AppState, SearchEntry, SearchStatus}; +use super::state::{PersistedDownload, PersistedMessage}; +use crate::models::{ + AppState, ChatMessage, MessageDirection, SearchEntry, SearchStatus, +}; use soulseek_rs::DownloadStatus; +/// How much private-message history survives a restart. +/// a flat cap on the whole log rather than per conversation; make +/// it per-peer if one chatty contact starts evicting everyone else. +const MESSAGE_HISTORY_LIMIT: usize = 500; + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Snapshot { pub downloads: Vec, pub queries: Vec, pub rooms: Vec, + pub messages: Vec, + /// Unread private-message count, so the badge survives a restart. Room + /// unread is deliberately not persisted: room chat logs are not saved, so + /// a restored room count would flag messages that no longer exist. + pub unread_messages: usize, } impl Snapshot { @@ -48,14 +60,44 @@ impl Snapshot { .map(|room| room.name.clone()) .collect(); + // Keep the newest messages; older ones fall off the end. + let start = state.messages.len().saturating_sub(MESSAGE_HISTORY_LIMIT); + let messages = state.messages[start..] + .iter() + .map(|message| PersistedMessage { + peer: message.peer.clone(), + outgoing: message.direction == MessageDirection::Outgoing, + text: message.text.clone(), + at: message.at, + }) + .collect(); + Self { downloads, queries, rooms, + messages, + unread_messages: state.unread_messages, } } } +/// Rebuild the private-message log from disk. +pub fn restore_messages(state: &mut AppState, messages: &[PersistedMessage]) { + for message in messages { + state.messages.push(ChatMessage { + direction: if message.outgoing { + MessageDirection::Outgoing + } else { + MessageDirection::Incoming + }, + peer: message.peer.clone(), + text: message.text.clone(), + at: message.at, + }); + } +} + /// Rebuild search history entries from persisted query strings. Restored /// searches are inert (completed, no results) — selecting one shows an /// empty result set until it is re-run. @@ -155,6 +197,13 @@ mod tests { ); } + #[test] + fn capture_carries_the_unread_count() { + let mut state = AppState::new(); + state.unread_messages = 3; + assert_eq!(Snapshot::capture(&state).unread_messages, 3); + } + #[test] fn capture_takes_open_room_names() { let mut state = AppState::new(); @@ -180,6 +229,48 @@ mod tests { assert!(state.searches[0].results.is_empty()); } + #[test] + fn messages_round_trip_through_a_snapshot() { + let mut state = AppState::new(); + state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: "alice".into(), + text: "hey".into(), + at: chrono::Local::now(), + }); + state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: "alice".into(), + text: "hey yourself".into(), + at: chrono::Local::now(), + }); + + let snapshot = Snapshot::capture(&state); + let mut restored = AppState::new(); + restore_messages(&mut restored, &snapshot.messages); + + assert_eq!(Snapshot::capture(&restored).messages, snapshot.messages); + assert_eq!(restored.messages[1].text, "hey yourself"); + assert_eq!(restored.messages[1].direction, MessageDirection::Outgoing); + assert_eq!(restored.active_chat_peer(), Some("alice")); + } + + #[test] + fn capture_keeps_only_the_newest_messages() { + let mut state = AppState::new(); + for i in 0..MESSAGE_HISTORY_LIMIT + 10 { + state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: "alice".into(), + text: i.to_string(), + at: chrono::Local::now(), + }); + } + let snapshot = Snapshot::capture(&state); + assert_eq!(snapshot.messages.len(), MESSAGE_HISTORY_LIMIT); + assert_eq!(snapshot.messages[0].text, "10", "oldest are dropped"); + } + #[test] fn capture_of_restored_searches_round_trips() { let mut state = AppState::new(); diff --git a/soulseek-rs/src/persist/state.rs b/soulseek-rs/src/persist/state.rs index dd8c132..478443f 100644 --- a/soulseek-rs/src/persist/state.rs +++ b/soulseek-rs/src/persist/state.rs @@ -1,4 +1,5 @@ -//! Versioned JSON state files (downloads, search queries, open rooms). +//! Versioned JSON state files (downloads, search queries, open rooms, +//! private-message history). //! //! Each file is an envelope `{ "version": N, "data": ... }`. On load the //! data passes through the migration chain from its stored version up to @@ -24,6 +25,16 @@ pub struct PersistedDownload { pub completed: bool, } +/// One line of private-message history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +pub struct PersistedMessage { + pub peer: String, + /// True when we sent it, false when the peer did. + pub outgoing: bool, + pub text: String, + pub at: chrono::DateTime, +} + pub struct StateStore { dir: PathBuf, } @@ -72,6 +83,31 @@ impl StateStore { &rooms, ) } + + pub fn load_messages(&self) -> Vec { + load(&self.dir.join("messages.json"), MESSAGES_MIGRATIONS) + } + + pub fn save_messages(&self, messages: &[PersistedMessage]) -> Result<()> { + save( + &self.dir.join("messages.json"), + MESSAGES_MIGRATIONS.len() as u32, + &messages, + ) + } + + /// The unread private-message count, so the badge survives a restart. + pub fn load_unread(&self) -> usize { + load(&self.dir.join("unread.json"), UNREAD_MIGRATIONS) + } + + pub fn save_unread(&self, unread: usize) -> Result<()> { + save( + &self.dir.join("unread.json"), + UNREAD_MIGRATIONS.len() as u32, + &unread, + ) + } } /// Per-file migration chains. `data` at version `i` is upgraded by @@ -80,6 +116,8 @@ impl StateStore { const DOWNLOADS_MIGRATIONS: &[Migration] = &[]; const SEARCHES_MIGRATIONS: &[Migration] = &[]; const ROOMS_MIGRATIONS: &[Migration] = &[]; +const MESSAGES_MIGRATIONS: &[Migration] = &[]; +const UNREAD_MIGRATIONS: &[Migration] = &[]; /// Load `data` from an envelope file, migrating old versions forward. /// Missing, corrupt, or newer-than-known files all yield `T::default()`. @@ -178,6 +216,28 @@ mod tests { assert_eq!(store.load_downloads(), downloads); } + #[test] + fn messages_round_trip_including_the_timestamp() { + let (_tmp, store) = store(); + let messages = vec![PersistedMessage { + peer: "alice".into(), + outgoing: true, + text: "hey".into(), + at: chrono::Local::now(), + }]; + store.save_messages(&messages).unwrap(); + assert_eq!(store.load_messages(), messages); + } + + #[test] + fn unread_count_round_trips_and_defaults_to_zero() { + let (_tmp, store) = store(); + // Missing file → no unread, so a fresh install shows no badge. + assert_eq!(store.load_unread(), 0); + store.save_unread(4).unwrap(); + assert_eq!(store.load_unread(), 4); + } + #[test] fn searches_and_rooms_round_trip() { let (_tmp, store) = store(); diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index ac8cf18..cdfbd77 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -300,6 +300,32 @@ impl MultiDownloadProgress { } } +/// Bytes transferred, bytes expected and combined speed across the downloads +/// that are actually running — or `None` when none are, so the caller can hide +/// the progress readout entirely. +/// +/// Only `InProgress` downloads count. A queued download has not started and a +/// failed one never will, so adding their size to the total just pins the bar +/// near zero for the rest of the session. +fn active_progress(downloads: &[DownloadEntry]) -> Option<(u64, u64, f64)> { + let mut transferred = 0; + let mut expected = 0; + let mut speed = 0.0; + let mut running = false; + + for entry in downloads { + if !matches!(entry.download.status, DownloadStatus::InProgress { .. }) { + continue; + } + running = true; + transferred += entry.download.bytes_downloaded(); + expected += entry.download.size; + speed += entry.download.speed_bytes_per_sec(); + } + + running.then_some((transferred, expected, speed)) +} + /// Renders download statistics in a reusable way pub fn render_download_stats( frame: &mut Frame, @@ -329,29 +355,7 @@ pub fn render_download_stats( .filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. })) .count(); - let total_downloaded: u64 = downloads - .iter() - .map(|d| d.download.bytes_downloaded()) - .sum(); - let total_size: u64 = downloads.iter().map(|d| d.download.size).sum(); - let overall_progress = if total_size > 0 { - (total_downloaded as f64 / total_size as f64 * 100.0) as u8 - } else { - 0 - }; - let progress_ratio = if total_size > 0 { - total_downloaded as f64 / total_size as f64 - } else { - 0.0 - }; - let total_speed: f64 = downloads - .iter() - .filter(|d| { - matches!(d.download.status, DownloadStatus::InProgress { .. }) - }) - .map(|d| d.download.speed_bytes_per_sec()) - .sum(); - let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0; + let active = active_progress(downloads); let block = pane_block(false).title(plain_title("Status", false)); @@ -387,6 +391,23 @@ pub fn render_download_stats( let stats_paragraph = Paragraph::new(stats_line); frame.render_widget(stats_paragraph, chunks[0]); + // Nothing is transferring, so there is no progress to draw: leave the + // right-hand half blank instead of showing an idle 0 B / 0 B bar. + let Some((total_downloaded, total_size, total_speed)) = active else { + return; + }; + let overall_progress = if total_size > 0 { + (total_downloaded as f64 / total_size as f64 * 100.0) as u8 + } else { + 0 + }; + let progress_ratio = if total_size > 0 { + total_downloaded as f64 / total_size as f64 + } else { + 0.0 + }; + let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0; + let right_width = chunks[1].width as usize; let bar_width = right_width.saturating_sub(42).max(10); let progress_bar = @@ -453,3 +474,73 @@ pub fn show_multi_download_progress( result } + +#[cfg(test)] +mod tests { + use super::{DownloadEntry, DownloadStatus, active_progress}; + use soulseek_rs::types::{Download, DownloadMetadata}; + + fn entry(size: u64, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = std::sync::mpsc::channel(); + DownloadEntry { + download: Download { + username: "peer".to_string(), + filename: "song.flac".to_string(), + token: 1, + size, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + #[test] + fn only_running_downloads_count_towards_the_bar() { + let downloads = vec![ + entry( + 100, + DownloadStatus::InProgress { + bytes_downloaded: 40, + total_bytes: 100, + speed_bytes_per_sec: 512.0, + }, + ), + // None of these may reach the bar: a queued file has not started, + // and a failed or timed-out one never will. Counting their size + // would drag the bar down for the rest of the session. + entry(9_000, DownloadStatus::Queued), + entry(9_000, DownloadStatus::Failed(None)), + entry(9_000, DownloadStatus::TimedOut), + ]; + + let (transferred, expected, speed) = + active_progress(&downloads).expect("one download is running"); + assert_eq!(transferred, 40); + assert_eq!(expected, 100, "queued and failed sizes must be excluded"); + assert!((speed - 512.0).abs() < f64::EPSILON); + } + + #[test] + fn nothing_running_hides_the_bar() { + assert!(active_progress(&[]).is_none(), "no downloads at all"); + + // Completed and paused transfers are not processing either, so the + // readout stays hidden rather than showing a stale bar. + let idle = vec![ + entry(100, DownloadStatus::Completed), + entry(100, DownloadStatus::Queued), + entry( + 100, + DownloadStatus::Paused { + bytes_downloaded: 50, + total_bytes: 100, + }, + ), + ]; + assert!(active_progress(&idle).is_none()); + } +} diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 8257ac8..3ac6b13 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -11,12 +11,9 @@ impl MainTui { return self.handle_command_bar_input(key); } - // Messages popup: any of i/Esc/q closes it. + // Chat popup takes over navigation while open. if self.state.show_messages { - if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) { - self.state.show_messages = false; - } - return; + return self.handle_chat_input(key); } // Browse popup takes over navigation while open. @@ -116,6 +113,57 @@ impl MainTui { } } + /// Chat popup: composing captures typing, otherwise these are navigation + /// keys over the conversation list. + fn handle_chat_input(&mut self, key: KeyEvent) { + if self.state.chat_composing { + match key.code { + KeyCode::Enter => self.send_chat_message(), + KeyCode::Esc => { + self.state.chat_composing = false; + self.state.chat_input.clear(); + } + KeyCode::Backspace => { + self.state.chat_input.pop(); + } + KeyCode::Char(c) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT, + ) => + { + self.state.chat_input.push(c); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('i' | 'q') | KeyCode::Esc => { + self.state.show_messages = false; + } + // Move through the conversation list on the right. + KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => { + self.state.cycle_chat_peer(true); + } + KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => { + self.state.cycle_chat_peer(false); + } + // Start a conversation with someone not in the list yet. + KeyCode::Char('m') => { + self.state.show_messages = false; + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Message; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Enter if self.state.active_chat_peer().is_some() => { + self.state.chat_composing = true; + } + _ => {} + } + } + fn handle_command_bar_input(&mut self, key: KeyEvent) { self.state.command_bar_cursor_position = self .state diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index c7d542f..864e116 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -8,7 +8,7 @@ mod settings; use crate::models::AppState; use crate::persist::{ - snapshot::{Snapshot, restore_searches}, + snapshot::{Snapshot, restore_messages, restore_searches}, state::StateStore, }; use color_eyre::Result; @@ -61,6 +61,10 @@ impl MainTui { let Some(store) = &self.store else { return }; restore_searches(&mut self.state, &store.load_search_queries()); + restore_messages(&mut self.state, &store.load_messages()); + // Restore the unread badge so messages left unread carry over. Set + // before the baseline snapshot below so it isn't seen as a change. + self.state.unread_messages = store.load_unread(); for room in store.load_rooms() { if self.state.rooms.focus_or_open(&room) @@ -139,6 +143,16 @@ impl MainTui { { soulseek_rs::warn!("Could not save room state: {e}"); } + if snapshot.messages != self.saved_snapshot.messages + && let Err(e) = store.save_messages(&snapshot.messages) + { + soulseek_rs::warn!("Could not save message history: {e}"); + } + if snapshot.unread_messages != self.saved_snapshot.unread_messages + && let Err(e) = store.save_unread(snapshot.unread_messages) + { + soulseek_rs::warn!("Could not save unread count: {e}"); + } self.saved_snapshot = snapshot; } diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 38fef37..9752c7f 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -1,17 +1,18 @@ use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, MessageDirection, RoomsView}; +use crate::models::{CommandBarMode, FocusedPane, RoomsView}; use crate::ui::panes::{ - ResultsPaneParams, render_browse_pane, render_download_info_pane, - render_downloads_pane, render_results_pane, render_rooms_pane, - render_searches_pane, + ResultsPaneParams, render_browse_pane, render_chat_pane, + render_download_info_pane, render_downloads_pane, render_results_pane, + render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ - accent_style, format_shortcuts_styled, pane_block, plain_title, - primary_style, render_download_stats, + accent_style, dimmed_style, format_shortcuts_styled, pane_block, + plain_title, primary_style, render_download_stats, warning_style, }; use ratatui::{ Frame, - layout::{Constraint, Layout, Position, Rect}, + layout::{Alignment, Constraint, Layout, Position, Rect}, + style::Modifier, text::{Line, Span}, widgets::Paragraph, }; @@ -141,15 +142,15 @@ impl MainTui { self.state.focused_pane == FocusedPane::Downloads, ); - let selected_download = self - .state - .downloads_table_state - .selected() - .and_then(|idx| self.state.downloads.get(idx)); + let selected_transfer = selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ); render_download_info_pane( frame, downloads_chunks[1], - selected_download, + selected_transfer, self.state.focused_pane == FocusedPane::Downloads, ); } @@ -251,38 +252,11 @@ impl MainTui { } fn render_messages_popup(&self, frame: &mut Frame) { + // The per-conversation chat box supersedes the old flat message list; + // it renders the messages and the compose line itself. let area = centered_rect(70, 60, frame.area()); - - let lines: Vec = if self.state.messages.is_empty() - { - vec![ratatui::text::Line::from( - "No messages yet. Press 'm' to send one.", - )] - } else { - self.state - .messages - .iter() - .map(|m| { - let (arrow, who) = match m.direction { - MessageDirection::Incoming => ("⇦ from", &m.peer), - MessageDirection::Outgoing => ("⇨ to ", &m.peer), - }; - ratatui::text::Line::from(format!( - "{arrow} {who}: {}", - m.text - )) - }) - .collect() - }; - - let popup = - Paragraph::new(lines).block(pane_block(true).title(plain_title( - "Messages · m: compose · i/Esc: close", - true, - ))); - frame.render_widget(ratatui::widgets::Clear, area); - frame.render_widget(popup, area); + render_chat_pane(frame, area, &self.state, self.client.username()); } /// Context shortcuts for the chat-rooms popup. @@ -325,19 +299,6 @@ impl MainTui { } fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { - // Unread badges for the inbox and chat shortcuts. - let inbox_label = if self.state.unread_messages > 0 { - format!("inbox ({})", self.state.unread_messages) - } else { - "inbox".to_string() - }; - let chat_unread = self.state.rooms.total_unread(); - let chat_label = if chat_unread > 0 { - format!("chat ({chat_unread})") - } else { - "chat".to_string() - }; - let shortcuts = if self.state.settings.is_some() { vec![ ("↑↓", "move"), @@ -347,6 +308,21 @@ impl MainTui { ("r", "re-index"), ("Esc", "close"), ] + } else if self.state.show_messages { + if self.state.chat_composing { + vec![ + ("Type", "message"), + ("Enter", "send"), + ("Esc", "stop typing"), + ] + } else { + vec![ + ("Enter", "type"), + ("↑↓/Tab", "switch chat"), + ("m", "new chat"), + ("i/Esc", "close"), + ] + } } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { @@ -385,8 +361,8 @@ impl MainTui { FocusedPane::Searches => vec![ ("s", "search"), ("m", "message"), - ("i", inbox_label.as_str()), - ("c", chat_label.as_str()), + ("i", "inbox"), + ("c", "chat"), ("b", "browse user"), ("1-3", "focus pane"), ("↑↓", "navigate"), @@ -405,7 +381,7 @@ impl MainTui { ("Space", "select"), ("Enter", "download"), ("b", "browse owner"), - ("c", chat_label.as_str()), + ("c", "chat"), ("/", "filter"), ("a/A", "select all/none"), ("1-3", "focus pane"), @@ -434,10 +410,37 @@ impl MainTui { more => format!("{} folders", more.len()), }; let title = format!("Shortcuts · Sharing: {sharing}"); - let shortcuts_widget = Paragraph::new(shortcuts_line) - .block(pane_block(false).title(plain_title(title, false))); + // Themed block (master's design system), but we take its inner area so + // the unread badge can be split off to the right below. + let block = pane_block(false).title(plain_title(title, false)); + let inner = block.inner(area); + frame.render_widget(block, area); - frame.render_widget(shortcuts_widget, area); + // A prominent unread badge, right-aligned so the context shortcuts on + // the left never crowd it out. It rides in the always-visible shortcuts + // bar, so unread mail/chat shows in every pane, not just some. The + // spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the + // first half of the cycle, dim for the second. + let unread = unread_indicator( + self.state.unread_messages, + self.state.rooms.total_unread(), + self.spinner_state < 5, + ); + if unread.spans.is_empty() { + frame.render_widget(Paragraph::new(shortcuts_line), inner); + } else { + let badge_width = unread.width() as u16; + let cols = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(badge_width), + ]) + .split(inner); + frame.render_widget(Paragraph::new(shortcuts_line), cols[0]); + frame.render_widget( + Paragraph::new(unread).alignment(Alignment::Right), + cols[1], + ); + } } fn render_command_bar(&self, frame: &mut Frame, area: Rect) { @@ -487,6 +490,38 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { .split(vertical[1])[1] } +/// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and +/// `💬 N` for unread chat-room messages. Returns an empty line when nothing is +/// unread so the caller can skip it. +/// +/// `bright` drives a self-drawn blink: the caller flips it on a timer so the +/// badge pulses between bold warning colour and dim. We do the blink ourselves +/// rather than lean on the terminal's SLOW_BLINK attribute, which tmux and most +/// macOS terminals silently drop. The text is identical in both phases, so the +/// badge keeps its width and the bar never reflows. +fn unread_indicator( + messages: usize, + rooms: usize, + bright: bool, +) -> Line<'static> { + let mut counts: Vec = Vec::new(); + if messages > 0 { + counts.push(format!("✉ {messages}")); + } + if rooms > 0 { + counts.push(format!("💬 {rooms}")); + } + if counts.is_empty() { + return Line::from(Vec::new()); + } + let style = if bright { + warning_style().add_modifier(Modifier::BOLD) + } else { + dimmed_style() + }; + Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style)) +} + fn visible_input_at_cursor( input: &str, cursor_position: usize, @@ -513,3 +548,46 @@ fn visible_input_at_cursor( (visible_input, cursor_column as u16) } + +#[cfg(test)] +mod tests { + use super::unread_indicator; + + fn text(messages: usize, rooms: usize) -> String { + unread_indicator(messages, rooms, true) + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect() + } + + #[test] + fn unread_indicator_is_empty_when_nothing_is_unread() { + // Empty in both blink phases — no badge means no badge. + assert!(unread_indicator(0, 0, true).spans.is_empty()); + assert!(unread_indicator(0, 0, false).spans.is_empty()); + } + + #[test] + fn unread_indicator_shows_each_source_and_both_together() { + // Bracketed so the badge reads as one distinct block. + assert_eq!(text(3, 0), "⟨ ✉ 3 ⟩"); + assert_eq!(text(0, 2), "⟨ 💬 2 ⟩"); + // Both present: DMs first, rooms second, separated so they don't run + // together. + assert_eq!(text(3, 2), "⟨ ✉ 3 💬 2 ⟩"); + } + + #[test] + fn blink_phases_share_text_but_differ_in_style() { + // The self-drawn blink must keep the same glyphs (so the bar never + // reflows) while changing style, or it would not read as blinking. + let bright = unread_indicator(1, 0, true); + let dim = unread_indicator(1, 0, false); + assert_eq!(bright.spans[0].content, dim.spans[0].content); + assert_ne!( + bright.spans[0].style, dim.spans[0].style, + "bright and dim phases must look different" + ); + } +} diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index 8b08e0f..bd048aa 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -1,5 +1,5 @@ use super::MainTui; -use crate::models::{CommandBarMode, RoomsView}; +use crate::models::RoomsView; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; impl MainTui { @@ -126,16 +126,11 @@ impl MainTui { } /// Compose a private message to the member highlighted in the active room. - /// Closes the rooms popup and opens the message command bar pre-filled with - /// the recipient. + /// Closes the rooms popup and opens the chat box on that conversation. fn message_selected_room_user(&mut self) { if let Some(user) = self.state.rooms.selected_user() { self.state.show_rooms = false; - self.state.command_bar_active = true; - self.state.command_bar_mode = CommandBarMode::Message; - self.state.command_bar_input = format!("{user} "); - self.state.command_bar_cursor_position = - self.state.command_bar_input.len(); + self.state.open_chat(user); } } diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs index db919ec..9d9bee7 100644 --- a/soulseek-rs/src/ui/main_tui/search.rs +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -3,6 +3,7 @@ use crate::models::{ ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, SearchStatus, }; +use chrono::Local; use std::{ sync::{Arc, atomic::AtomicBool}, thread, @@ -105,6 +106,7 @@ impl MainTui { direction: MessageDirection::Incoming, peer: msg.username().to_string(), text: msg.message().to_string(), + at: Local::now(), }); // Badge the inbox when it isn't currently open. if !self.state.show_messages { @@ -126,13 +128,43 @@ impl MainTui { } match self.client.send_private_message(recipient, text) { + Ok(()) => { + self.state.messages.push(ChatMessage { + direction: MessageDirection::Outgoing, + peer: recipient.to_string(), + text: text.to_string(), + at: Local::now(), + }); + // Land in the chat box on the conversation just started. + self.state.open_chat(recipient.to_string()); + self.state.chat_composing = false; + } + Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), + } + } + + /// Send the chat popup's compose buffer to the active conversation, + /// staying in compose mode so a reply can follow immediately. + pub(super) fn send_chat_message(&mut self) { + let Some(peer) = self.state.active_chat_peer().map(ToString::to_string) + else { + return; + }; + let text = self.state.chat_input.trim().to_string(); + if text.is_empty() { + return; + } + + match self.client.send_private_message(&peer, &text) { Ok(()) => self.state.messages.push(ChatMessage { direction: MessageDirection::Outgoing, - peer: recipient.to_string(), - text: text.to_string(), + peer, + text, + at: Local::now(), }), Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), } + self.state.chat_input.clear(); } pub(super) fn start_search(&mut self, query: String) { diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index fc9d5e2..e50152c 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -57,6 +57,9 @@ impl MainTui { let result = crate::persist::config::FileConfig::load(&path) .and_then(|mut config| { config.download_dir = Some(download_dir); + // The singular `shared_dir` is merged with `shared_dirs` on + // load, so leaving it would resurrect a removed folder. + config.shared_dir = None; config.shared_dirs = Some(share_dirs); config.save(&path) }); diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs new file mode 100644 index 0000000..9f9b2b1 --- /dev/null +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -0,0 +1,135 @@ +use crate::models::{AppState, MessageDirection}; +use crate::ui::{ + accent_style, border_style, border_type, dimmed_style, highlight_style, + info_style, primary_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +/// Render the private-message chat box: title is the conversation partner, +/// messages left, the list of people you have chatted with on the right, and +/// the compose line pinned to the bottom. `own_username` labels our own +/// messages. +pub fn render_chat_pane( + frame: &mut Frame, + area: Rect, + state: &AppState, + own_username: &str, +) { + let peer = state.active_chat_peer(); + let title = peer.map_or_else( + || " Messages (m: compose, i/Esc: close) ".to_string(), + |peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "), + ); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)) + .title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]) + .split(inner); + + // Body: messages (left) + conversation list (right). + let body = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) + .split(chunks[0]); + render_peers(frame, body[1], state, peer); + + let Some(peer) = peer else { + frame.render_widget( + Paragraph::new("No messages yet. Press 'm' to send one.") + .style(dimmed_style()), + body[0], + ); + return; + }; + + render_messages(frame, body[0], state, peer, own_username); + render_compose(frame, chunks[1], state); +} + +fn render_peers( + frame: &mut Frame, + area: Rect, + state: &AppState, + active: Option<&str>, +) { + let peers = state.chat_peers(); + let block = Block::default() + .borders(Borders::LEFT) + .border_style(dimmed_style()) + .title(format!(" Chats ({}) ", peers.len())); + let inner = block.inner(area); + frame.render_widget(block, area); + + let height = inner.height as usize; + let selected = active + .and_then(|active| peers.iter().position(|p| *p == active)) + .unwrap_or(0); + // Keep the active conversation in view by scrolling the window to it. + let start = selected.saturating_sub(height.saturating_sub(1)); + let shown: Vec = peers + .iter() + .enumerate() + .skip(start) + .take(height) + .map(|(i, peer)| { + if i == selected { + Line::from(Span::styled(format!("▸ {peer}"), highlight_style())) + } else { + Line::from(Span::styled(format!(" {peer}"), primary_style())) + } + }) + .collect(); + frame.render_widget(Paragraph::new(shown), inner); +} + +fn render_messages( + frame: &mut Frame, + area: Rect, + state: &AppState, + peer: &str, + own_username: &str, +) { + let lines: Vec = state + .messages + .iter() + .filter(|m| m.peer == peer) + .map(|m| { + let (sender, sender_style) = match m.direction { + MessageDirection::Incoming => (peer, info_style()), + MessageDirection::Outgoing => (own_username, accent_style()), + }; + Line::from(vec![ + Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), + Span::styled(format!("<{sender}> "), sender_style), + Span::styled(m.text.clone(), primary_style()), + ]) + }) + .collect(); + + // Auto-scroll: one row per message (no wrapping), so tailing by the pane + // height keeps the newest message visible. + let start = lines.len().saturating_sub((area.height as usize).max(1)); + frame.render_widget(Paragraph::new(lines[start..].to_vec()), area); +} + +fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) { + let line = if state.chat_composing { + Line::from(vec![ + Span::styled("› ", accent_style()), + Span::styled(state.chat_input.clone(), primary_style()), + Span::styled("▏", accent_style()), + ]) + } else { + Line::from(Span::styled("Enter: type a message", dimmed_style())) + }; + frame.render_widget(Paragraph::new(line), area); +} diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs index 9968190..1255585 100644 --- a/soulseek-rs/src/ui/panes/download_info_pane.rs +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -12,21 +12,47 @@ use ratatui::{ widgets::{Paragraph, Wrap}, }; use soulseek_rs::DownloadStatus; +use soulseek_rs::types::{UploadInfo, UploadStatus}; use soulseek_rs::utils::path::expand_tilde; const LABEL_WIDTH: usize = 20; +/// Whichever transfer the table has highlighted. Rows are downloads first and +/// uploads after, so a raw index has to be resolved against both lists. +pub enum SelectedTransfer<'a> { + Download(&'a DownloadEntry), + Upload(&'a UploadInfo), +} + +/// Resolve the transfers table's selected row index into the download or +/// upload it points at, matching the order [`render_downloads_pane`] renders. +#[must_use] +pub fn selected_transfer<'a>( + index: Option, + downloads: &'a [DownloadEntry], + uploads: &'a [UploadInfo], +) -> Option> { + let index = index?; + if let Some(download) = downloads.get(index) { + return Some(SelectedTransfer::Download(download)); + } + // Past the downloads, so this row is an upload. + uploads + .get(index - downloads.len()) + .map(SelectedTransfer::Upload) +} + pub fn render_download_info_pane( frame: &mut Frame, area: Rect, - selected: Option<&DownloadEntry>, + selected: Option, focused: bool, ) { let block = pane_block(focused).title(pane_title("Info", "", focused)); - let Some(entry) = selected else { + let Some(selected) = selected else { let paragraph = Paragraph::new(Line::from(Span::styled( - "Select a download for details.", + "Select a transfer for details.", dimmed_style(), ))) .block(block); @@ -34,7 +60,10 @@ pub fn render_download_info_pane( return; }; - let lines = build_info_lines(&entry.download); + let lines = match selected { + SelectedTransfer::Download(entry) => build_info_lines(&entry.download), + SelectedTransfer::Upload(upload) => build_upload_info_lines(upload), + }; let paragraph = Paragraph::new(lines) .block(block) @@ -42,6 +71,73 @@ pub fn render_download_info_pane( frame.render_widget(paragraph, area); } +/// Details for a file we are serving to a peer, laid out like the download +/// view so the pane reads the same whichever direction is selected. +fn build_upload_info_lines(upload: &UploadInfo) -> Vec> { + let mut lines: Vec> = Vec::new(); + + let (basename, parent_dir) = split_filename(&upload.filename); + lines.push(Line::from(Span::styled( + basename, + primary_style().add_modifier(ratatui::style::Modifier::BOLD), + ))); + if !parent_dir.is_empty() { + lines.push(Line::from(Span::styled(parent_dir, dimmed_style()))); + } + lines.push(Line::from("")); + + // The pane is shared with downloads, so name the direction explicitly. + lines.push(label_value("Direction", "↑ Upload")); + lines.push(label_value("To user", &upload.username)); + lines.push(label_value("Size", &format_bytes(upload.size))); + + let (status_text, status_style) = match &upload.status { + UploadStatus::InProgress => { + ("In progress".to_string(), warning_style()) + } + UploadStatus::Completed => ("Completed".to_string(), success_style()), + UploadStatus::Cancelled => ("Cancelled".to_string(), inactive_style()), + UploadStatus::Failed(_) => ("Failed".to_string(), error_style()), + }; + lines.push(label_value_styled("Status", status_text, status_style)); + + match &upload.status { + UploadStatus::InProgress => { + lines.push(Line::from("")); + push_transfer_progress( + &mut lines, + "Sent", + upload.bytes_sent, + upload.size, + ); + lines.push(label_value( + "Speed", + &format_speed(upload.speed_bytes_per_sec), + )); + + if upload.speed_bytes_per_sec > 0.0 + && upload.size > upload.bytes_sent + { + let remaining = upload.size - upload.bytes_sent; + let eta_secs = + (remaining as f64 / upload.speed_bytes_per_sec) as u32; + lines.push(label_value("ETA", &format_duration(eta_secs))); + } + } + UploadStatus::Failed(reason) => { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{: {} + } + + lines +} + fn build_info_lines( download: &soulseek_rs::types::Download, ) -> Vec> { @@ -184,19 +280,30 @@ fn push_progress_lines( lines: &mut Vec>, bytes_downloaded: u64, total_bytes: u64, +) { + push_transfer_progress(lines, "Downloaded", bytes_downloaded, total_bytes); +} + +/// A progress bar plus a `