From 2918b0ce75786ce7983dbf2b617eb6756eb2748e Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 20 Jul 2026 10:53:57 +0200 Subject: [PATCH] refactor: resolve all lint findings - remove 34 trivial restating comments (auto-fix) - replace all 20 production .unwrap() calls with graceful handling: ?-propagation via LockPoisoned, unwrap_or_default on infallible conversions, let _ = on actor-channel sends, and a downloads_sender() helper in the TUI that also dedupes three channel-init blocks - collapse nested if into match guard (clippy::collapsible_match) - split client.rs (2275 lines) into a client/ module: connection, search, downloads, uploads, rooms, operations - split ui/main_tui.rs (2027 lines) into a main_tui/ module: render, input, browse, rooms, downloads, search - extract oversized functions: peer_actor/server_actor handle_message into per-message helpers, main() into init_logging/run_default_tui, download_selector and main_tui render into per-section helpers Mechanical only, zero behavior change. Verified: clippy clean (pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against soulfind pass, lint clean. --- Cargo.lock | 4 +- soulseek-rs-lib/src/actor/mod.rs | 2 - soulseek-rs-lib/src/actor/peer_actor.rs | 343 +-- soulseek-rs-lib/src/actor/server_actor.rs | 255 ++- soulseek-rs-lib/src/client/connection.rs | 270 +++ soulseek-rs-lib/src/client/downloads.rs | 191 ++ soulseek-rs-lib/src/client/mod.rs | 660 ++++++ .../src/{client.rs => client/operations.rs} | 1428 +----------- soulseek-rs-lib/src/client/rooms.rs | 174 ++ soulseek-rs-lib/src/client/search.rs | 105 + soulseek-rs-lib/src/client/uploads.rs | 70 + soulseek-rs-lib/src/message/mod.rs | 5 +- .../src/message/peer/file_search_response.rs | 4 +- soulseek-rs-lib/src/message/peer/peer_init.rs | 2 +- .../message/peer/place_in_queue_response.rs | 5 +- .../src/message/peer/transfer_request.rs | 2 +- .../src/message/peer/transfer_response.rs | 12 +- .../src/message/server/get_peer_address.rs | 16 +- soulseek-rs-lib/src/message/server/login.rs | 5 +- soulseek-rs-lib/src/peer/download_peer.rs | 9 +- soulseek-rs-lib/src/utils/logger.rs | 4 +- soulseek-rs-lib/src/utils/md5.rs | 4 - soulseek-rs-lib/src/utils/zlib.rs | 15 +- soulseek-rs/src/main.rs | 205 +- soulseek-rs/src/models/browse.rs | 1 - soulseek-rs/src/ui/download_selector.rs | 220 +- soulseek-rs/src/ui/downloads.rs | 5 - soulseek-rs/src/ui/main_tui.rs | 2026 ----------------- soulseek-rs/src/ui/main_tui/browse.rs | 233 ++ soulseek-rs/src/ui/main_tui/downloads.rs | 218 ++ soulseek-rs/src/ui/main_tui/input.rs | 476 ++++ soulseek-rs/src/ui/main_tui/mod.rs | 122 + soulseek-rs/src/ui/main_tui/render.rs | 452 ++++ soulseek-rs/src/ui/main_tui/rooms.rs | 229 ++ soulseek-rs/src/ui/main_tui/search.rs | 324 +++ 35 files changed, 4113 insertions(+), 3983 deletions(-) create mode 100644 soulseek-rs-lib/src/client/connection.rs create mode 100644 soulseek-rs-lib/src/client/downloads.rs create mode 100644 soulseek-rs-lib/src/client/mod.rs rename soulseek-rs-lib/src/{client.rs => client/operations.rs} (50%) create mode 100644 soulseek-rs-lib/src/client/rooms.rs create mode 100644 soulseek-rs-lib/src/client/search.rs create mode 100644 soulseek-rs-lib/src/client/uploads.rs delete mode 100644 soulseek-rs/src/ui/main_tui.rs create mode 100644 soulseek-rs/src/ui/main_tui/browse.rs create mode 100644 soulseek-rs/src/ui/main_tui/downloads.rs create mode 100644 soulseek-rs/src/ui/main_tui/input.rs create mode 100644 soulseek-rs/src/ui/main_tui/mod.rs create mode 100644 soulseek-rs/src/ui/main_tui/render.rs create mode 100644 soulseek-rs/src/ui/main_tui/rooms.rs create mode 100644 soulseek-rs/src/ui/main_tui/search.rs diff --git a/Cargo.lock b/Cargo.lock index 9323249..eeaee93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1666,7 +1666,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "soulseek-rs" -version = "0.3.0" +version = "5.0.0" dependencies = [ "clap", "color-eyre", @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "soulseek-rs-lib" -version = "0.3.0" +version = "5.0.0" [[package]] name = "stable_deref_trait" diff --git a/soulseek-rs-lib/src/actor/mod.rs b/soulseek-rs-lib/src/actor/mod.rs index b64f690..f409a22 100644 --- a/soulseek-rs-lib/src/actor/mod.rs +++ b/soulseek-rs-lib/src/actor/mod.rs @@ -74,7 +74,6 @@ impl ActorSystem { let (sender, receiver) = channel::>(); let handle = ActorHandle { sender }; - // Start the actor event loop on the thread pool self.thread_pool.execute(move || { actor.on_start(); Self::run_actor_loop(&mut actor, receiver); @@ -198,7 +197,6 @@ mod tests { assert_eq!(count.load(Ordering::SeqCst), 6); - // Stop the actor handle.stop().unwrap(); // Give actor time to process the stop message diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index 79f5a8b..f0d86bd 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -199,130 +199,23 @@ impl PeerActor { self.send_message(message); } PeerMessage::FileSearchResult(file_search) => { - if let Err(e) = self - .client_channel - .send(ClientOperation::SearchResult(file_search)) - { - error!( - "[peer:{}] failed to forward search result: {}", - self.peer_username(), - e - ); - } + self.handle_file_search_result(file_search); } PeerMessage::TransferRequest(transfer) => { - let username = self.peer_username(); - debug!( - "[peer:{}] TransferRequest for {}", - username, transfer.token - ); - - if let Err(e) = self.client_channel.send( - ClientOperation::UpdateDownloadTokens( - transfer.clone(), - username.clone(), - ), - ) { - error!( - "[peer:{}] failed to send UpdateDownloadTokens: {}", - username, e - ); - } - - let transfer_response = - MessageFactory::build_transfer_response_message(transfer); - - if let Some(ref handle) = self.self_handle - && let Err(e) = - handle.send(PeerMessage::SendMessage(transfer_response)) - { - error!( - "[peer:{}] Failed to send TransferResponse message: {}", - username, e - ); - } + self.handle_transfer_request(transfer); } PeerMessage::TransferResponse { token, allowed, reason, } => { - let username = self.peer_username(); - debug!( - "[peer:{}] transfer response token: {} allowed: {}", - username, token, allowed - ); - - // If this token is one of our uploads, the peer just accepted - // our offer — start streaming. This leaves the download path - // (every other token) byte-for-byte unchanged. - if self.serving_tokens.remove(&token) { - if allowed { - let _ = self - .client_channel - .send(ClientOperation::StartUpload { token }); - } - return; - } - - if allowed { - debug!( - "[peer:{}] Transfer allowed, ready to connect with token {:}", - username, token - ); - let Some(peer_snapshot) = self.peer_snapshot() else { - return; - }; - if let Err(e) = self.client_channel.send( - ClientOperation::DownloadFromPeer( - token, - peer_snapshot, - allowed, - ), - ) { - error!( - "[peer:{}] failed to send DownloadFromPeer: {}", - username, e - ); - } - } else if let Some(reason_text) = reason { - debug!( - "[peer:{}] Transfer rejected: {} - token {}, waiting for TransferRequest...", - username, reason_text, token - ); - } + self.handle_transfer_response(token, allowed, reason); } PeerMessage::PlaceInQueueResponse { filename, place } => { - let username = self.peer_username(); - debug!( - "[peer:{}] Place in queue response - file: {}, place: {}", - username, filename, place - ); - if let Err(e) = self.client_channel.send( - ClientOperation::PlaceInQueueUpdate { - username: username.clone(), - filename, - place, - }, - ) { - error!( - "[peer:{}] failed to forward PlaceInQueueUpdate: {}", - username, e - ); - } + self.handle_place_in_queue_response(filename, place); } PeerMessage::SetUsername(username) => { - trace!( - "[peer:{}] SetUsername: {}", - self.peer_username(), - username - ); - match self.peer.write_safe() { - Ok(mut p) => p.username = username, - Err(e) => { - error!("[peer_actor] SetUsername write: {}", e); - } - } + self.handle_set_username(username); } PeerMessage::QueueUpload(filename) => { let message = @@ -330,48 +223,20 @@ impl PeerActor { self.send_message(message); } PeerMessage::IncomingQueueUpload(filename) => { - // A peer wants to download one of our shared files. Ask the - // client (which owns the shares) to prepare the upload. - let requester_key = self.peer_username(); - if let Err(e) = - self.client_channel.send(ClientOperation::QueueUpload { - requester_key, - filename, - }) - { - error!("[peer_actor] forward IncomingQueueUpload: {}", e); - } + self.handle_incoming_queue_upload(filename); } PeerMessage::ServeUpload { token, filename, size, } => { - self.serving_tokens.insert(token); - let message = MessageFactory::build_upload_transfer_request( - &filename, token, size, - ); - self.send_message(message); + self.handle_serve_upload(token, filename, size); } PeerMessage::ShareListRequested => { - let requester_key = self.peer_username(); - if let Err(e) = self - .client_channel - .send(ClientOperation::ShareListRequested { requester_key }) - { - error!("[peer_actor] forward ShareListRequested: {}", e); - } + self.handle_share_list_requested(); } PeerMessage::ShareListReceived(directories) => { - let username = self.peer_username(); - if let Err(e) = - self.client_channel.send(ClientOperation::BrowseResult { - username, - directories, - }) - { - error!("[peer_actor] forward BrowseResult: {}", e); - } + self.handle_share_list_received(directories); } PeerMessage::RequestTransfer(download) => { let message = MessageFactory::build_transfer_request_message( @@ -384,19 +249,189 @@ impl PeerActor { self.process_read(); } PeerMessage::UploadFailed(username, filename) => { - if let Err(e) = self - .client_channel - .send(ClientOperation::UploadFailed(username, filename)) - { - error!( - "[peer_actor] failed to forward UploadFailed: {}", - e - ); - } + self.handle_upload_failed(username, filename); } } } + fn handle_file_search_result(&self, file_search: SearchResult) { + if let Err(e) = self + .client_channel + .send(ClientOperation::SearchResult(file_search)) + { + error!( + "[peer:{}] failed to forward search result: {}", + self.peer_username(), + e + ); + } + } + + fn handle_transfer_request(&self, transfer: Transfer) { + let username = self.peer_username(); + debug!("[peer:{}] TransferRequest for {}", username, transfer.token); + + if let Err(e) = + self.client_channel + .send(ClientOperation::UpdateDownloadTokens( + transfer.clone(), + username.clone(), + )) + { + error!( + "[peer:{}] failed to send UpdateDownloadTokens: {}", + username, e + ); + } + + let transfer_response = + MessageFactory::build_transfer_response_message(transfer); + + if let Some(ref handle) = self.self_handle + && let Err(e) = + handle.send(PeerMessage::SendMessage(transfer_response)) + { + error!( + "[peer:{}] Failed to send TransferResponse message: {}", + username, e + ); + } + } + + fn handle_transfer_response( + &mut self, + token: u32, + allowed: bool, + reason: Option, + ) { + let username = self.peer_username(); + debug!( + "[peer:{}] transfer response token: {} allowed: {}", + username, token, allowed + ); + + // If this token is one of our uploads, the peer just accepted + // our offer — start streaming. This leaves the download path + // (every other token) byte-for-byte unchanged. + if self.serving_tokens.remove(&token) { + if allowed { + let _ = self + .client_channel + .send(ClientOperation::StartUpload { token }); + } + return; + } + + if allowed { + debug!( + "[peer:{}] Transfer allowed, ready to connect with token {:}", + username, token + ); + let Some(peer_snapshot) = self.peer_snapshot() else { + return; + }; + if let Err(e) = + self.client_channel.send(ClientOperation::DownloadFromPeer( + token, + peer_snapshot, + allowed, + )) + { + error!( + "[peer:{}] failed to send DownloadFromPeer: {}", + username, e + ); + } + } else if let Some(reason_text) = reason { + debug!( + "[peer:{}] Transfer rejected: {} - token {}, waiting for TransferRequest...", + username, reason_text, token + ); + } + } + + fn handle_place_in_queue_response(&self, filename: String, place: u32) { + let username = self.peer_username(); + debug!( + "[peer:{}] Place in queue response - file: {}, place: {}", + username, filename, place + ); + if let Err(e) = + self.client_channel + .send(ClientOperation::PlaceInQueueUpdate { + username: username.clone(), + filename, + place, + }) + { + error!( + "[peer:{}] failed to forward PlaceInQueueUpdate: {}", + username, e + ); + } + } + + fn handle_set_username(&self, username: String) { + trace!("[peer:{}] SetUsername: {}", self.peer_username(), username); + match self.peer.write_safe() { + Ok(mut p) => p.username = username, + Err(e) => { + error!("[peer_actor] SetUsername write: {}", e); + } + } + } + + fn handle_incoming_queue_upload(&self, filename: String) { + // A peer wants to download one of our shared files. Ask the + // client (which owns the shares) to prepare the upload. + let requester_key = self.peer_username(); + if let Err(e) = self.client_channel.send(ClientOperation::QueueUpload { + requester_key, + filename, + }) { + error!("[peer_actor] forward IncomingQueueUpload: {}", e); + } + } + + fn handle_serve_upload(&mut self, token: u32, filename: String, size: u64) { + self.serving_tokens.insert(token); + let message = MessageFactory::build_upload_transfer_request( + &filename, token, size, + ); + self.send_message(message); + } + + fn handle_share_list_requested(&self) { + let requester_key = self.peer_username(); + if let Err(e) = self + .client_channel + .send(ClientOperation::ShareListRequested { requester_key }) + { + error!("[peer_actor] forward ShareListRequested: {}", e); + } + } + + fn handle_share_list_received(&self, directories: Vec) { + let username = self.peer_username(); + if let Err(e) = + self.client_channel.send(ClientOperation::BrowseResult { + username, + directories, + }) + { + error!("[peer_actor] forward BrowseResult: {}", e); + } + } + + fn handle_upload_failed(&self, username: String, filename: String) { + if let Err(e) = self + .client_channel + .send(ClientOperation::UploadFailed(username, filename)) + { + error!("[peer_actor] failed to forward UploadFailed: {}", e); + } + } + fn process_read(&mut self) { if self.reader.buffer_len() > 0 { self.extract_and_process_messages(); @@ -489,7 +524,7 @@ impl PeerActor { .get_message_name( MessageType::Peer, u32::from_le_bytes( - message.get_slice(0, 4).try_into().unwrap() + message.get_slice(0, 4).try_into().unwrap_or_default() ) ) .map_err(|e| e.to_string()) diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 908d18f..1d436b4 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -413,37 +413,10 @@ impl ServerActor { match msg { ServerMessage::ConnectToPeer(peer) => { - if let Some(op) = match peer.connection_type { - ConnectionType::P | ConnectionType::F => { - Some(ClientOperation::ConnectToPeer(peer)) - } - ConnectionType::D => None, - } && let Err(e) = self.client_channel.send(op) - { - error!("[server] failed to send ConnectToPeer: {}", e); - } + self.handle_connect_to_peer(peer); } ServerMessage::LoginStatus(message) => { - match self.context.write_safe() { - Ok(mut ctx) => ctx.logged_in = Some(message), - Err(e) => { - error!("[server] LoginStatus write: {}", e); - } - } - // Send the post-login handshake exactly once, only on success, - // on the live path (the old ServerActor::login did this but was - // never called). Advertises real shared counts and, when - // listening, the port peers must connect to. - if message { - for msg in post_login_messages( - self.enable_listen, - self.listen_port, - self.shared_folder_count, - self.shared_file_count, - ) { - self.send_message(msg); - } - } + self.handle_login_status(message); } ServerMessage::PierceFirewall(token) => { self.send_message( @@ -465,40 +438,16 @@ impl ServerActor { obfuscation_type, obfuscated_port, } => { - debug!( - "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", - username, host, port, obfuscation_type, obfuscated_port + self.handle_get_peer_address_response( + username, + host, + port, + obfuscation_type, + obfuscated_port, ); - - if let Err(e) = self.client_channel.send( - ClientOperation::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - }, - ) { - error!( - "[server] Error forwarding GetPeerAddress response to client: {}", - e - ); - } } ServerMessage::PrivateMessageReceived(user_message) => { - debug!( - "[server] Private message from {}", - user_message.username() - ); - if let Err(e) = self - .client_channel - .send(ClientOperation::PrivateMessageReceived(user_message)) - { - error!( - "[server] Error forwarding private message to client: {}", - e - ); - } + self.handle_private_message_received(user_message); } ServerMessage::RoomListReceived(rooms) => { self.forward_room_event(RoomEvent::List(rooms)); @@ -537,41 +486,7 @@ impl ServerActor { password, response, } => { - self.queue_message(MessageFactory::build_login_message( - &username, &password, - )); - - let start = std::time::Instant::now(); - let timeout = Duration::from_secs(5); - - let context = self.context.clone(); - std::thread::spawn(move || { - loop { - if start.elapsed() >= timeout { - let _ = response.send(Err(SoulseekRs::Timeout)); - break; - } - - let logged_in = match context.read_safe() { - Ok(ctx) => ctx.logged_in, - Err(e) => { - let _ = response.send(Err(e)); - break; - } - }; - if let Some(logged_in) = logged_in { - let result = if logged_in { - Ok(true) - } else { - Err(SoulseekRs::AuthenticationFailed) - }; - let _ = response.send(result); - break; - } - - std::thread::sleep(Duration::from_millis(100)); - } - }); + self.handle_login(username, password, response); } ServerMessage::FileSearch { token, query } => { self.file_search(token, &query); @@ -581,19 +496,149 @@ impl ServerActor { token, query, } => { - if let Err(e) = - self.client_channel.send(ClientOperation::IncomingSearch { - username, - token, - query, - }) - { - error!("[server] forward IncomingSearch: {}", e); - } + self.handle_file_search_request(username, token, query); } } } + fn handle_connect_to_peer(&self, peer: Peer) { + if let Some(op) = match peer.connection_type { + ConnectionType::P | ConnectionType::F => { + Some(ClientOperation::ConnectToPeer(peer)) + } + ConnectionType::D => None, + } && let Err(e) = self.client_channel.send(op) + { + error!("[server] failed to send ConnectToPeer: {}", e); + } + } + + fn handle_login_status(&mut self, message: bool) { + match self.context.write_safe() { + Ok(mut ctx) => ctx.logged_in = Some(message), + Err(e) => { + error!("[server] LoginStatus write: {}", e); + } + } + // Send the post-login handshake exactly once, only on success, + // on the live path (the old ServerActor::login did this but was + // never called). Advertises real shared counts and, when + // listening, the port peers must connect to. + if message { + for msg in post_login_messages( + self.enable_listen, + self.listen_port, + self.shared_folder_count, + self.shared_file_count, + ) { + self.send_message(msg); + } + } + } + + fn handle_get_peer_address_response( + &self, + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + ) { + debug!( + "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", + username, host, port, obfuscation_type, obfuscated_port + ); + + if let Err(e) = + self.client_channel + .send(ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }) + { + error!( + "[server] Error forwarding GetPeerAddress response to client: {}", + e + ); + } + } + + fn handle_private_message_received(&self, user_message: UserMessage) { + debug!("[server] Private message from {}", user_message.username()); + if let Err(e) = self + .client_channel + .send(ClientOperation::PrivateMessageReceived(user_message)) + { + error!( + "[server] Error forwarding private message to client: {}", + e + ); + } + } + + fn handle_login( + &mut self, + username: String, + password: String, + response: std::sync::mpsc::Sender>, + ) { + self.queue_message(MessageFactory::build_login_message( + &username, &password, + )); + + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + + let context = self.context.clone(); + std::thread::spawn(move || { + loop { + if start.elapsed() >= timeout { + let _ = response.send(Err(SoulseekRs::Timeout)); + break; + } + + let logged_in = match context.read_safe() { + Ok(ctx) => ctx.logged_in, + Err(e) => { + let _ = response.send(Err(e)); + break; + } + }; + if let Some(logged_in) = logged_in { + let result = if logged_in { + Ok(true) + } else { + Err(SoulseekRs::AuthenticationFailed) + }; + let _ = response.send(result); + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + } + + fn handle_file_search_request( + &self, + username: String, + token: u32, + query: String, + ) { + if let Err(e) = + self.client_channel.send(ClientOperation::IncomingSearch { + username, + token, + query, + }) + { + error!("[server] forward IncomingSearch: {}", e); + } + } + fn process_read(&mut self) { if self.reader.buffer_len() > 0 { self.extract_and_process_messages(); @@ -696,7 +741,7 @@ impl ServerActor { .get_message_name( MessageType::Server, u32::from_le_bytes( - message.get_slice(0, 4).try_into().unwrap() + message.get_slice(0, 4).try_into().unwrap_or_default() ) ) .map_err(|e| e.to_string()) diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs new file mode 100644 index 0000000..5c89383 --- /dev/null +++ b/soulseek-rs-lib/src/client/connection.rs @@ -0,0 +1,270 @@ +use super::{ + Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, + DownloadStatus, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, + RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, + TcpStream, debug, error, info, mpsc, thread, trace, warn, +}; + +impl Client { + pub fn connect(&mut self) -> Result<()> { + let (sender, message_reader): ( + Sender, + Receiver, + ) = mpsc::channel(); + + let mut ctx = self.context.write_safe()?; + ctx.sender = Some(sender.clone()); + let peer_registry = PeerRegistry::new( + ctx.actor_system.clone(), + sender.clone(), + self.username.clone(), + ); + ctx.peer_registry = Some(peer_registry); + + let listen_sender = sender.clone(); + + // Scan the shared directory once into the read-only index, and report + // the real folder/file counts to the server on login. + let shares = match self.shared_directory.as_deref() { + Some(dir) if !dir.trim().is_empty() => { + match Shares::scan(std::path::Path::new(dir)) { + Ok(scanned) => { + info!( + "Sharing {} files in {} folders from {}", + scanned.file_count(), + scanned.folder_count(), + dir + ); + Arc::new(scanned) + } + Err(e) => { + warn!("Failed to scan shared directory {}: {}", dir, e); + Arc::new(Shares::empty()) + } + } + } + _ => Arc::new(Shares::empty()), + }; + let shared_folder_count = shares.folder_count(); + let shared_file_count = shares.file_count(); + ctx.shares = shares; + + let server_actor = ServerActor::new( + self.address.clone(), + sender, + self.listen_port, + self.enable_listen, + shared_folder_count, + shared_file_count, + ); + + self.server_handle = Some(ctx.actor_system.spawn_with_handle( + server_actor, + |actor, handle| { + actor.set_self_handle(handle); + }, + )); + + if self.enable_listen { + let listen_port = self.listen_port; + let client_sender = listen_sender; + let context = self.context.clone(); + let own_username = self.username.clone(); + + thread::spawn(move || { + Listen::start( + listen_port, + client_sender, + context, + own_username, + ); + }); + } + + Self::listen_to_client_operations( + message_reader, + self.context.clone(), + self.username.clone(), + ); + + Ok(()) + } + + pub fn login(&self) -> Result { + info!("Logging in as {}", self.username); + if let Some(handle) = &self.server_handle { + let (tx, rx) = std::sync::mpsc::channel(); + let _ = handle.send(ServerMessage::Login { + username: self.username.clone(), + password: self.password.clone(), + response: tx, + }); + + match rx.recv() { + Ok(result) => result, + Err(_) => Err(SoulseekRs::Timeout), + } + } else { + Err(SoulseekRs::NotConnected) + } + } + + /// Ask the server for a peer's address and open a direct control + /// connection to it. Downloads queued for that peer are sent automatically + /// once the connection is established. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn connect_peer(&self, username: &str) -> Result<()> { + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; + handle + .send(ServerMessage::GetPeerAddress(username.to_string())) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + #[allow(dead_code)] + pub fn remove_peer(&self, username: &str) { + let context = match self.context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] remove_peer: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = registry.remove_peer(username) + { + let _ = handle.stop(); + } + } + + pub(crate) fn connect_to_peer( + peer: Peer, + client_context: Arc>, + own_username: String, + stream: Option, + ) { + let client_context = client_context; + + let peer_clone = peer.clone(); + trace!( + "[client] connecting to {}, with connection_type: {}, and token {:?}", + peer.username, peer.connection_type, peer.token + ); + match peer.connection_type { + ConnectionType::P => { + let username = peer.username; + + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] connect_to_peer read: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry { + match registry.register_peer(peer_clone, stream, None) { + Ok(_) => (), + Err(e) => { + trace!( + "Failed to spawn peer actor for {:?}: {:?}", + username, e + ); + } + } + } else { + trace!("PeerRegistry not initialized"); + } + } + + ConnectionType::F => { + trace!( + "[client] downloading from: {}, {:?}", + peer.username, peer.token + ); + let Some(token) = peer.token else { + error!( + "[client] F connection from {} without token", + peer.username + ); + return; + }; + let download_peer = DownloadPeer::new( + peer.username, + peer.host, + peer.port, + token, + false, + own_username, + ); + + match download_peer.download_file( + client_context.clone(), + None, + None, + ) { + Ok((download, filename)) => { + trace!( + "[client] downloaded {} bytes {:?} ", + filename, download.size + ); + let _ = download.sender.send(DownloadStatus::Completed); + match client_context.write_safe() { + Ok(mut ctx) => ctx.update_download_with_status( + download.token, + DownloadStatus::Completed, + ), + Err(e) => error!( + "[client] connect_to_peer F write: {}", + e + ), + } + } + Err(e) => { + trace!("[client] failed to download: {}", e); + } + } + } + ConnectionType::D => { + error!("ConnectionType::D not implemented"); + } + } + } + + pub(crate) fn pierce_firewall( + peer: Peer, + client_context: Arc>, + own_username: String, + ) { + debug!("Piercing firewall for peer: {:?}", peer); + + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] pierce_firewall read: {}", e); + return; + } + }; + if let Some(server_sender) = &context.server_sender { + if let Some(token) = peer.token { + match server_sender.send(ServerMessage::PierceFirewall(token)) { + Ok(()) => (), + Err(e) => { + error!("Failed to send PierceFirewall message: {}", e); + } + } + } else { + error!("No token available for PierceFirewall"); + } + } else { + error!("No server sender available for PierceFirewall"); + } + + drop(context); + Self::connect_to_peer(peer, client_context, own_username, None); + } +} diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs new file mode 100644 index 0000000..19bcf39 --- /dev/null +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -0,0 +1,191 @@ +use super::{ + Arc, Client, ClientContext, Download, DownloadMetadata, DownloadStatus, + Receiver, Result, RwLock, RwLockExt, Sender, error, info, md5, mpsc, +}; + +impl Client { + #[must_use] + pub fn get_all_downloads(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.get_downloads().clone()) + .unwrap_or_default() + } + + #[must_use] + pub fn pause_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.pause_by_file(username, filename), + Err(e) => { + error!("[client] pause_download: {}", e); + false + } + } + } + + #[must_use] + pub fn resume_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.resume_by_file(username, filename), + Err(e) => { + error!("[client] resume_download: {}", e); + false + } + } + } + + #[must_use] + pub fn remove_queued_download( + &self, + username: &str, + filename: &str, + ) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.remove_queued_by_file(username, filename) + } + Err(e) => { + error!("[client] remove_queued_download: {}", e); + false + } + } + } + + /// Remove every download for `username`/`filename` regardless of status. + /// Call this before re-issuing [`Client::download`] for a failed download, + /// otherwise the stale entry (whose md5-derived token equals the retry's) + /// shadows the fresh one and its completion is misrouted. + /// + /// Returns whether anything was removed. + #[must_use] + pub fn remove_download(&self, username: &str, filename: &str) -> bool { + match self.context.write_safe() { + Ok(mut ctx) => ctx.downloads.remove_by_file(username, filename), + Err(e) => { + error!("[client] remove_download: {}", e); + false + } + } + } + + pub fn download( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + ) -> Result<(Download, Receiver)> { + self.download_with_metadata( + filename, + username, + size, + download_directory, + DownloadMetadata::default(), + ) + } + + pub fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + metadata: DownloadMetadata, + ) -> Result<(Download, Receiver)> { + info!("[client] Downloading {} from {}", filename, username); + + let hash = md5::md5(&filename); + let token = u32::from_str_radix(&hash[0..5], 16)?; + + let (download_sender, download_receiver): ( + Sender, + Receiver, + ) = mpsc::channel(); + + let download = Download { + username: username.clone(), + filename, + token, + size, + download_directory, + status: DownloadStatus::Queued, + sender: download_sender, + queue_position: None, + metadata, + }; + + let mut context = self.context.write_safe()?; + context.add_download(download.clone()); + + // If we already have a control connection to this peer, queue the + // upload immediately. Otherwise open one directly (server GetPeerAddress + // → outbound PeerInit → PeerConnected → the queued upload is flushed). + let peer_registered = context + .peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)); + let queued_now = peer_registered + && context.peer_registry.as_ref().is_some_and(|r| { + r.queue_upload(&username, download.filename.clone()).is_ok() + }); + + drop(context); + + let failed = if peer_registered { + !queued_now + } else { + // No existing connection: initiate one. Only a genuinely + // unconnected client (no server handle) fails outright here. + self.connect_peer(&username).is_err() + }; + + if failed { + let reason = if peer_registered { + "The user declined the download" + } else { + "Could not connect to the user" + }; + let _ = download + .sender + .send(DownloadStatus::Failed(Some(reason.to_string()))); + self.context.write_safe()?.update_download_with_status( + token, + DownloadStatus::Failed(Some(reason.to_string())), + ); + } + + Ok((download, download_receiver)) + } + + /// Fail every still-`Queued` download for `username`, both on the caller's + /// status channel (so a blocked `Receiver` unblocks) and in the store. + pub(crate) fn fail_queued_downloads( + client_context: &Arc>, + username: &str, + ) { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] fail_queued_downloads write: {}", e); + return; + } + }; + let doomed: Vec<(u32, Sender)> = context + .get_downloads() + .iter() + .filter(|d| { + d.username == username + && matches!(d.status, DownloadStatus::Queued) + }) + .map(|d| (d.token, d.sender.clone())) + .collect(); + for (token, sender) in doomed { + let reason = Some("The user went offline".to_string()); + let _ = sender.send(DownloadStatus::Failed(reason.clone())); + context.update_download_with_status( + token, + DownloadStatus::Failed(reason), + ); + } + } +} diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs new file mode 100644 index 0000000..7253656 --- /dev/null +++ b/soulseek-rs-lib/src/client/mod.rs @@ -0,0 +1,660 @@ +use crate::actor::ActorHandle; +use crate::actor::server_actor::{ + PeerAddress, ServerActor, ServerMessage, UserMessage, +}; +use crate::download_store::{DownloadStore, collect_failed_tokens}; +use crate::types::{DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo}; +use crate::utils::logger; +use crate::{ + Transfer, + actor::{ActorSystem, peer_registry::PeerRegistry}, + error::{Result, SoulseekRs}, + message::peer::{FileEntry, SharedDirectory, build_file_search_response}, + peer::{ + ConnectionType, DownloadPeer, NewPeer, Peer, PeerMessage, + listen::Listen, + }, + shares::Shares, + types::{Download, Search, SearchResult}, + utils::{lock::RwLockExt, md5, thread_pool::ThreadPool}, +}; +use std::{ + collections::HashMap, + net::TcpStream, + sync::{ + RwLock, + atomic::{AtomicBool, AtomicU32, Ordering}, + mpsc::{Receiver, Sender}, + }, + thread::{self, sleep}, +}; +use std::{ + sync::{Arc, mpsc}, + time::{Duration, Instant}, +}; + +use crate::{debug, error, info, trace, warn}; +const DEFAULT_LISTEN_PORT: u16 = 2234; + +/// How long to wait for a server-brokered (firewalled) peer to connect back +/// before giving up and failing the download. Matches the direct-dial timeout. +const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +/// Source of non-zero correlation tokens for server-brokered connections. +static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1); + +fn next_connect_token() -> u32 { + NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1) +} + +/// Upload tokens are minted in the high half of the space so they never collide +/// with download tokens (md5-derived, always < 2^20). +static NEXT_UPLOAD_TOKEN: AtomicU32 = AtomicU32::new(0x8000_0000); + +fn next_upload_token() -> u32 { + NEXT_UPLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) +} + +/// A file we have agreed to serve to a peer, awaiting their TransferResponse. +struct UploadJob { + downloader: String, + real_path: std::path::PathBuf, +} + +/// Build a `FileSearchResponse` for `query` against `shares`, or `None` if +/// nothing matches. `own_username` is the name the searcher will download from. +fn build_search_response( + shares: &Shares, + own_username: &str, + token: u32, + query: &str, +) -> Option { + let matches = shares.search(query); + if matches.is_empty() { + return None; + } + let entries: Vec = matches + .iter() + .map(|f| FileEntry { + name: &f.virtual_path, + size: f.size, + attribs: &f.attributes, + }) + .collect(); + Some(build_file_search_response( + own_username, + token, + &entries, + 1, + 0, + )) +} + +#[derive(Debug, Clone)] +pub struct ClientSettings { + pub username: String, + pub password: String, + pub server_address: PeerAddress, + pub enable_listen: bool, + pub listen_port: u16, + /// Directory whose files are shared with (uploaded to) other peers. + /// `None` means nothing is shared. + pub shared_directory: Option, +} + +impl ClientSettings { + pub fn new( + username: impl Into, + password: impl Into, + ) -> Self { + Self { + username: username.into(), + password: password.into(), + ..Default::default() + } + } +} + +impl Default for ClientSettings { + fn default() -> Self { + Self { + username: String::new(), + password: String::new(), + server_address: PeerAddress::new( + "server.slsknet.org".to_string(), + 2416, + ), + enable_listen: true, + listen_port: DEFAULT_LISTEN_PORT, + shared_directory: None, + } + } +} + +#[derive(Debug)] + +pub enum ClientOperation { + NewPeer(NewPeer), + ConnectToPeer(Peer), + SearchResult(SearchResult), + PeerDisconnected(u64, String, Option), + PierceFireWall(Peer), + DownloadFromPeer(u32, Peer, bool), + UpdateDownloadTokens(Transfer, String), + GetPeerAddressResponse { + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + }, + UploadFailed(String, String), + PlaceInQueueUpdate { + username: String, + filename: String, + place: u32, + }, + SetServerSender(Sender), + PrivateMessageReceived(UserMessage), + PeerConnected(String), + /// A search distributed to us by the server; reply if our shares match. + IncomingSearch { + username: String, + token: u32, + query: String, + }, + /// A peer queued one of our shared files; `requester_key` is the registry + /// key of the peer actor (may carry a `:direct` suffix). + QueueUpload { + requester_key: String, + filename: String, + }, + /// The peer accepted our upload offer for `token`; start streaming. + StartUpload { + token: u32, + }, + /// A peer asked to browse our shared files; send our SharedFileListResponse. + ShareListRequested { + requester_key: String, + }, + /// A peer we are browsing returned their shared-file listing. + BrowseResult { + username: String, + directories: Vec, + }, + /// A direct outbound connection to this peer failed before it was + /// established — the peer is likely firewalled, so fall back to asking the + /// server to broker the connection. Carries the reporting actor's id. + PeerConnectFailed(u64, String), + /// Something happened in the chat-room subsystem (list refreshed, a room + /// joined/left, a message said, a member joined/left). + RoomEvent(RoomEvent), +} +pub struct ClientContext { + pub peer_registry: Option, + pub downloads: DownloadStore, + sender: Option>, + server_sender: Option>, + searches: HashMap, + private_messages: Vec, + /// Correlation tokens for server-brokered (firewalled) connections, mapping + /// a token we sent in a ConnectToPeer to the peer we expect back. + pending_connect_tokens: HashMap, + /// Files we share with peers (read-only after connect). + pub shares: Arc, + /// Peer listen addresses learned from GetPeerAddress responses. + peer_addresses: HashMap, + /// Peer messages waiting for a control connection to that peer. + pending_peer_messages: HashMap>, + /// Uploads we have offered, keyed by our transfer token. + uploads: HashMap, + /// Upload tokens waiting for the downloader's address to be resolved. + pending_serves: HashMap>, + /// Shared-file listings received from peers we browsed. + browse_results: HashMap>, + /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). + room_list: Vec, + /// Chat-room events awaiting consumption by the client/UI. + room_events: Vec, + actor_system: Arc, +} +impl Default for ClientContext { + fn default() -> Self { + Self::new() + } +} + +// Thin delegating shims so existing callers (peer/listen, peer/download_peer, +// tests) keep working while download state lives in DownloadStore. +impl ClientContext { + pub fn add_download(&mut self, download: Download) { + self.downloads.add(download); + } + pub fn remove_download(&mut self, token: u32) { + self.downloads.remove(token); + } + #[must_use] + pub fn get_download_by_token(&self, token: u32) -> Option<&Download> { + self.downloads.get_by_token(token) + } + pub fn get_download_by_token_mut( + &mut self, + token: u32, + ) -> Option<&mut Download> { + self.downloads.get_by_token_mut(token) + } + pub fn get_download_by_file_mut( + &mut self, + username: &str, + filename: &str, + ) -> Option<&mut Download> { + self.downloads.get_by_file_mut(username, filename) + } + #[must_use] + pub fn get_download_tokens(&self) -> Vec { + self.downloads.tokens() + } + #[must_use] + pub const fn get_downloads(&self) -> &Vec { + self.downloads.list() + } + pub fn update_download_with_status( + &mut self, + token: u32, + status: DownloadStatus, + ) { + self.downloads.update_status(token, status); + } + pub fn remove_queued_download_by_file( + &mut self, + username: &str, + filename: &str, + ) -> bool { + self.downloads.remove_queued_by_file(username, filename) + } +} +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + let download = Download { + username: "test".to_string(), + filename: "test.txt".to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + context.add_download(download); + assert!(context.get_download_by_token(123).is_some()); + assert_eq!(context.get_download_tokens(), vec![123]); + assert_eq!(context.get_downloads().len(), 1); + if let Some(download) = context.get_download_by_token_mut(token) { + assert_eq!(download.token, token); + download.token = new_token; + } + assert!(context.get_download_by_token(new_token).is_some()); + assert_eq!(context.get_download_tokens(), vec![new_token]); + context.remove_download(new_token); + assert_eq!(context.get_downloads().len(), 0); + assert!(context.get_download_by_token(1234).is_none()); +} + +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + let download = Download { + username: "peer".to_string(), + filename: "song.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: download_sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + + client.context.write().unwrap().add_download(download); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn download_without_a_connection_resolves_failed() { + // A client that never connected has no server handle and no peer registry, + // so it cannot open a connection to the peer: the download must resolve to + // Failed rather than hang Queued forever. + let client = Client::new("test-user", "test-password"); + let (_download, receiver) = client + .download( + "song.mp3".to_string(), + "peer".to_string(), + 100, + "test".to_string(), + ) + .expect("download() should return a handle"); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(DownloadStatus::Failed(_)) + )); +} + +#[test] +fn fail_queued_downloads_notifies_receiver_and_store() { + // When a brokered connect times out, every Queued download for the peer + // must resolve to Failed both on its channel and in the store. + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(Download { + username: "peer".to_string(), + filename: "f.mp3".to_string(), + token: 7, + size: 10, + download_directory: "d".to_string(), + status: DownloadStatus::Queued, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }); + + Client::fail_queued_downloads(&client.context, "peer"); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(7) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); +} + +#[test] +fn build_search_response_matches_shares_and_echoes_token() { + let dir = std::env::temp_dir() + .join(format!("soulseek-searchresp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + let response = build_search_response(&shares, "me", 99, "xyzzy") + .expect("a matching share yields a response"); + let mut decoded = + crate::message::Message::new_with_data(response.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + assert_eq!(result.username, "me"); + assert_eq!(result.token, 99); + assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); + + assert!(build_search_response(&shares, "me", 1, "nomatch").is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + let queued_download = Download { + username: "peer".to_string(), + filename: "queued.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + let active_download = Download { + username: "peer".to_string(), + filename: "active.mp3".to_string(), + token: 456, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + + { + let mut context = client.context.write().unwrap(); + context.add_download(queued_download); + context.add_download(active_download); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + +impl ClientContext { + #[must_use] + pub fn new() -> Self { + let max_threads = + thread::available_parallelism().map_or(8, std::num::NonZero::get); + + let thread_pool = Arc::new(ThreadPool::new(max_threads)); + let actor_system = Arc::new(ActorSystem::new(thread_pool)); + + Self { + peer_registry: None, + sender: None, + server_sender: None, + searches: HashMap::new(), + private_messages: Vec::new(), + pending_connect_tokens: HashMap::new(), + shares: Arc::new(Shares::empty()), + peer_addresses: HashMap::new(), + pending_peer_messages: HashMap::new(), + uploads: HashMap::new(), + pending_serves: HashMap::new(), + browse_results: HashMap::new(), + room_list: Vec::new(), + room_events: Vec::new(), + downloads: DownloadStore::new(), + actor_system, + } + } + + /// Apply a chat-room event: keep the room-list snapshot current and queue + /// the event for the client/UI to drain. + pub fn apply_room_event(&mut self, event: RoomEvent) { + if let RoomEvent::List(rooms) = &event { + self.room_list.clone_from(rooms); + } + self.room_events.push(event); + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + + /// Cache a peer's listen address learned from a GetPeerAddress response. + pub fn cache_peer_address( + &mut self, + username: &str, + host: String, + port: u32, + ) { + self.peer_addresses + .insert(username.to_string(), (host, port)); + } + + /// The cached listen address for `username`, if known. + #[must_use] + pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { + self.peer_addresses.get(username).cloned() + } + + /// Queue a peer message to send once a control connection to `username` is up. + pub fn queue_peer_message( + &mut self, + username: &str, + message: crate::message::Message, + ) { + self.pending_peer_messages + .entry(username.to_string()) + .or_default() + .push(message); + } + + /// Remove and return the messages queued for `username`. + pub fn take_peer_messages( + &mut self, + username: &str, + ) -> Vec { + self.pending_peer_messages + .remove(username) + .unwrap_or_default() + } + + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.browse_results.insert(username, directories); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + + /// Remember that a server-brokered connection to `username` is pending under + /// `token`; the peer will quote it back in a PierceFirewall. + pub fn add_pending_connect(&mut self, token: u32, username: String) { + self.pending_connect_tokens.insert(token, username); + } + + /// Resolve and consume the peer expected for a brokered connection `token`. + pub fn take_pending_connect(&mut self, token: u32) -> Option { + self.pending_connect_tokens.remove(&token) + } + + /// Record a private message received from another user. + pub fn push_private_message(&mut self, message: UserMessage) { + self.private_messages.push(message); + } + + /// Remove and return all buffered private messages. + pub fn take_private_messages(&mut self) -> Vec { + std::mem::take(&mut self.private_messages) + } +} +pub struct Client { + enable_listen: bool, + listen_port: u16, + address: PeerAddress, + username: String, + password: String, + shared_directory: Option, + server_handle: Option>, + context: Arc>, +} + +impl Client { + pub fn new( + username: impl Into, + password: impl Into, + ) -> Self { + Self::with_settings(ClientSettings::new(username, password)) + } + + #[must_use] + pub fn with_settings(settings: ClientSettings) -> Self { + logger::init(); + Self { + enable_listen: settings.enable_listen, + listen_port: settings.listen_port, + address: settings.server_address, + username: settings.username, + password: settings.password, + shared_directory: settings.shared_directory, + context: Arc::new(RwLock::new(ClientContext::new())), + server_handle: None, + } + } + + /// The directory whose files are shared with other peers, if configured. + #[must_use] + pub fn shared_directory(&self) -> Option<&str> { + self.shared_directory.as_deref() + } +} + +mod connection; +mod downloads; +mod operations; +mod rooms; +mod search; +mod uploads; diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client/operations.rs similarity index 50% rename from soulseek-rs-lib/src/client.rs rename to soulseek-rs-lib/src/client/operations.rs index 3319abc..6919e8c 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -1,1309 +1,13 @@ -use crate::actor::ActorHandle; -use crate::actor::server_actor::{ - PeerAddress, ServerActor, ServerMessage, UserMessage, +use super::{ + Arc, BROKER_CONNECT_TIMEOUT, Client, ClientContext, ClientOperation, + ConnectionType, Download, DownloadPeer, DownloadStatus, Peer, PeerMessage, + PeerRegistry, Receiver, RwLock, RwLockExt, ServerMessage, UploadJob, + build_search_response, debug, error, info, next_connect_token, + next_upload_token, sleep, thread, trace, warn, }; -use crate::download_store::{DownloadStore, collect_failed_tokens}; -use crate::types::{DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo}; -use crate::utils::logger; -use crate::{ - Transfer, - actor::{ActorSystem, peer_registry::PeerRegistry}, - error::{Result, SoulseekRs}, - message::peer::{FileEntry, SharedDirectory, build_file_search_response}, - peer::{ - ConnectionType, DownloadPeer, NewPeer, Peer, PeerMessage, - listen::Listen, - }, - shares::Shares, - types::{Download, Search, SearchResult}, - utils::{lock::RwLockExt, md5, thread_pool::ThreadPool}, -}; -use std::{ - collections::HashMap, - net::TcpStream, - sync::{ - RwLock, - atomic::{AtomicBool, AtomicU32, Ordering}, - mpsc::{Receiver, Sender}, - }, - thread::{self, sleep}, -}; -use std::{ - sync::{Arc, mpsc}, - time::{Duration, Instant}, -}; - -use crate::{debug, error, info, trace, warn}; -const DEFAULT_LISTEN_PORT: u16 = 2234; - -/// How long to wait for a server-brokered (firewalled) peer to connect back -/// before giving up and failing the download. Matches the direct-dial timeout. -const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); - -/// Source of non-zero correlation tokens for server-brokered connections. -static NEXT_CONNECT_TOKEN: AtomicU32 = AtomicU32::new(1); - -fn next_connect_token() -> u32 { - NEXT_CONNECT_TOKEN.fetch_add(1, Ordering::Relaxed).max(1) -} - -/// Upload tokens are minted in the high half of the space so they never collide -/// with download tokens (md5-derived, always < 2^20). -static NEXT_UPLOAD_TOKEN: AtomicU32 = AtomicU32::new(0x8000_0000); - -fn next_upload_token() -> u32 { - NEXT_UPLOAD_TOKEN.fetch_add(1, Ordering::Relaxed) -} - -/// A file we have agreed to serve to a peer, awaiting their TransferResponse. -struct UploadJob { - downloader: String, - real_path: std::path::PathBuf, -} - -/// Build a `FileSearchResponse` for `query` against `shares`, or `None` if -/// nothing matches. `own_username` is the name the searcher will download from. -fn build_search_response( - shares: &Shares, - own_username: &str, - token: u32, - query: &str, -) -> Option { - let matches = shares.search(query); - if matches.is_empty() { - return None; - } - let entries: Vec = matches - .iter() - .map(|f| FileEntry { - name: &f.virtual_path, - size: f.size, - attribs: &f.attributes, - }) - .collect(); - Some(build_file_search_response( - own_username, - token, - &entries, - 1, - 0, - )) -} - -#[derive(Debug, Clone)] -pub struct ClientSettings { - pub username: String, - pub password: String, - pub server_address: PeerAddress, - pub enable_listen: bool, - pub listen_port: u16, - /// Directory whose files are shared with (uploaded to) other peers. - /// `None` means nothing is shared. - pub shared_directory: Option, -} - -impl ClientSettings { - pub fn new( - username: impl Into, - password: impl Into, - ) -> Self { - Self { - username: username.into(), - password: password.into(), - ..Default::default() - } - } -} - -impl Default for ClientSettings { - fn default() -> Self { - Self { - username: String::new(), - password: String::new(), - server_address: PeerAddress::new( - "server.slsknet.org".to_string(), - 2416, - ), - enable_listen: true, - listen_port: DEFAULT_LISTEN_PORT, - shared_directory: None, - } - } -} - -#[derive(Debug)] - -pub enum ClientOperation { - NewPeer(NewPeer), - ConnectToPeer(Peer), - SearchResult(SearchResult), - PeerDisconnected(u64, String, Option), - PierceFireWall(Peer), - DownloadFromPeer(u32, Peer, bool), - UpdateDownloadTokens(Transfer, String), - GetPeerAddressResponse { - username: String, - host: String, - port: u32, - obfuscation_type: u32, - obfuscated_port: u16, - }, - UploadFailed(String, String), - PlaceInQueueUpdate { - username: String, - filename: String, - place: u32, - }, - SetServerSender(Sender), - PrivateMessageReceived(UserMessage), - PeerConnected(String), - /// A search distributed to us by the server; reply if our shares match. - IncomingSearch { - username: String, - token: u32, - query: String, - }, - /// A peer queued one of our shared files; `requester_key` is the registry - /// key of the peer actor (may carry a `:direct` suffix). - QueueUpload { - requester_key: String, - filename: String, - }, - /// The peer accepted our upload offer for `token`; start streaming. - StartUpload { - token: u32, - }, - /// A peer asked to browse our shared files; send our SharedFileListResponse. - ShareListRequested { - requester_key: String, - }, - /// A peer we are browsing returned their shared-file listing. - BrowseResult { - username: String, - directories: Vec, - }, - /// A direct outbound connection to this peer failed before it was - /// established — the peer is likely firewalled, so fall back to asking the - /// server to broker the connection. Carries the reporting actor's id. - PeerConnectFailed(u64, String), - /// Something happened in the chat-room subsystem (list refreshed, a room - /// joined/left, a message said, a member joined/left). - RoomEvent(RoomEvent), -} -pub struct ClientContext { - pub peer_registry: Option, - pub downloads: DownloadStore, - sender: Option>, - server_sender: Option>, - searches: HashMap, - private_messages: Vec, - /// Correlation tokens for server-brokered (firewalled) connections, mapping - /// a token we sent in a ConnectToPeer to the peer we expect back. - pending_connect_tokens: HashMap, - /// Files we share with peers (read-only after connect). - pub shares: Arc, - /// Peer listen addresses learned from GetPeerAddress responses. - peer_addresses: HashMap, - /// Peer messages waiting for a control connection to that peer. - pending_peer_messages: HashMap>, - /// Uploads we have offered, keyed by our transfer token. - uploads: HashMap, - /// Upload tokens waiting for the downloader's address to be resolved. - pending_serves: HashMap>, - /// Shared-file listings received from peers we browsed. - browse_results: HashMap>, - /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). - room_list: Vec, - /// Chat-room events awaiting consumption by the client/UI. - room_events: Vec, - actor_system: Arc, -} -impl Default for ClientContext { - fn default() -> Self { - Self::new() - } -} - -// Thin delegating shims so existing callers (peer/listen, peer/download_peer, -// tests) keep working while download state lives in DownloadStore. -impl ClientContext { - pub fn add_download(&mut self, download: Download) { - self.downloads.add(download); - } - pub fn remove_download(&mut self, token: u32) { - self.downloads.remove(token); - } - #[must_use] - pub fn get_download_by_token(&self, token: u32) -> Option<&Download> { - self.downloads.get_by_token(token) - } - pub fn get_download_by_token_mut( - &mut self, - token: u32, - ) -> Option<&mut Download> { - self.downloads.get_by_token_mut(token) - } - pub fn get_download_by_file_mut( - &mut self, - username: &str, - filename: &str, - ) -> Option<&mut Download> { - self.downloads.get_by_file_mut(username, filename) - } - #[must_use] - pub fn get_download_tokens(&self) -> Vec { - self.downloads.tokens() - } - #[must_use] - pub const fn get_downloads(&self) -> &Vec { - self.downloads.list() - } - pub fn update_download_with_status( - &mut self, - token: u32, - status: DownloadStatus, - ) { - self.downloads.update_status(token, status); - } - pub fn remove_queued_download_by_file( - &mut self, - username: &str, - filename: &str, - ) -> bool { - self.downloads.remove_queued_by_file(username, filename) - } -} -#[test] -fn test_client_context_downloads() { - let mut context = ClientContext::new(); - let token = 123; - let new_token = 1234; - let download = Download { - username: "test".to_string(), - filename: "test.txt".to_string(), - token, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::Queued, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - context.add_download(download); - assert!(context.get_download_by_token(123).is_some()); - assert_eq!(context.get_download_tokens(), vec![123]); - assert_eq!(context.get_downloads().len(), 1); - if let Some(download) = context.get_download_by_token_mut(token) { - assert_eq!(download.token, token); - download.token = new_token; - } - assert!(context.get_download_by_token(new_token).is_some()); - assert_eq!(context.get_download_tokens(), vec![new_token]); - context.remove_download(new_token); - assert_eq!(context.get_downloads().len(), 0); - assert!(context.get_download_by_token(1234).is_none()); -} - -#[test] -fn test_client_pause_and_resume_download() { - let client = Client::new("test-user", "test-password"); - let (download_sender, download_receiver) = mpsc::channel(); - let download = Download { - username: "peer".to_string(), - filename: "song.mp3".to_string(), - token: 123, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - sender: download_sender, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - - client.context.write().unwrap().add_download(download); - - assert!(client.pause_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - assert!(matches!( - download_receiver.try_recv().unwrap(), - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - - assert!(client.resume_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 0.0 - } - )); -} - -#[test] -fn download_without_a_connection_resolves_failed() { - // A client that never connected has no server handle and no peer registry, - // so it cannot open a connection to the peer: the download must resolve to - // Failed rather than hang Queued forever. - let client = Client::new("test-user", "test-password"); - let (_download, receiver) = client - .download( - "song.mp3".to_string(), - "peer".to_string(), - 100, - "test".to_string(), - ) - .expect("download() should return a handle"); - assert!(matches!( - receiver.recv_timeout(Duration::from_secs(1)), - Ok(DownloadStatus::Failed(_)) - )); -} - -#[test] -fn fail_queued_downloads_notifies_receiver_and_store() { - // When a brokered connect times out, every Queued download for the peer - // must resolve to Failed both on its channel and in the store. - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(Download { - username: "peer".to_string(), - filename: "f.mp3".to_string(), - token: 7, - size: 10, - download_directory: "d".to_string(), - status: DownloadStatus::Queued, - sender, - queue_position: None, - metadata: DownloadMetadata::default(), - }); - - Client::fail_queued_downloads(&client.context, "peer"); - - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(7) - .unwrap() - .status, - DownloadStatus::Failed(_) - )); -} - -#[test] -fn build_search_response_matches_shares_and_echoes_token() { - let dir = std::env::temp_dir() - .join(format!("soulseek-searchresp-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - let response = build_search_response(&shares, "me", 99, "xyzzy") - .expect("a matching share yields a response"); - let mut decoded = - crate::message::Message::new_with_data(response.get_buffer()); - decoded.set_pointer(8); - let result = SearchResult::new_from_message(&mut decoded).unwrap(); - assert_eq!(result.username, "me"); - assert_eq!(result.token, 99); - assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); - - assert!(build_search_response(&shares, "me", 1, "nomatch").is_none()); - let _ = std::fs::remove_dir_all(dir); -} - -#[test] -fn test_client_removes_only_queued_downloads() { - let client = Client::new("test-user", "test-password"); - let queued_download = Download { - username: "peer".to_string(), - filename: "queued.mp3".to_string(), - token: 123, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::Queued, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - let active_download = Download { - username: "peer".to_string(), - filename: "active.mp3".to_string(), - token: 456, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - - { - let mut context = client.context.write().unwrap(); - context.add_download(queued_download); - context.add_download(active_download); - } - - assert!(client.remove_queued_download("peer", "queued.mp3")); - assert!(!client.remove_queued_download("peer", "active.mp3")); - let context = client.context.read().unwrap(); - assert!(context.get_download_by_token(123).is_none()); - assert!(context.get_download_by_token(456).is_some()); -} - -impl ClientContext { - #[must_use] - pub fn new() -> Self { - let max_threads = - thread::available_parallelism().map_or(8, std::num::NonZero::get); - - let thread_pool = Arc::new(ThreadPool::new(max_threads)); - let actor_system = Arc::new(ActorSystem::new(thread_pool)); - - Self { - peer_registry: None, - sender: None, - server_sender: None, - searches: HashMap::new(), - private_messages: Vec::new(), - pending_connect_tokens: HashMap::new(), - shares: Arc::new(Shares::empty()), - peer_addresses: HashMap::new(), - pending_peer_messages: HashMap::new(), - uploads: HashMap::new(), - pending_serves: HashMap::new(), - browse_results: HashMap::new(), - room_list: Vec::new(), - room_events: Vec::new(), - downloads: DownloadStore::new(), - actor_system, - } - } - - /// Apply a chat-room event: keep the room-list snapshot current and queue - /// the event for the client/UI to drain. - pub fn apply_room_event(&mut self, event: RoomEvent) { - if let RoomEvent::List(rooms) = &event { - self.room_list.clone_from(rooms); - } - self.room_events.push(event); - } - - /// The latest snapshot of the public chat-room list. - #[must_use] - pub fn room_list(&self) -> Vec { - self.room_list.clone() - } - - /// Remove and return all chat-room events received since the last call. - #[must_use] - pub fn take_room_events(&mut self) -> Vec { - std::mem::take(&mut self.room_events) - } - - /// Cache a peer's listen address learned from a GetPeerAddress response. - pub fn cache_peer_address( - &mut self, - username: &str, - host: String, - port: u32, - ) { - self.peer_addresses - .insert(username.to_string(), (host, port)); - } - - /// The cached listen address for `username`, if known. - #[must_use] - pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { - self.peer_addresses.get(username).cloned() - } - - /// Queue a peer message to send once a control connection to `username` is up. - pub fn queue_peer_message( - &mut self, - username: &str, - message: crate::message::Message, - ) { - self.pending_peer_messages - .entry(username.to_string()) - .or_default() - .push(message); - } - - /// Remove and return the messages queued for `username`. - pub fn take_peer_messages( - &mut self, - username: &str, - ) -> Vec { - self.pending_peer_messages - .remove(username) - .unwrap_or_default() - } - - /// Store a shared-file listing received from browsing `username`. - pub fn store_browse_result( - &mut self, - username: String, - directories: Vec, - ) { - self.browse_results.insert(username, directories); - } - - /// Remove and return the shared-file listing browsed from `username`. - pub fn take_browse_result( - &mut self, - username: &str, - ) -> Option> { - self.browse_results.remove(username) - } - - /// Remember that a server-brokered connection to `username` is pending under - /// `token`; the peer will quote it back in a PierceFirewall. - pub fn add_pending_connect(&mut self, token: u32, username: String) { - self.pending_connect_tokens.insert(token, username); - } - - /// Resolve and consume the peer expected for a brokered connection `token`. - pub fn take_pending_connect(&mut self, token: u32) -> Option { - self.pending_connect_tokens.remove(&token) - } - - /// Record a private message received from another user. - pub fn push_private_message(&mut self, message: UserMessage) { - self.private_messages.push(message); - } - - /// Remove and return all buffered private messages. - pub fn take_private_messages(&mut self) -> Vec { - std::mem::take(&mut self.private_messages) - } -} -pub struct Client { - enable_listen: bool, - listen_port: u16, - address: PeerAddress, - username: String, - password: String, - shared_directory: Option, - server_handle: Option>, - context: Arc>, -} impl Client { - pub fn new( - username: impl Into, - password: impl Into, - ) -> Self { - Self::with_settings(ClientSettings::new(username, password)) - } - - #[must_use] - pub fn with_settings(settings: ClientSettings) -> Self { - logger::init(); - Self { - enable_listen: settings.enable_listen, - listen_port: settings.listen_port, - address: settings.server_address, - username: settings.username, - password: settings.password, - shared_directory: settings.shared_directory, - context: Arc::new(RwLock::new(ClientContext::new())), - server_handle: None, - } - } - - /// The directory whose files are shared with other peers, if configured. - #[must_use] - pub fn shared_directory(&self) -> Option<&str> { - self.shared_directory.as_deref() - } - - pub fn connect(&mut self) -> Result<()> { - let (sender, message_reader): ( - Sender, - Receiver, - ) = mpsc::channel(); - - let mut ctx = self.context.write_safe()?; - ctx.sender = Some(sender.clone()); - let peer_registry = PeerRegistry::new( - ctx.actor_system.clone(), - sender.clone(), - self.username.clone(), - ); - ctx.peer_registry = Some(peer_registry); - - let listen_sender = sender.clone(); - - // Scan the shared directory once into the read-only index, and report - // the real folder/file counts to the server on login. - let shares = match self.shared_directory.as_deref() { - Some(dir) if !dir.trim().is_empty() => { - match Shares::scan(std::path::Path::new(dir)) { - Ok(scanned) => { - info!( - "Sharing {} files in {} folders from {}", - scanned.file_count(), - scanned.folder_count(), - dir - ); - Arc::new(scanned) - } - Err(e) => { - warn!("Failed to scan shared directory {}: {}", dir, e); - Arc::new(Shares::empty()) - } - } - } - _ => Arc::new(Shares::empty()), - }; - let shared_folder_count = shares.folder_count(); - let shared_file_count = shares.file_count(); - ctx.shares = shares; - - let server_actor = ServerActor::new( - self.address.clone(), - sender, - self.listen_port, - self.enable_listen, - shared_folder_count, - shared_file_count, - ); - - self.server_handle = Some(ctx.actor_system.spawn_with_handle( - server_actor, - |actor, handle| { - actor.set_self_handle(handle); - }, - )); - - if self.enable_listen { - let listen_port = self.listen_port; - let client_sender = listen_sender; - let context = self.context.clone(); - let own_username = self.username.clone(); - - thread::spawn(move || { - Listen::start( - listen_port, - client_sender, - context, - own_username, - ); - }); - } - - Self::listen_to_client_operations( - message_reader, - self.context.clone(), - self.username.clone(), - ); - - Ok(()) - } - - pub fn login(&self) -> Result { - info!("Logging in as {}", self.username); - if let Some(handle) = &self.server_handle { - let (tx, rx) = std::sync::mpsc::channel(); - let _ = handle.send(ServerMessage::Login { - username: self.username.clone(), - password: self.password.clone(), - response: tx, - }); - - match rx.recv() { - Ok(result) => result, - Err(_) => Err(SoulseekRs::Timeout), - } - } else { - Err(SoulseekRs::NotConnected) - } - } - - /// Send a private message to another user via the server. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn send_private_message( - &self, - username: &str, - message: &str, - ) -> Result<()> { - let handle = self - .server_handle - .as_ref() - .ok_or(SoulseekRs::NotConnected)?; - let msg = crate::message::server::MessageFactory::build_message_user( - username, message, - ); - handle - .send(ServerMessage::SendMessage(msg)) - .map_err(|_| SoulseekRs::NotConnected)?; - Ok(()) - } - - /// Send a raw server message via the server actor, mapping a dead channel - /// to [`SoulseekRs::NotConnected`]. - fn send_server_message( - &self, - message: crate::message::Message, - ) -> Result<()> { - self.server_handle - .as_ref() - .ok_or(SoulseekRs::NotConnected)? - .send(ServerMessage::SendMessage(message)) - .map_err(|_| SoulseekRs::NotConnected)?; - Ok(()) - } - - /// Ask the server for the list of public chat rooms. The response arrives - /// asynchronously; read it with [`Client::room_list`] or by draining - /// [`Client::take_room_events`] for a [`RoomEvent::List`]. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn request_room_list(&self) -> Result<()> { - self.send_server_message( - crate::message::server::MessageFactory::build_room_list_request(), - ) - } - - /// Join a public chat room. The membership list and subsequent messages - /// arrive via [`Client::take_room_events`]. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn join_room(&self, room: &str) -> Result<()> { - self.send_server_message( - crate::message::server::MessageFactory::build_join_room( - room, false, - ), - ) - } - - /// Leave a chat room previously joined with [`Client::join_room`]. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn leave_room(&self, room: &str) -> Result<()> { - self.send_server_message( - crate::message::server::MessageFactory::build_leave_room(room), - ) - } - - /// Say `message` in chat room `room`. The server echoes it back as a - /// [`RoomEvent::Message`], so the UI should render from that echo rather - /// than optimistically. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> { - self.send_server_message( - crate::message::server::MessageFactory::build_say_chatroom( - room, message, - ), - ) - } - - /// The latest snapshot of the public chat-room list. - #[must_use] - pub fn room_list(&self) -> Vec { - match self.context.read_safe() { - Ok(ctx) => ctx.room_list(), - Err(e) => { - error!("[client] room_list: {}", e); - Vec::new() - } - } - } - - /// Remove and return all chat-room events received since the last call. - #[must_use] - pub fn take_room_events(&self) -> Vec { - match self.context.write_safe() { - Ok(mut ctx) => ctx.take_room_events(), - Err(e) => { - error!("[client] take_room_events: {}", e); - Vec::new() - } - } - } - - /// Request a peer's shared-file listing. When it arrives it can be - /// retrieved with [`Client::take_browse_result`]. - /// - /// # Errors - /// Returns an error if the client's context lock is poisoned. - pub fn browse_user(&self, username: &str) -> Result<()> { - let request = - crate::message::server::MessageFactory::build_get_share_file_list(); - let (connected, registry) = { - let ctx = self.context.read_safe()?; - ( - ctx.peer_registry - .as_ref() - .is_some_and(|r| r.contains(username)), - ctx.peer_registry.clone(), - ) - }; - if connected { - if let Some(registry) = registry { - let _ = registry - .send_to_peer(username, PeerMessage::SendMessage(request)); - } - } else { - self.context - .write_safe()? - .queue_peer_message(username, request); - if let Some(handle) = &self.server_handle { - let _ = handle - .send(ServerMessage::GetPeerAddress(username.to_string())); - } - } - Ok(()) - } - - /// Remove and return a peer's shared-file listing requested via - /// [`Client::browse_user`], if it has arrived. - #[must_use] - pub fn take_browse_result( - &self, - username: &str, - ) -> Option> { - self.context - .write_safe() - .ok() - .and_then(|mut ctx| ctx.take_browse_result(username)) - } - - /// Ask the server for a peer's address and open a direct control - /// connection to it. Downloads queued for that peer are sent automatically - /// once the connection is established. - /// - /// # Errors - /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. - pub fn connect_peer(&self, username: &str) -> Result<()> { - let handle = self - .server_handle - .as_ref() - .ok_or(SoulseekRs::NotConnected)?; - handle - .send(ServerMessage::GetPeerAddress(username.to_string())) - .map_err(|_| SoulseekRs::NotConnected)?; - Ok(()) - } - - /// Remove and return all private messages received since the last call. - #[must_use] - pub fn take_private_messages(&self) -> Vec { - match self.context.write_safe() { - Ok(mut ctx) => ctx.take_private_messages(), - Err(e) => { - error!("[client] take_private_messages: {}", e); - Vec::new() - } - } - } - - #[allow(dead_code)] - pub fn remove_peer(&self, username: &str) { - let context = match self.context.read_safe() { - Ok(c) => c, - Err(e) => { - error!("[client] remove_peer: {}", e); - return; - } - }; - if let Some(ref registry) = context.peer_registry - && let Some(handle) = registry.remove_peer(username) - { - let _ = handle.stop(); - } - } - - pub fn search( - &self, - query: &str, - timeout: Duration, - ) -> Result> { - self.search_with_cancel(query, timeout, None) - } - - pub fn search_with_cancel( - &self, - query: &str, - timeout: Duration, - cancel_flag: Option>, - ) -> Result> { - info!("Searching for {}", query); - - if let Some(handle) = &self.server_handle { - let hash = md5::md5(query); - let token = u32::from_str_radix(&hash[0..5], 16)?; - - // Initialize new search with query string as key - self.context.write_safe()?.searches.insert( - query.to_string(), - Search { - token, - results: Vec::new(), - }, - ); - - let _ = handle.send(ServerMessage::FileSearch { - token, - query: query.to_string(), - }); - } else { - return Err(SoulseekRs::NotConnected); - } - - let start = Instant::now(); - loop { - sleep(Duration::from_millis(100)); - - // Check if cancelled - if let Some(ref flag) = cancel_flag - && flag.load(Ordering::Relaxed) - { - info!("Search cancelled by user"); - break; - } - - // Check if timeout reached - if start.elapsed() >= timeout { - break; - } - } - - Ok(self.get_search_results(query)) - } - - #[must_use] - pub fn get_search_results_count(&self, search_key: &str) -> usize { - self.context - .read_safe() - .ok() - .and_then(|ctx| { - ctx.searches.get(search_key).map(|s| s.results.len()) - }) - .unwrap_or(0) - } - - #[must_use] - pub fn get_search_results(&self, search_key: &str) -> Vec { - self.context - .read_safe() - .ok() - .and_then(|ctx| { - ctx.searches.get(search_key).map(|s| s.results.clone()) - }) - .unwrap_or_default() - } - - /// Non-blocking variant that returns None if the lock is unavailable - #[must_use] - pub fn try_get_search_results( - &self, - search_key: &str, - ) -> Option> { - self.context.try_read().ok().and_then(|ctx| { - ctx.searches.get(search_key).map(|s| s.results.clone()) - }) - } - - #[must_use] - pub fn get_all_searches(&self) -> HashMap { - self.context - .read_safe() - .map(|ctx| ctx.searches.clone()) - .unwrap_or_default() - } - - #[must_use] - pub fn get_all_downloads(&self) -> Vec { - self.context - .read_safe() - .map(|ctx| ctx.get_downloads().clone()) - .unwrap_or_default() - } - - #[must_use] - pub fn pause_download(&self, username: &str, filename: &str) -> bool { - match self.context.write_safe() { - Ok(mut ctx) => ctx.downloads.pause_by_file(username, filename), - Err(e) => { - error!("[client] pause_download: {}", e); - false - } - } - } - - #[must_use] - pub fn resume_download(&self, username: &str, filename: &str) -> bool { - match self.context.write_safe() { - Ok(mut ctx) => ctx.downloads.resume_by_file(username, filename), - Err(e) => { - error!("[client] resume_download: {}", e); - false - } - } - } - - #[must_use] - pub fn remove_queued_download( - &self, - username: &str, - filename: &str, - ) -> bool { - match self.context.write_safe() { - Ok(mut ctx) => { - ctx.downloads.remove_queued_by_file(username, filename) - } - Err(e) => { - error!("[client] remove_queued_download: {}", e); - false - } - } - } - - /// Remove every download for `username`/`filename` regardless of status. - /// Call this before re-issuing [`Client::download`] for a failed download, - /// otherwise the stale entry (whose md5-derived token equals the retry's) - /// shadows the fresh one and its completion is misrouted. - /// - /// Returns whether anything was removed. - #[must_use] - pub fn remove_download(&self, username: &str, filename: &str) -> bool { - match self.context.write_safe() { - Ok(mut ctx) => ctx.downloads.remove_by_file(username, filename), - Err(e) => { - error!("[client] remove_download: {}", e); - false - } - } - } - - pub fn download( - &self, - filename: String, - username: String, - size: u64, - download_directory: String, - ) -> Result<(Download, Receiver)> { - self.download_with_metadata( - filename, - username, - size, - download_directory, - DownloadMetadata::default(), - ) - } - - pub fn download_with_metadata( - &self, - filename: String, - username: String, - size: u64, - download_directory: String, - metadata: DownloadMetadata, - ) -> Result<(Download, Receiver)> { - info!("[client] Downloading {} from {}", filename, username); - - let hash = md5::md5(&filename); - let token = u32::from_str_radix(&hash[0..5], 16)?; - - let (download_sender, download_receiver): ( - Sender, - Receiver, - ) = mpsc::channel(); - - let download = Download { - username: username.clone(), - filename, - token, - size, - download_directory, - status: DownloadStatus::Queued, - sender: download_sender, - queue_position: None, - metadata, - }; - - let mut context = self.context.write_safe()?; - context.add_download(download.clone()); - - // If we already have a control connection to this peer, queue the - // upload immediately. Otherwise open one directly (server GetPeerAddress - // → outbound PeerInit → PeerConnected → the queued upload is flushed). - let peer_registered = context - .peer_registry - .as_ref() - .is_some_and(|r| r.contains(&username)); - let queued_now = peer_registered - && context.peer_registry.as_ref().is_some_and(|r| { - r.queue_upload(&username, download.filename.clone()).is_ok() - }); - - drop(context); - - let failed = if peer_registered { - !queued_now - } else { - // No existing connection: initiate one. Only a genuinely - // unconnected client (no server handle) fails outright here. - self.connect_peer(&username).is_err() - }; - - if failed { - let reason = if peer_registered { - "The user declined the download" - } else { - "Could not connect to the user" - }; - let _ = download - .sender - .send(DownloadStatus::Failed(Some(reason.to_string()))); - self.context.write_safe()?.update_download_with_status( - token, - DownloadStatus::Failed(Some(reason.to_string())), - ); - } - - Ok((download, download_receiver)) - } - - /// Fail every still-`Queued` download for `username`, both on the caller's - /// status channel (so a blocked `Receiver` unblocks) and in the store. - fn fail_queued_downloads( - client_context: &Arc>, - username: &str, - ) { - let mut context = match client_context.write_safe() { - Ok(c) => c, - Err(e) => { - error!("[client] fail_queued_downloads write: {}", e); - return; - } - }; - let doomed: Vec<(u32, Sender)> = context - .get_downloads() - .iter() - .filter(|d| { - d.username == username - && matches!(d.status, DownloadStatus::Queued) - }) - .map(|d| (d.token, d.sender.clone())) - .collect(); - for (token, sender) in doomed { - let reason = Some("The user went offline".to_string()); - let _ = sender.send(DownloadStatus::Failed(reason.clone())); - context.update_download_with_status( - token, - DownloadStatus::Failed(reason), - ); - } - } - - /// Consume the upload job for `token` and stream the file to `host:port` - /// on a background thread. - fn spawn_serve( - client_context: &Arc>, - own_username: &str, - token: u32, - host: String, - port: u32, - ) { - let Ok(mut ctx) = client_context.write_safe() else { - return; - }; - let Some(job) = ctx.uploads.remove(&token) else { - return; - }; - drop(ctx); - let own = own_username.to_string(); - let real_path = job.real_path; - thread::spawn(move || { - if let Err(e) = crate::peer::upload_peer::serve_file( - &host, port, &own, token, &real_path, - ) { - error!("[client] serve {}: {}", real_path.display(), e); - } - }); - } - - fn process_failed_uploads( - client_context: Arc>, - username: &str, - filename: Option<&str>, - ) { - let failed_tokens = match client_context.read_safe() { - Ok(context) => { - collect_failed_tokens(&context.downloads, username, filename) - } - Err(e) => { - error!("[client] process_failed_uploads read: {}", e); - return; - } - }; - - if failed_tokens.is_empty() { - return; - } - - match client_context.write_safe() { - Ok(mut context) => { - for token in failed_tokens { - context.downloads.update_status( - token, - DownloadStatus::Failed(Some( - "The upload failed on the other side".to_string(), - )), - ); - context.downloads.remove(token); - } - } - Err(e) => { - error!("[client] process_failed_uploads write: {}", e); - } - } - } - - fn listen_to_client_operations( + pub(crate) fn listen_to_client_operations( reader: Receiver, client_context: Arc>, own_username: String, @@ -2153,122 +857,4 @@ impl Client { } }); } - - fn connect_to_peer( - peer: Peer, - client_context: Arc>, - own_username: String, - stream: Option, - ) { - let client_context = client_context; - - let peer_clone = peer.clone(); - trace!( - "[client] connecting to {}, with connection_type: {}, and token {:?}", - peer.username, peer.connection_type, peer.token - ); - match peer.connection_type { - ConnectionType::P => { - let username = peer.username; - - let context = match client_context.read_safe() { - Ok(c) => c, - Err(e) => { - error!("[client] connect_to_peer read: {}", e); - return; - } - }; - if let Some(ref registry) = context.peer_registry { - match registry.register_peer(peer_clone, stream, None) { - Ok(_) => (), - Err(e) => { - trace!( - "Failed to spawn peer actor for {:?}: {:?}", - username, e - ); - } - } - } else { - trace!("PeerRegistry not initialized"); - } - } - - ConnectionType::F => { - trace!( - "[client] downloading from: {}, {:?}", - peer.username, peer.token - ); - let download_peer = DownloadPeer::new( - peer.username, - peer.host, - peer.port, - peer.token.unwrap(), - false, - own_username, - ); - - match download_peer.download_file( - client_context.clone(), - None, - None, - ) { - Ok((download, filename)) => { - trace!( - "[client] downloaded {} bytes {:?} ", - filename, download.size - ); - let _ = download.sender.send(DownloadStatus::Completed); - match client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => error!( - "[client] connect_to_peer F write: {}", - e - ), - } - } - Err(e) => { - trace!("[client] failed to download: {}", e); - } - } - } - ConnectionType::D => { - error!("ConnectionType::D not implemented"); - } - } - } - fn pierce_firewall( - peer: Peer, - client_context: Arc>, - own_username: String, - ) { - debug!("Piercing firewall for peer: {:?}", peer); - - let context = match client_context.read_safe() { - Ok(c) => c, - Err(e) => { - error!("[client] pierce_firewall read: {}", e); - return; - } - }; - if let Some(server_sender) = &context.server_sender { - if let Some(token) = peer.token { - match server_sender.send(ServerMessage::PierceFirewall(token)) { - Ok(()) => (), - Err(e) => { - error!("Failed to send PierceFirewall message: {}", e); - } - } - } else { - error!("No token available for PierceFirewall"); - } - } else { - error!("No server sender available for PierceFirewall"); - } - - drop(context); - Self::connect_to_peer(peer, client_context, own_username, None); - } } diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs new file mode 100644 index 0000000..e7fbb65 --- /dev/null +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -0,0 +1,174 @@ +use super::{ + Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage, + SharedDirectory, SoulseekRs, UserMessage, error, +}; + +impl Client { + /// Send a private message to another user via the server. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn send_private_message( + &self, + username: &str, + message: &str, + ) -> Result<()> { + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; + let msg = crate::message::server::MessageFactory::build_message_user( + username, message, + ); + handle + .send(ServerMessage::SendMessage(msg)) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + /// Send a raw server message via the server actor, mapping a dead channel + /// to [`SoulseekRs::NotConnected`]. + fn send_server_message( + &self, + message: crate::message::Message, + ) -> Result<()> { + self.server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)? + .send(ServerMessage::SendMessage(message)) + .map_err(|_| SoulseekRs::NotConnected)?; + Ok(()) + } + + /// Ask the server for the list of public chat rooms. The response arrives + /// asynchronously; read it with [`Client::room_list`] or by draining + /// [`Client::take_room_events`] for a [`RoomEvent::List`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn request_room_list(&self) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_room_list_request(), + ) + } + + /// Join a public chat room. The membership list and subsequent messages + /// arrive via [`Client::take_room_events`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn join_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_join_room( + room, false, + ), + ) + } + + /// Leave a chat room previously joined with [`Client::join_room`]. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn leave_room(&self, room: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_leave_room(room), + ) + } + + /// Say `message` in chat room `room`. The server echoes it back as a + /// [`RoomEvent::Message`], so the UI should render from that echo rather + /// than optimistically. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn say_in_room(&self, room: &str, message: &str) -> Result<()> { + self.send_server_message( + crate::message::server::MessageFactory::build_say_chatroom( + room, message, + ), + ) + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_list(), + Err(e) => { + error!("[client] room_list: {}", e); + Vec::new() + } + } + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&self) -> Vec { + match self.context.write_safe() { + Ok(mut ctx) => ctx.take_room_events(), + Err(e) => { + error!("[client] take_room_events: {}", e); + Vec::new() + } + } + } + + /// Request a peer's shared-file listing. When it arrives it can be + /// retrieved with [`Client::take_browse_result`]. + /// + /// # Errors + /// Returns an error if the client's context lock is poisoned. + pub fn browse_user(&self, username: &str) -> Result<()> { + let request = + crate::message::server::MessageFactory::build_get_share_file_list(); + let (connected, registry) = { + let ctx = self.context.read_safe()?; + ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(username)), + ctx.peer_registry.clone(), + ) + }; + if connected { + if let Some(registry) = registry { + let _ = registry + .send_to_peer(username, PeerMessage::SendMessage(request)); + } + } else { + self.context + .write_safe()? + .queue_peer_message(username, request); + if let Some(handle) = &self.server_handle { + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); + } + } + Ok(()) + } + + /// Remove and return a peer's shared-file listing requested via + /// [`Client::browse_user`], if it has arrived. + #[must_use] + pub fn take_browse_result( + &self, + username: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_browse_result(username)) + } + + /// Remove and return all private messages received since the last call. + #[must_use] + pub fn take_private_messages(&self) -> Vec { + match self.context.write_safe() { + Ok(mut ctx) => ctx.take_private_messages(), + Err(e) => { + error!("[client] take_private_messages: {}", e); + Vec::new() + } + } + } +} diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs new file mode 100644 index 0000000..63cbdef --- /dev/null +++ b/soulseek-rs-lib/src/client/search.rs @@ -0,0 +1,105 @@ +use super::{ + Arc, AtomicBool, Client, Duration, HashMap, Instant, Ordering, Result, + RwLockExt, Search, SearchResult, ServerMessage, SoulseekRs, info, md5, + sleep, +}; + +impl Client { + pub fn search( + &self, + query: &str, + timeout: Duration, + ) -> Result> { + self.search_with_cancel(query, timeout, None) + } + + pub fn search_with_cancel( + &self, + query: &str, + timeout: Duration, + cancel_flag: Option>, + ) -> Result> { + info!("Searching for {}", query); + + if let Some(handle) = &self.server_handle { + let hash = md5::md5(query); + let token = u32::from_str_radix(&hash[0..5], 16)?; + + self.context.write_safe()?.searches.insert( + query.to_string(), + Search { + token, + results: Vec::new(), + }, + ); + + let _ = handle.send(ServerMessage::FileSearch { + token, + query: query.to_string(), + }); + } else { + return Err(SoulseekRs::NotConnected); + } + + let start = Instant::now(); + loop { + sleep(Duration::from_millis(100)); + + // Check if cancelled + if let Some(ref flag) = cancel_flag + && flag.load(Ordering::Relaxed) + { + info!("Search cancelled by user"); + break; + } + + // Check if timeout reached + if start.elapsed() >= timeout { + break; + } + } + + Ok(self.get_search_results(query)) + } + + #[must_use] + pub fn get_search_results_count(&self, search_key: &str) -> usize { + self.context + .read_safe() + .ok() + .and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.len()) + }) + .unwrap_or(0) + } + + #[must_use] + pub fn get_search_results(&self, search_key: &str) -> Vec { + self.context + .read_safe() + .ok() + .and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.clone()) + }) + .unwrap_or_default() + } + + /// Non-blocking variant that returns None if the lock is unavailable + #[must_use] + pub fn try_get_search_results( + &self, + search_key: &str, + ) -> Option> { + self.context.try_read().ok().and_then(|ctx| { + ctx.searches.get(search_key).map(|s| s.results.clone()) + }) + } + + #[must_use] + pub fn get_all_searches(&self) -> HashMap { + self.context + .read_safe() + .map(|ctx| ctx.searches.clone()) + .unwrap_or_default() + } +} diff --git a/soulseek-rs-lib/src/client/uploads.rs b/soulseek-rs-lib/src/client/uploads.rs new file mode 100644 index 0000000..5aa65f2 --- /dev/null +++ b/soulseek-rs-lib/src/client/uploads.rs @@ -0,0 +1,70 @@ +use super::{ + Arc, Client, ClientContext, DownloadStatus, RwLock, RwLockExt, + collect_failed_tokens, error, thread, +}; + +impl Client { + /// Consume the upload job for `token` and stream the file to `host:port` + /// on a background thread. + pub(crate) fn spawn_serve( + client_context: &Arc>, + own_username: &str, + token: u32, + host: String, + port: u32, + ) { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + let Some(job) = ctx.uploads.remove(&token) else { + return; + }; + drop(ctx); + let own = own_username.to_string(); + let real_path = job.real_path; + thread::spawn(move || { + if let Err(e) = crate::peer::upload_peer::serve_file( + &host, port, &own, token, &real_path, + ) { + error!("[client] serve {}: {}", real_path.display(), e); + } + }); + } + + pub(crate) fn process_failed_uploads( + client_context: Arc>, + username: &str, + filename: Option<&str>, + ) { + let failed_tokens = match client_context.read_safe() { + Ok(context) => { + collect_failed_tokens(&context.downloads, username, filename) + } + Err(e) => { + error!("[client] process_failed_uploads read: {}", e); + return; + } + }; + + if failed_tokens.is_empty() { + return; + } + + match client_context.write_safe() { + Ok(mut context) => { + for token in failed_tokens { + context.downloads.update_status( + token, + DownloadStatus::Failed(Some( + "The upload failed on the other side".to_string(), + )), + ); + context.downloads.remove(token); + } + } + Err(e) => { + error!("[client] process_failed_uploads write: {}", e); + } + } + } +} diff --git a/soulseek-rs-lib/src/message/mod.rs b/soulseek-rs-lib/src/message/mod.rs index bf2bcca..241161d 100644 --- a/soulseek-rs-lib/src/message/mod.rs +++ b/soulseek-rs-lib/src/message/mod.rs @@ -57,10 +57,8 @@ impl Message { let chunks = data.chunks(BYTES_PER_LINE); for (i, chunk) in chunks.enumerate() { - // Print the offset print!("{:04x} ", i * BYTES_PER_LINE); - // Print the hexadecimal part for j in 0..BYTES_PER_LINE { if j < chunk.len() { print!("{:02x} ", chunk[j]); @@ -76,7 +74,6 @@ impl Message { print!(" "); - // Print the ASCII part let mut i = 0; for &byte in chunk { i += 1; @@ -101,7 +98,7 @@ impl Message { if self.data.len() < 8 { return 0; } - u32::from_le_bytes(self.data[4..8].try_into().unwrap()) + self.data[4..8].try_into().map_or(0, u32::from_le_bytes) } #[must_use] diff --git a/soulseek-rs-lib/src/message/peer/file_search_response.rs b/soulseek-rs-lib/src/message/peer/file_search_response.rs index 0e80e1c..f6367e4 100644 --- a/soulseek-rs-lib/src/message/peer/file_search_response.rs +++ b/soulseek-rs-lib/src/message/peer/file_search_response.rs @@ -59,9 +59,7 @@ impl MessageHandler for FileSearchResponse { return; }; - sender - .send(PeerMessage::FileSearchResult(file_search)) - .unwrap(); + let _ = sender.send(PeerMessage::FileSearchResult(file_search)); } } diff --git a/soulseek-rs-lib/src/message/peer/peer_init.rs b/soulseek-rs-lib/src/message/peer/peer_init.rs index 1071150..7960098 100644 --- a/soulseek-rs-lib/src/message/peer/peer_init.rs +++ b/soulseek-rs-lib/src/message/peer/peer_init.rs @@ -23,6 +23,6 @@ impl MessageHandler for PeerInit { username, connection_type, token ); - sender.send(PeerMessage::SetUsername(username)).unwrap(); + let _ = sender.send(PeerMessage::SetUsername(username)); } } diff --git a/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs b/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs index ab9c4cf..9a372c5 100644 --- a/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs +++ b/soulseek-rs-lib/src/message/peer/place_in_queue_response.rs @@ -15,8 +15,7 @@ impl MessageHandler for PlaceInQueueResponse { let filename = message.read_string(); let place = message.read_int32(); - sender - .send(PeerMessage::PlaceInQueueResponse { filename, place }) - .unwrap(); + let _ = + sender.send(PeerMessage::PlaceInQueueResponse { filename, place }); } } diff --git a/soulseek-rs-lib/src/message/peer/transfer_request.rs b/soulseek-rs-lib/src/message/peer/transfer_request.rs index a4d7499..f431fe0 100644 --- a/soulseek-rs-lib/src/message/peer/transfer_request.rs +++ b/soulseek-rs-lib/src/message/peer/transfer_request.rs @@ -13,6 +13,6 @@ impl MessageHandler for TransferRequest { fn handle(&self, message: &mut Message, sender: Sender) { let transfer = Transfer::new_from_message(message); - sender.send(PeerMessage::TransferRequest(transfer)).unwrap(); + let _ = sender.send(PeerMessage::TransferRequest(transfer)); } } diff --git a/soulseek-rs-lib/src/message/peer/transfer_response.rs b/soulseek-rs-lib/src/message/peer/transfer_response.rs index 7191b09..699b492 100644 --- a/soulseek-rs-lib/src/message/peer/transfer_response.rs +++ b/soulseek-rs-lib/src/message/peer/transfer_response.rs @@ -16,12 +16,10 @@ impl MessageHandler for TransferResponse { let allowed = message.read_int8(); let reason = (allowed == 0).then(|| message.read_string()); - sender - .send(PeerMessage::TransferResponse { - token, - allowed: allowed == 1, - reason, - }) - .unwrap(); + let _ = sender.send(PeerMessage::TransferResponse { + token, + allowed: allowed == 1, + reason, + }); } } diff --git a/soulseek-rs-lib/src/message/server/get_peer_address.rs b/soulseek-rs-lib/src/message/server/get_peer_address.rs index 426bfa0..7ea77a4 100644 --- a/soulseek-rs-lib/src/message/server/get_peer_address.rs +++ b/soulseek-rs-lib/src/message/server/get_peer_address.rs @@ -24,14 +24,12 @@ impl MessageHandler for GetPeerAddressHandler { let obfuscated_port = message.read_int32() as u16; crate::debug!("GetPeerAddressHandler: {username:?}"); - sender - .send(ServerMessage::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - }) - .unwrap(); + let _ = sender.send(ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }); } } diff --git a/soulseek-rs-lib/src/message/server/login.rs b/soulseek-rs-lib/src/message/server/login.rs index 8424709..0248503 100644 --- a/soulseek-rs-lib/src/message/server/login.rs +++ b/soulseek-rs-lib/src/message/server/login.rs @@ -16,13 +16,14 @@ impl MessageHandler for LoginHandler { let response = message.read_int8(); if response != 1 { - return sender.send(ServerMessage::LoginStatus(false)).unwrap(); + let _ = sender.send(ServerMessage::LoginStatus(false)); + return; } info!("Login successful"); let greeting = message.read_string(); debug!("Server greeting: {:?}", greeting); - sender.send(ServerMessage::LoginStatus(true)).unwrap(); + let _ = sender.send(ServerMessage::LoginStatus(true)); } } diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 380757c..7212c60 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -380,10 +380,9 @@ impl DownloadPeer { status: DownloadStatus, ) { let _ = download.sender.send(status.clone()); - client_context - .write() - .unwrap() - .update_download_with_status(download.token, status); + if let Ok(mut context) = client_context.write() { + context.update_download_with_status(download.token, status); + } } fn wait_while_paused( @@ -491,7 +490,7 @@ impl DownloadPeer { let _ = dl.sender.send(DownloadStatus::Queued); client_context .write() - .unwrap() + .map_err(|_| DownloadError::LockPoisoned)? .update_download_with_status(dl.token, DownloadStatus::Queued); } diff --git a/soulseek-rs-lib/src/utils/logger.rs b/soulseek-rs-lib/src/utils/logger.rs index 3a00f36..03eaad7 100644 --- a/soulseek-rs-lib/src/utils/logger.rs +++ b/soulseek-rs-lib/src/utils/logger.rs @@ -96,7 +96,9 @@ pub fn log(level: LogLevel, message: &str) { }; let now = std::time::SystemTime::now(); - let datetime = now.duration_since(std::time::UNIX_EPOCH).unwrap(); + let datetime = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); let secs = datetime.as_secs(); let subsec_millis = datetime.subsec_millis(); diff --git a/soulseek-rs-lib/src/utils/md5.rs b/soulseek-rs-lib/src/utils/md5.rs index c06becd..fd427de 100644 --- a/soulseek-rs-lib/src/utils/md5.rs +++ b/soulseek-rs-lib/src/utils/md5.rs @@ -297,7 +297,6 @@ fn compute_md5_digest(mut v: Vec) -> String { let word_cc = word_c; let word_dd = word_d; - // execute round 1 let result = round_one_operations(word_a, word_b, word_c, word_d, &table, &x); word_a = result[0]; @@ -305,7 +304,6 @@ fn compute_md5_digest(mut v: Vec) -> String { word_c = result[2]; word_d = result[3]; - // execute round 2 let result = round_two_operations(word_a, word_b, word_c, word_d, &table, &x); @@ -314,7 +312,6 @@ fn compute_md5_digest(mut v: Vec) -> String { word_c = result[2]; word_d = result[3]; - // execute round 3 let result = round_three_operations(word_a, word_b, word_c, word_d, &table, &x); word_a = result[0]; @@ -322,7 +319,6 @@ fn compute_md5_digest(mut v: Vec) -> String { word_c = result[2]; word_d = result[3]; - // execute round 4 let result = round_four_operations(word_a, word_b, word_c, word_d, &table, &x); word_a = result[0]; diff --git a/soulseek-rs-lib/src/utils/zlib.rs b/soulseek-rs-lib/src/utils/zlib.rs index a434602..3a16606 100644 --- a/soulseek-rs-lib/src/utils/zlib.rs +++ b/soulseek-rs-lib/src/utils/zlib.rs @@ -184,17 +184,12 @@ impl HuffmanTree { let mut node = &mut self.root; for i in (0..n).rev() { let b = (codeword >> i) & 1; - if b != 0 { - if node.right.is_none() { - node.right = Some(Box::new(Node::new())); - } - node = node.right.as_mut().unwrap(); + let child = if b != 0 { + &mut node.right } else { - if node.left.is_none() { - node.left = Some(Box::new(Node::new())); - } - node = node.left.as_mut().unwrap(); - } + &mut node.left + }; + node = child.get_or_insert_with(|| Box::new(Node::new())); } node.symbol = Some(symbol); } diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 2986365..b2b2448 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -23,23 +23,7 @@ fn main() -> Result<()> { let cli = Cli::parse(); - let log_level = match cli.verbose { - 0 => "ERROR", - 1 => "WARN", - 2 => "INFO", - 3 => "DEBUG", - _ => "TRACE", - }; - // SAFETY: Called before any threads are spawned - unsafe { env::set_var("LOG_LEVEL", log_level) }; - - // Set LOG_FILE env var if provided via CLI - if let Some(log_file) = &cli.log_file { - // SAFETY: Called before any threads are spawned - unsafe { - env::set_var("LOG_FILE", log_file.to_string_lossy().to_string()); - }; - } + init_logging(&cli); // `portmap` is a local network diagnostic; it needs no server credentials, // so handle it before requiring a username/password. @@ -78,6 +62,15 @@ fn main() -> Result<()> { } }; + let settings = ClientSettings { + username: username.clone(), + password: password.clone(), + server_address: PeerAddress::new(server_host.clone(), server_port), + enable_listen: !cli.disable_listener, + listen_port: cli.listener_port, + shared_directory: shared_directory.clone(), + }; + match cli.command { Some(Commands::Search { query, @@ -104,112 +97,93 @@ fn main() -> Result<()> { Some(Commands::Message { username: recipient, message, - }) => send_private_message( - &ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host, server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, - shared_directory, - }, - &recipient, - &message, - ), - Some(Commands::Browse { username: target }) => browse_user( - &ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host, server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, - shared_directory, - }, - &target, - ), - Some(Commands::Rooms) => list_rooms(&ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host, server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, - shared_directory, - }), + }) => send_private_message(&settings, &recipient, &message), + Some(Commands::Browse { username: target }) => { + browse_user(&settings, &target) + } + Some(Commands::Rooms) => list_rooms(&settings), Some(Commands::Chat { room, message, listen_secs, - }) => chat_room( - &ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host, server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, - shared_directory, - }, - &room, - message.as_deref(), - listen_secs, - ), + }) => chat_room(&settings, &room, message.as_deref(), listen_secs), // Handled before the credential check above. Some(Commands::Portmap) => unreachable!(), - None => { - use ratatui::crossterm::{ - event::EnableMouseCapture, - execute, - terminal::{Clear, ClearType}, - }; - - // Launch main TUI - // Enable logger buffering BEFORE connection to prevent log artifacts - soulseek_rs::utils::logger::enable_buffering(); - - let settings = ClientSettings { - username, - password, - server_address: PeerAddress::new(server_host, server_port), - enable_listen: !cli.disable_listener, - listen_port: cli.listener_port, - shared_directory, - }; - - // Best-effort: make ourselves reachable behind a home router so - // firewalled peers can connect back. Kept alive for the session. - let _port_mapper = settings - .enable_listen - .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); - - let mut client = Client::with_settings(settings); - client.connect().map_err(|e| { - color_eyre::eyre::eyre!("Failed to connect: {}", e) - })?; - client.login().map_err(|e| { - color_eyre::eyre::eyre!("Failed to login: {}", e) - })?; - - let client = Arc::new(client); - - // Clear screen and enable mouse capture before initializing TUI - let _ = execute!( - std::io::stdout(), - Clear(ClearType::All), - EnableMouseCapture - ); - - let terminal = ratatui::init(); - - launch_main_tui( - terminal, - client, - cli.download_dir, - cli.max_concurrent_downloads, - Duration::from_secs(cli.search_timeout), - ) - } + None => run_default_tui( + settings, + cli.download_dir, + cli.max_concurrent_downloads, + Duration::from_secs(cli.search_timeout), + ), } } +fn init_logging(cli: &Cli) { + let log_level = match cli.verbose { + 0 => "ERROR", + 1 => "WARN", + 2 => "INFO", + 3 => "DEBUG", + _ => "TRACE", + }; + // SAFETY: Called before any threads are spawned + unsafe { env::set_var("LOG_LEVEL", log_level) }; + + if let Some(log_file) = &cli.log_file { + // SAFETY: Called before any threads are spawned + unsafe { + env::set_var("LOG_FILE", log_file.to_string_lossy().to_string()); + }; + } +} + +/// Connect, log in, and run the interactive TUI (the default no-subcommand path). +fn run_default_tui( + settings: ClientSettings, + download_dir: String, + max_concurrent_downloads: usize, + search_timeout: Duration, +) -> Result<()> { + use ratatui::crossterm::{ + event::EnableMouseCapture, + execute, + terminal::{Clear, ClearType}, + }; + + // Enable logger buffering BEFORE connection to prevent log artifacts + soulseek_rs::utils::logger::enable_buffering(); + + // Best-effort: make ourselves reachable behind a home router so + // firewalled peers can connect back. Kept alive for the session. + let _port_mapper = settings + .enable_listen + .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); + + let mut client = Client::with_settings(settings); + client + .connect() + .map_err(|e| color_eyre::eyre::eyre!("Failed to connect: {}", e))?; + client + .login() + .map_err(|e| color_eyre::eyre::eyre!("Failed to login: {}", e))?; + + let client = Arc::new(client); + + // Clear screen and enable mouse capture before initializing TUI + let _ = + execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture); + + let terminal = ratatui::init(); + + launch_main_tui( + terminal, + client, + download_dir, + max_concurrent_downloads, + search_timeout, + ) +} + fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> { use std::time::Instant; @@ -420,10 +394,8 @@ fn search_and_download(config: SearchConfig) -> Result<()> { // Wrap client in Arc for sharing with FileSelector let client = Arc::new(client); - // Create cancel flag for search let cancel_flag = Arc::new(AtomicBool::new(false)); - // Start search in background thread let search_client = client.clone(); let search_query = config.query.clone(); let search_timeout = Duration::from_secs(config.timeout); @@ -450,7 +422,6 @@ fn search_and_download(config: SearchConfig) -> Result<()> { // Cancel search thread - no need to wait for it cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); - // Get final results let results = client.get_search_results(&config.query); if selected_indices.is_empty() { diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 28026bd..c20bf16 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -563,7 +563,6 @@ mod tests { fn browse_tabs_retry_active_only_when_timed_out() { let mut tabs = BrowseTabs::new(); tabs.open("alice"); - // Loading tab isn't retryable. assert_eq!(tabs.retry_active(), None); tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; // Timed-out tab retries and resets to loading. diff --git a/soulseek-rs/src/ui/download_selector.rs b/soulseek-rs/src/ui/download_selector.rs index 49adea6..6d8a466 100644 --- a/soulseek-rs/src/ui/download_selector.rs +++ b/soulseek-rs/src/ui/download_selector.rs @@ -85,7 +85,6 @@ impl FileSelector { terminal.draw(|frame| self.render(frame))?; self.spinner_state = (self.spinner_state + 1) % 10; - // Update spinner animation every 80ms self.last_spinner_update = Instant::now(); // Poll for new search results if active @@ -106,7 +105,6 @@ impl FileSelector { } } - // Check for keyboard input with timeout for polling let timeout = if self.search_active { Duration::from_millis(50) } else { @@ -144,7 +142,6 @@ impl FileSelector { } } - // Update items and reset state let len = new_items.len(); self.all_items = new_items.clone(); self.items = new_items; @@ -354,7 +351,21 @@ impl FileSelector { (None, chunks[1]) }; - let title = if self.search_active { + self.render_results_table(frame, table_area); + + if self.items.is_empty() { + self.render_loading_message(frame, table_area); + } + + if let Some(info_area) = info_area { + self.render_info(frame, info_area); + } + + self.render_controls(frame, controls_area); + } + + fn table_title(&self) -> String { + if self.search_active { // Live Soulseek search is active let spinner = get_spinner_char(self.spinner_state); let elapsed = self.search_start_time.elapsed().as_secs(); @@ -378,7 +389,15 @@ impl FileSelector { "Multi-select files to download ({} selected)", self.selected_indices.len() ) - }; + } + } + + fn render_results_table( + &mut self, + frame: &mut Frame, + table_area: ratatui::layout::Rect, + ) { + let title = self.table_title(); let header = Row::new(vec![ Cell::from(""), @@ -465,108 +484,109 @@ impl FileSelector { frame.buffer_mut(), &mut self.state, ); + } - if self.items.is_empty() { - let spinner = get_spinner_char(self.spinner_state); + fn render_loading_message( + &self, + frame: &mut Frame, + table_area: ratatui::layout::Rect, + ) { + let spinner = get_spinner_char(self.spinner_state); - let mut loading_message: Vec = Vec::new(); - if self.items.is_empty() && self.search_active { - loading_message.extend(vec![ - Span::raw(spinner), - Span::raw(" Searching for '"), - Span::styled(self.soulseek_query.clone(), primary_style()), - Span::raw(format!( - "' [{}/{}s]", - self.search_start_time.elapsed().as_secs(), - self.search_timeout.as_secs() - )), - ]); - } else { - loading_message.extend(vec![ - Span::raw(spinner), - Span::raw(" Searching for '"), - Span::styled(self.soulseek_query.clone(), primary_style()), - Span::raw("'; No results yet"), - ]); - } - - // Center the loading message - let vertical = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(3), - Constraint::Fill(1), - ]) - .split(table_area); - - let loading_message_line = Line::from(loading_message); - // Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen - let text_width = - loading_message_line.to_string().chars().count() as u16 + 5; // +10 for borders, padding, and safety margin - let max_width = (table_area.width * 80) / 100; - let widget_width = text_width.min(max_width); - - let horizontal = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Length(widget_width), - Constraint::Fill(1), - ]) - .split(vertical[1]); - - let loading_widget = Paragraph::new(loading_message_line) - .style(Style::default()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(primary_style()) - .border_type(border_type(false)), - ) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - frame.render_widget(loading_widget, horizontal[1]); + let mut loading_message: Vec = Vec::new(); + if self.items.is_empty() && self.search_active { + loading_message.extend(vec![ + Span::raw(spinner), + Span::raw(" Searching for '"), + Span::styled(self.soulseek_query.clone(), primary_style()), + Span::raw(format!( + "' [{}/{}s]", + self.search_start_time.elapsed().as_secs(), + self.search_timeout.as_secs() + )), + ]); + } else { + loading_message.extend(vec![ + Span::raw(spinner), + Span::raw(" Searching for '"), + Span::styled(self.soulseek_query.clone(), primary_style()), + Span::raw("'; No results yet"), + ]); } - if let Some(info_area) = info_area { - let (info_text, title, style) = if self.is_filtering { - ( - format!("Filter: {}", self.filter_query), - "Filter", - warning_style(), - ) - } else if !self.filter_query.is_empty() { - ( - format!( - "Current filter: {} (press / to modify, Esc to clear)", - self.filter_query - ), - "Filter", - primary_style(), - ) - } else { - ( - format!( - "{} file(s) selected for download", - self.selected_indices.len() - ), - "Selection", - success_style(), - ) - }; + // Center the loading message + let vertical = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Fill(1), + ]) + .split(table_area); - let info_widget = Paragraph::new(info_text) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title(title), - ) - .style(style); + let loading_message_line = Line::from(loading_message); + // Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen + let text_width = + loading_message_line.to_string().chars().count() as u16 + 5; // +10 for borders, padding, and safety margin + let max_width = (table_area.width * 80) / 100; + let widget_width = text_width.min(max_width); - frame.render_widget(info_widget, info_area); - } + let horizontal = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(widget_width), + Constraint::Fill(1), + ]) + .split(vertical[1]); - // Render controls footer - self.render_controls(frame, controls_area); + let loading_widget = Paragraph::new(loading_message_line) + .style(Style::default()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(primary_style()) + .border_type(border_type(false)), + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + frame.render_widget(loading_widget, horizontal[1]); + } + + fn render_info(&self, frame: &mut Frame, info_area: ratatui::layout::Rect) { + let (info_text, title, style) = if self.is_filtering { + ( + format!("Filter: {}", self.filter_query), + "Filter", + warning_style(), + ) + } else if !self.filter_query.is_empty() { + ( + format!( + "Current filter: {} (press / to modify, Esc to clear)", + self.filter_query + ), + "Filter", + primary_style(), + ) + } else { + ( + format!( + "{} file(s) selected for download", + self.selected_indices.len() + ), + "Selection", + success_style(), + ) + }; + + let info_widget = Paragraph::new(info_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type(false)) + .title(title), + ) + .style(style); + + frame.render_widget(info_widget, info_area); } fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) { diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 38887e5..9b0544b 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -60,13 +60,11 @@ impl MultiDownloadProgress { } pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { - // Start initial batch of downloads self.start_next_batch(); loop { terminal.draw(|frame| self.render(frame))?; - // Check for incoming downloads from background thread while let Ok((download, receiver)) = self.receiver_channel.try_recv() { @@ -96,12 +94,10 @@ impl MultiDownloadProgress { } } - // Start next batch after iteration completes if need_start_next { self.start_next_batch(); } - // Handle keyboard input if poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? { @@ -118,7 +114,6 @@ impl MultiDownloadProgress { } fn start_next_batch(&mut self) { - // Start queued downloads up to max_concurrent limit for download_entry in &self.downloads { if self.active_count >= self.max_concurrent { break; diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs deleted file mode 100644 index c23de17..0000000 --- a/soulseek-rs/src/ui/main_tui.rs +++ /dev/null @@ -1,2026 +0,0 @@ -use crate::models::{ - AppState, BrowseStatus, ChatMessage, CommandBarMode, DownloadEntry, - FileDisplayData, FocusedPane, MessageDirection, RoomsView, SearchEntry, - SearchStatus, files_under, find_node, -}; -use crate::ui::panes::{ - ResultsPaneParams, render_browse_pane, render_download_info_pane, - render_downloads_pane, render_results_pane, render_rooms_pane, - render_searches_pane, -}; -use crate::ui::{ - border_style, border_type, format_shortcuts_styled, render_download_stats, -}; -use color_eyre::Result; -use ratatui::{ - DefaultTerminal, Frame, - crossterm::event::{ - self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, - MouseButton, MouseEvent, MouseEventKind, poll, - }, - layout::{Constraint, Layout, Position, Rect}, - widgets::{Block, Borders, Paragraph}, -}; -use soulseek_rs::{Client, DownloadStatus}; -use std::{ - sync::{ - Arc, - atomic::AtomicBool, - mpsc::{self}, - }, - thread, - time::{Duration, Instant}, -}; - -const COMMAND_BAR_PREFIX: &str = "search: "; -const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): "; -const BROWSE_BAR_PREFIX: &str = "browse user: "; - -/// How long to wait for a browse response before showing a timeout notice. -const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); - -const fn command_bar_prefix(mode: CommandBarMode) -> &'static str { - match mode { - CommandBarMode::Search => COMMAND_BAR_PREFIX, - CommandBarMode::Message => MESSAGE_BAR_PREFIX, - CommandBarMode::Browse => BROWSE_BAR_PREFIX, - } -} - -pub struct MainTui { - client: Arc, - state: AppState, - download_dir: String, - #[allow(dead_code)] - max_concurrent_downloads: usize, - search_timeout: Duration, - #[allow(dead_code)] - spinner_state: usize, -} - -impl MainTui { - pub fn new( - client: Arc, - download_dir: String, - max_concurrent_downloads: usize, - search_timeout: Duration, - ) -> Self { - Self { - client, - state: AppState::new(), - download_dir, - max_concurrent_downloads, - search_timeout, - spinner_state: 0, - } - } - - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { - use ratatui::crossterm::{event::DisableMouseCapture, execute}; - - // Run the event loop, then restore the terminal unconditionally: if the - // loop returns early with an error the terminal must still be taken out - // of raw mode / the alternate screen and mouse capture disabled, or the - // user is left with a corrupted terminal. - let result = self.run_event_loop(&mut terminal); - - let _ = execute!(std::io::stdout(), DisableMouseCapture); - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - - result - } - - fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - while !self.state.should_exit { - terminal.draw(|frame| self.render(frame))?; - - // Poll for search results updates - self.update_search_results(); - - // Poll for download updates - self.update_downloads(); - - // Poll for incoming private messages - self.poll_private_messages(); - - // Poll for a browse (shared-file listing) response - self.poll_browse_result(); - - // Poll for chat-room events - self.poll_room_events(); - - // Update spinner - self.spinner_state = (self.spinner_state + 1) % 10; - - // Drain every queued input event before the next draw: key - // autorepeat outpaces the frame time, and handling one event per - // frame makes the backlog keep scrolling for seconds after the - // key is released. - if poll(Duration::from_millis(100))? { - loop { - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - self.handle_key_event(key); - } - Event::Mouse(mouse) => { - self.handle_mouse_event(mouse); - } - _ => {} - } - if self.state.should_exit || !poll(Duration::ZERO)? { - break; - } - } - } - } - - Ok(()) - } - - fn render(&mut self, frame: &mut Frame) { - // Create layout based on whether command bar is active - let main_chunks = if self.state.command_bar_active { - Layout::vertical([ - Constraint::Length(3), // Status bar - Constraint::Fill(1), // Main content - Constraint::Length(3), // Command bar - Constraint::Length(3), // Shortcuts - ]) - .split(frame.area()) - } else { - Layout::vertical([ - Constraint::Length(3), // Status bar - Constraint::Fill(1), // Main content - Constraint::Length(3), // Shortcuts - ]) - .split(frame.area()) - }; - - // Render status bar - render_download_stats( - frame, - main_chunks[0], - &self.state.downloads, - self.state.active_downloads_count, - ); - - // Split main content area - let content_chunks = Layout::horizontal([ - Constraint::Percentage(30), // Searches pane - Constraint::Percentage(70), // Results + Downloads - ]) - .split(main_chunks[1]); - - // Store searches pane area - self.state.searches_pane_area = Some(content_chunks[0]); - - // Render Searches pane (left) - render_searches_pane( - frame, - content_chunks[0], - &self.state.searches, - &mut self.state.searches_table_state, - self.state.focused_pane == FocusedPane::Searches, - ); - - // Split right side into Results (top) and Downloads (bottom) - let right_chunks = Layout::vertical([ - Constraint::Percentage(60), // Results - Constraint::Percentage(40), // Downloads - ]) - .split(content_chunks[1]); - - // Split bottom-right into Downloads list and Info pane - let downloads_chunks = Layout::horizontal([ - Constraint::Percentage(60), // Downloads list - Constraint::Percentage(40), // Info pane - ]) - .split(right_chunks[1]); - - // Store results and downloads pane areas - self.state.results_pane_area = Some(right_chunks[0]); - self.state.downloads_pane_area = Some(downloads_chunks[0]); - - // Render Results pane. When a filter is active the rendered rows are a - // subset, so the pane also needs the mapping back to unfiltered indices - // to render the selection checkboxes correctly. - let (results_items, results_original_indices) = - if self.state.results_filter_query.is_empty() { - (&self.state.results_items, None) - } else { - ( - &self.state.results_filtered_items, - Some(self.state.results_filtered_indices.as_slice()), - ) - }; - - let active_search_query = self - .state - .selected_search_index - .and_then(|idx| self.state.searches.get(idx)) - .map(|search| search.query.as_str()); - - render_results_pane( - frame, - right_chunks[0], - ResultsPaneParams { - items: results_items, - table_state: &mut self.state.results_table_state, - selected_indices: &self.state.results_selected_indices, - original_indices: results_original_indices, - filter_query: &self.state.results_filter_query, - is_filtering: self.state.results_is_filtering, - focused: self.state.focused_pane == FocusedPane::Results, - active_search_query, - }, - ); - - // Render Downloads pane - render_downloads_pane( - frame, - downloads_chunks[0], - &self.state.downloads, - &mut self.state.downloads_table_state, - self.state.focused_pane == FocusedPane::Downloads, - ); - - // Render Info pane next to downloads - let selected_download = self - .state - .downloads_table_state - .selected() - .and_then(|idx| self.state.downloads.get(idx)); - render_download_info_pane( - frame, - downloads_chunks[1], - selected_download, - self.state.focused_pane == FocusedPane::Downloads, - ); - - // Render command bar if active (vim-style, above shortcuts) - if self.state.command_bar_active { - self.render_command_bar(frame, main_chunks[2]); - self.render_shortcuts(frame, main_chunks[3]); - } else { - self.render_shortcuts(frame, main_chunks[2]); - } - - // Messages inbox overlays everything when open. - if self.state.show_messages { - self.render_messages_popup(frame); - } - - // Browse tree overlays everything when open. - if self.state.show_browse && !self.state.browse.is_empty() { - let area = centered_rect(80, 80, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - render_browse_pane( - frame, - area, - &self.state.browse, - &mut self.state.browse_table_state, - self.spinner_state, - ); - } - - // Chat rooms overlay everything when open. - if self.state.show_rooms { - let area = centered_rect(85, 80, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - render_rooms_pane( - frame, - area, - &self.state.rooms, - &mut self.state.rooms_list_table_state, - ); - } - } - - fn render_messages_popup(&self, frame: &mut Frame) { - 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( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title(" Messages (m: compose, i/Esc: close) "), - ); - - frame.render_widget(ratatui::widgets::Clear, area); - frame.render_widget(popup, area); - } - - /// Context shortcuts for the chat-rooms popup. - fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { - if self.state.rooms.composing { - return vec![ - ("Type", "message"), - ("Enter", "send"), - ("Esc", "cancel"), - ]; - } - match self.state.rooms.view { - RoomsView::List => { - if self.state.rooms.list_is_filtering { - vec![ - ("Type", "filter"), - ("Enter", "join match"), - ("Esc", "clear filter"), - ] - } else { - vec![ - ("↑↓", "move"), - ("Enter", "join"), - ("/", "filter"), - ("Tab", "open rooms"), - ("Esc", "close"), - ] - } - } - RoomsView::Chat => vec![ - ("Enter", "say"), - ("↑↓", "pick user"), - ("b", "browse user"), - ("m", "message user"), - ("Tab", "switch room"), - ("l", "rooms"), - ("x", "leave"), - ], - } - } - - 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.show_rooms { - self.rooms_shortcuts() - } else if self.state.show_browse { - vec![ - ("↑↓", "move"), - ("→←", "expand/collapse"), - ("Enter", "open/download"), - ("d", "download folder"), - ("Tab", "switch user"), - ("r", "retry"), - ("w", "close tab"), - ("Esc", "hide"), - ] - } else if self.state.command_bar_active { - match self.state.command_bar_mode { - CommandBarMode::Search => vec![ - ("Type", "search term"), - ("←→", "move cursor"), - ("Backspace/Del", "edit"), - ("Enter", "search"), - ("Esc", "cancel"), - ], - CommandBarMode::Message => vec![ - ("Type", "recipient then message"), - ("Enter", "send"), - ("Esc", "cancel"), - ], - CommandBarMode::Browse => vec![ - ("Type", "username"), - ("Enter", "browse"), - ("Esc", "cancel"), - ], - } - } else { - match self.state.focused_pane { - FocusedPane::Searches => vec![ - ("s", "search"), - ("m", "message"), - ("i", inbox_label.as_str()), - ("c", chat_label.as_str()), - ("b", "browse user"), - ("1-3", "focus pane"), - ("↑↓", "navigate"), - ("Enter", "results"), - ("q", "quit"), - ], - FocusedPane::Results if self.state.results_is_filtering => { - vec![ - ("Type", "filter"), - ("Esc", "clear filter"), - ("1-3", "focus pane"), - ("q", "quit"), - ] - } - FocusedPane::Results => vec![ - ("Space", "select"), - ("Enter", "download"), - ("b", "browse owner"), - ("c", chat_label.as_str()), - ("/", "filter"), - ("a/A", "select all/none"), - ("1-3", "focus pane"), - ("q", "quit"), - ], - FocusedPane::Downloads => { - vec![ - ("p", "pause/resume"), - ("r", "retry failed"), - ("d", "delete queued"), - ("c", "clear finished"), - ("b", "browse user"), - ("1-3", "focus pane"), - ("q", "quit"), - ] - } - } - }; - - let shortcuts_line = format_shortcuts_styled(&shortcuts); - // Surface our own sharing status in the block title. - let title = format!( - "Shortcuts · Sharing: {}", - self.client.shared_directory().unwrap_or("off") - ); - let shortcuts_widget = Paragraph::new(shortcuts_line).block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title(title), - ); - - frame.render_widget(shortcuts_widget, area); - } - - fn render_command_bar(&self, frame: &mut Frame, area: Rect) { - let prefix = command_bar_prefix(self.state.command_bar_mode); - let content_width = area.width.saturating_sub(2); - let prefix_width = prefix.chars().count() as u16; - let input_width = content_width.saturating_sub(prefix_width); - let (visible_input, cursor_column) = visible_input_at_cursor( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - input_width, - ); - let command_text = format!("{prefix}{visible_input}"); - - let paragraph = Paragraph::new(command_text).block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)), - ); - - frame.render_widget(paragraph, area); - - if area.width > 2 && area.height > 2 { - let cursor_x = area - .x - .saturating_add(1) - .saturating_add(prefix_width) - .saturating_add(cursor_column) - .min(area.x.saturating_add(area.width.saturating_sub(2))); - frame.set_cursor_position(Position::new(cursor_x, area.y + 1)); - } - } - - fn handle_key_event(&mut self, key: KeyEvent) { - // Command bar takes priority - if self.state.command_bar_active { - return self.handle_command_bar_input(key); - } - - // Messages popup: any of i/Esc/q closes it. - if self.state.show_messages { - if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) { - self.state.show_messages = false; - } - return; - } - - // Browse popup takes over navigation while open. - if self.state.show_browse { - return self.handle_browse_input(key); - } - - // Rooms popup takes over navigation while open. - if self.state.show_rooms { - return self.handle_rooms_input(key); - } - - // Filter mode in Results pane - if self.state.results_is_filtering - && self.state.focused_pane == FocusedPane::Results - { - return self.handle_filter_input(key); - } - - // Global shortcuts - match key.code { - KeyCode::Char('q') => { - self.state.should_exit = true; - return; - } - KeyCode::Char('1') => { - self.state.focused_pane = FocusedPane::Searches; - return; - } - KeyCode::Char('2') => { - self.state.focused_pane = FocusedPane::Results; - return; - } - KeyCode::Char('3') => { - self.state.focused_pane = FocusedPane::Downloads; - return; - } - KeyCode::Char('s') => { - self.state.command_bar_active = true; - self.state.command_bar_mode = CommandBarMode::Search; - self.state.command_bar_input.clear(); - self.state.command_bar_cursor_position = 0; - return; - } - KeyCode::Char('m') => { - 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; - return; - } - KeyCode::Char('i') => { - self.state.show_messages = true; - self.state.unread_messages = 0; - return; - } - // Chat rooms. In the Downloads pane `c` clears finished downloads, - // so only open chat from the other panes (like `b` is contextual). - KeyCode::Char('c') - if self.state.focused_pane != FocusedPane::Downloads => - { - self.start_rooms(); - return; - } - KeyCode::Char('b') => { - // From a highlighted search result, browse its owner directly; - // otherwise prompt for a username. - if self.state.focused_pane == FocusedPane::Results - && let Some(owner) = self.highlighted_result_owner() - { - self.start_browse(owner); - } else { - self.state.command_bar_active = true; - self.state.command_bar_mode = CommandBarMode::Browse; - self.state.command_bar_input.clear(); - self.state.command_bar_cursor_position = 0; - } - return; - } - _ => {} - } - - // Pane-specific shortcuts - match self.state.focused_pane { - FocusedPane::Searches => self.handle_searches_input(key), - FocusedPane::Results => self.handle_results_input(key), - FocusedPane::Downloads => self.handle_downloads_input(key), - } - } - - fn handle_browse_input(&mut self, key: KeyEvent) { - if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { - self.state.show_browse = false; - return; - } - - // Tab management: switch between browsed users or close the active tab. - match key.code { - KeyCode::Tab => { - self.state.browse.next_tab(); - self.sync_browse_selection(); - return; - } - KeyCode::BackTab => { - self.state.browse.prev_tab(); - self.sync_browse_selection(); - return; - } - KeyCode::Char('w') => { - if !self.state.browse.close_active() { - self.state.show_browse = false; - } - self.sync_browse_selection(); - return; - } - KeyCode::Char('r') => { - // Retry a timed-out browse. - if let Some(username) = self.state.browse.retry_active() { - let _ = self.client.browse_user(&username); - } - return; - } - _ => {} - } - - // Snapshot the flattened rows + current selection, then drop the borrow. - let (rows, sel, row) = { - let Some(browse) = self.state.browse.active_tab() else { - self.state.show_browse = false; - return; - }; - if browse.status != BrowseStatus::Loaded { - return; - } - let rows = browse.rows(); - if rows.is_empty() { - return; - } - let sel = browse.selected_row.min(rows.len() - 1); - let row = rows[sel].clone(); - (rows, sel, row) - }; - - // Downloads need `&self.client` free of the browse borrow. - match key.code { - KeyCode::Enter if !row.is_folder => { - self.queue_browse_files(vec![( - row.path.clone(), - row.size.unwrap_or(0), - )]); - return; - } - KeyCode::Char('d') => { - let files = if row.is_folder { - self.browse_folder_files(&row.path) - } else { - vec![(row.path.clone(), row.size.unwrap_or(0))] - }; - self.queue_browse_files(files); - return; - } - _ => {} - } - - // Navigation and expand/collapse mutate the browse state. - if let Some(browse) = self.state.browse.active_tab_mut() { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - browse.selected_row = sel.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - browse.selected_row = (sel + 1).min(rows.len() - 1); - } - KeyCode::Right | KeyCode::Char('l') => { - if row.is_folder && !row.expanded { - browse.expanded.insert(row.path.clone()); - } else if row.is_folder { - browse.selected_row = (sel + 1).min(rows.len() - 1); - } - } - KeyCode::Left | KeyCode::Char('h') => { - if row.is_folder && row.expanded { - browse.expanded.remove(&row.path); - } else if let Some(parent) = - (0..sel).rev().find(|&i| rows[i].depth < row.depth) - { - browse.selected_row = parent; - } - } - KeyCode::Enter => { - // Folder toggle (files handled above). - if row.expanded { - browse.expanded.remove(&row.path); - } else { - browse.expanded.insert(row.path.clone()); - } - } - _ => {} - } - // Re-clamp against the new flattened length. - let new_len = browse.rows().len(); - browse.selected_row = - browse.selected_row.min(new_len.saturating_sub(1)); - } - - self.sync_browse_selection(); - } - - /// Point the browse table cursor at the active tab's selected row. - fn sync_browse_selection(&mut self) { - let selected = self.state.browse.active_tab().map(|b| b.selected_row); - self.state.browse_table_state.select(selected); - } - - /// Files (`path`, `size`) under the active browse tab's folder at `path`. - fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { - self.state - .browse - .active_tab() - .and_then(|b| find_node(&b.tree, path)) - .map(files_under) - .unwrap_or_default() - } - - /// Queue downloads of `files` (path, size) from the active browse tab's user. - fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { - let Some(username) = - self.state.browse.active_tab().map(|b| b.username.clone()) - else { - return; - }; - if files.is_empty() { - return; - } - if self.state.downloads_receiver_channel.is_none() { - let (sender, receiver) = mpsc::channel(); - self.state.downloads_receiver_channel = Some(receiver); - self.state.downloads_sender_channel = Some(sender); - } - let client = self.client.clone(); - let download_dir = self.download_dir.clone(); - let sender = self.state.downloads_sender_channel.clone().unwrap(); - thread::spawn(move || { - for (path, size) in files { - match client.download( - path.clone(), - username.clone(), - size, - download_dir.clone(), - ) { - Ok((download, rx)) => { - let _ = sender.send((download, rx)); - } - Err(e) => { - soulseek_rs::warn!( - "Failed to queue download {path}: {e}" - ); - } - } - } - }); - } - - /// Open the chat-rooms popup and refresh the room list. If rooms are - /// already open, jump straight to the chat view; otherwise show the list. - fn start_rooms(&mut self) { - let _ = self.client.request_room_list(); - self.state.show_rooms = true; - if self.state.rooms.open.is_empty() { - self.state.rooms.view = RoomsView::List; - } else { - self.state.rooms.view = RoomsView::Chat; - self.state.rooms.mark_active_read(); - } - } - - fn handle_rooms_input(&mut self, key: KeyEvent) { - // Composing a message captures typing. - if self.state.rooms.composing { - self.handle_room_compose_input(key); - return; - } - // Filtering the room list captures typing. - if self.state.rooms.view == RoomsView::List - && self.state.rooms.list_is_filtering - { - self.handle_room_filter_input(key); - return; - } - - match self.state.rooms.view { - RoomsView::List => self.handle_rooms_list_input(key), - RoomsView::Chat => self.handle_rooms_chat_input(key), - } - } - - fn handle_rooms_list_input(&mut self, key: KeyEvent) { - let len = self.state.rooms.filtered_rooms().len(); - match key.code { - // Esc peels back one level: clear a lingering filter first (as the - // title's "Esc: clear" promises), otherwise close the popup. - KeyCode::Esc if !self.state.rooms.list_filter.is_empty() => { - self.state.rooms.list_filter.clear(); - self.state.rooms.list_selected = 0; - } - KeyCode::Esc | KeyCode::Char('q') => { - self.state.show_rooms = false; - } - KeyCode::Char('/') => { - self.state.rooms.list_is_filtering = true; - } - KeyCode::Up | KeyCode::Char('k') => { - self.state.rooms.list_selected = - self.state.rooms.list_selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - if len > 0 { - self.state.rooms.list_selected = - (self.state.rooms.list_selected + 1).min(len - 1); - } - } - KeyCode::Enter => self.join_selected_room(), - KeyCode::Tab if !self.state.rooms.open.is_empty() => { - self.state.rooms.view = RoomsView::Chat; - self.state.rooms.mark_active_read(); - } - _ => {} - } - } - - fn handle_room_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.rooms.list_is_filtering = false; - self.state.rooms.list_filter.clear(); - self.state.rooms.list_selected = 0; - } - KeyCode::Enter => { - self.state.rooms.list_is_filtering = false; - self.join_selected_room(); - } - KeyCode::Char(c) => { - self.state.rooms.list_filter.push(c); - self.state.rooms.list_selected = 0; - } - KeyCode::Backspace => { - self.state.rooms.list_filter.pop(); - self.state.rooms.list_selected = 0; - } - _ => {} - } - } - - fn handle_rooms_chat_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('q') => self.state.show_rooms = false, - KeyCode::Esc | KeyCode::Char('l') => { - self.state.rooms.view = RoomsView::List; - } - KeyCode::Tab => self.state.rooms.next_tab(), - KeyCode::BackTab => self.state.rooms.prev_tab(), - KeyCode::Char('x') => self.leave_active_room(), - KeyCode::Up | KeyCode::Char('k') => { - self.state.rooms.select_user_up(); - } - KeyCode::Down | KeyCode::Char('j') => { - self.state.rooms.select_user_down(); - } - // Act on the highlighted member of the room. - KeyCode::Char('b') => self.browse_selected_room_user(), - KeyCode::Char('m') => self.message_selected_room_user(), - KeyCode::Enter if self.state.rooms.active_room().is_some() => { - self.state.rooms.composing = true; - } - _ => {} - } - } - - /// Browse the shares of the member highlighted in the active room. Closes - /// the rooms popup so the browse tree is the sole overlay. - fn browse_selected_room_user(&mut self) { - if let Some(user) = self.state.rooms.selected_user() { - self.state.show_rooms = false; - self.start_browse(user); - } - } - - /// 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. - 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(); - } - } - - fn handle_room_compose_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Enter => self.send_room_message(), - KeyCode::Esc => { - self.state.rooms.composing = false; - if let Some(room) = - self.state.rooms.open.get_mut(self.state.rooms.active) - { - room.input.clear(); - } - } - KeyCode::Backspace => { - if let Some(room) = - self.state.rooms.open.get_mut(self.state.rooms.active) - { - room.input.pop(); - } - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - if let Some(room) = - self.state.rooms.open.get_mut(self.state.rooms.active) - { - room.input.push(c); - } - } - _ => {} - } - } - - /// Join (or focus) the room highlighted in the list view. - fn join_selected_room(&mut self) { - if let Some(name) = self.state.rooms.selected_room_name() { - let newly_opened = self.state.rooms.focus_or_open(&name); - if newly_opened && let Err(e) = self.client.join_room(&name) { - soulseek_rs::warn!("Failed to join {name}: {e}"); - } - } - } - - /// Leave the active room and close its tab. - fn leave_active_room(&mut self) { - if let Some(name) = self.state.rooms.close_active() - && let Err(e) = self.client.leave_room(&name) - { - soulseek_rs::warn!("Failed to leave {name}: {e}"); - } - } - - /// Send the active room's compose buffer. The server echoes the message - /// back as a RoomEvent, which is what actually renders it in the log. - fn send_room_message(&mut self) { - let active = self.state.rooms.active; - let (room, text) = match self.state.rooms.open.get(active) { - Some(room) if !room.input.trim().is_empty() => { - (room.name.clone(), room.input.trim().to_string()) - } - _ => { - self.state.rooms.composing = false; - return; - } - }; - if let Err(e) = self.client.say_in_room(&room, &text) { - soulseek_rs::warn!("Failed to say in {room}: {e}"); - } - if let Some(room) = self.state.rooms.open.get_mut(active) { - room.input.clear(); - } - self.state.rooms.composing = false; - } - - /// Drain chat-room events into the rooms state, tracking unread badges. - fn poll_room_events(&mut self) { - let viewing = if self.state.show_rooms - && self.state.rooms.view == RoomsView::Chat - { - self.state.rooms.active_room().map(|r| r.name.clone()) - } else { - None - }; - for event in self.client.take_room_events() { - self.state.rooms.apply_event(event, viewing.as_deref()); - } - } - - fn handle_command_bar_input(&mut self, key: KeyEvent) { - self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - ); - - match key.code { - KeyCode::Enter => { - let input = self.state.command_bar_input.trim().to_string(); - if !input.is_empty() { - match self.state.command_bar_mode { - CommandBarMode::Search => self.start_search(input), - CommandBarMode::Message => { - self.send_message_from_input(&input); - } - CommandBarMode::Browse => self.start_browse(input), - } - } - self.state.command_bar_active = false; - self.state.command_bar_input.clear(); - self.state.command_bar_cursor_position = 0; - } - KeyCode::Esc => { - self.state.command_bar_active = false; - self.state.command_bar_input.clear(); - self.state.command_bar_cursor_position = 0; - } - KeyCode::Backspace => { - let cursor_position = self.state.command_bar_cursor_position; - if cursor_position > 0 { - let previous_position = previous_char_boundary( - &self.state.command_bar_input, - cursor_position, - ); - self.state - .command_bar_input - .drain(previous_position..cursor_position); - self.state.command_bar_cursor_position = previous_position; - } - } - KeyCode::Delete => { - let cursor_position = self.state.command_bar_cursor_position; - if cursor_position < self.state.command_bar_input.len() { - let next_position = next_char_boundary( - &self.state.command_bar_input, - cursor_position, - ); - self.state - .command_bar_input - .drain(cursor_position..next_position); - } - } - KeyCode::Left => { - self.state.command_bar_cursor_position = previous_char_boundary( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - ); - } - KeyCode::Right => { - self.state.command_bar_cursor_position = next_char_boundary( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - ); - } - KeyCode::Home => { - self.state.command_bar_cursor_position = 0; - } - KeyCode::End => { - self.state.command_bar_cursor_position = - self.state.command_bar_input.len(); - } - KeyCode::Char('a') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - self.state.command_bar_cursor_position = 0; - } - KeyCode::Char('e') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - self.state.command_bar_cursor_position = - self.state.command_bar_input.len(); - } - KeyCode::Char('u') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - self.state.command_bar_input.clear(); - self.state.command_bar_cursor_position = 0; - } - KeyCode::Char(c) - if !key.modifiers.intersects( - KeyModifiers::CONTROL - | KeyModifiers::ALT - | KeyModifiers::SUPER - | KeyModifiers::HYPER - | KeyModifiers::META, - ) => - { - let cursor_position = self.state.command_bar_cursor_position; - self.state.command_bar_input.insert(cursor_position, c); - self.state.command_bar_cursor_position = - cursor_position + c.len_utf8(); - } - _ => {} - } - } - - fn handle_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.results_is_filtering = false; - self.state.results_filter_query.clear(); - self.state.results_filtered_items = - self.state.results_items.clone(); - self.state.results_filtered_indices = - (0..self.state.results_items.len()).collect(); - } - KeyCode::Char(c) => { - self.state.results_filter_query.push(c); - self.apply_filter(); - } - KeyCode::Backspace => { - self.state.results_filter_query.pop(); - self.apply_filter(); - } - _ => {} - } - } - - fn handle_searches_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') - if !self.state.searches.is_empty() => - { - let current = - self.state.searches_table_state.selected().unwrap_or(0); - let new = if current == 0 { - self.state.searches.len() - 1 - } else { - current - 1 - }; - self.state.searches_table_state.select(Some(new)); - } - KeyCode::Down | KeyCode::Char('j') - if !self.state.searches.is_empty() => - { - let current = - self.state.searches_table_state.selected().unwrap_or(0); - let new = (current + 1) % self.state.searches.len(); - self.state.searches_table_state.select(Some(new)); - } - KeyCode::Enter => { - if let Some(selected) = - self.state.searches_table_state.selected() - { - self.state.selected_search_index = Some(selected); - if let Some(search) = self.state.searches.get(selected) { - self.state.results_items = search.results.clone(); - self.state.results_filtered_items = - search.results.clone(); - self.state.results_filtered_indices = - (0..search.results.len()).collect(); - self.state.results_selected_indices.clear(); - self.state.results_table_state.select(Some(0)); - self.state.focused_pane = FocusedPane::Results; - } - } - } - KeyCode::Char('d') => { - if let Some(selected) = - self.state.searches_table_state.selected() - { - self.remove_search_at_index(selected); - } - } - KeyCode::Char('C') => { - self.clear_all_searches(); - } - _ => {} - } - } - - fn handle_results_input(&mut self, key: KeyEvent) { - let items_count = if self.state.results_filter_query.is_empty() { - self.state.results_items.len() - } else { - self.state.results_filtered_items.len() - }; - - match key.code { - KeyCode::Up | KeyCode::Char('k') if items_count > 0 => { - let current = - self.state.results_table_state.selected().unwrap_or(0); - let new = if current == 0 { - items_count - 1 - } else { - current - 1 - }; - self.state.results_table_state.select(Some(new)); - } - KeyCode::Down | KeyCode::Char('j') if items_count > 0 => { - let current = - self.state.results_table_state.selected().unwrap_or(0); - let new = (current + 1) % items_count; - self.state.results_table_state.select(Some(new)); - } - KeyCode::Char(' ') => { - if let Some(current) = self.state.results_table_state.selected() - { - let actual_index = - if self.state.results_filter_query.is_empty() { - current - } else { - self.state.results_filtered_indices[current] - }; - - if self - .state - .results_selected_indices - .contains(&actual_index) - { - self.state - .results_selected_indices - .remove(&actual_index); - } else { - self.state - .results_selected_indices - .insert(actual_index); - } - } - } - KeyCode::Char('/') => { - self.state.results_is_filtering = true; - self.state.results_filter_query.clear(); - } - KeyCode::Char('a') => { - let indices: Vec = - if self.state.results_filter_query.is_empty() { - (0..self.state.results_items.len()).collect() - } else { - self.state.results_filtered_indices.clone() - }; - self.state.results_selected_indices.extend(indices); - } - KeyCode::Char('A') => { - self.state.results_selected_indices.clear(); - } - KeyCode::Enter => { - self.queue_selected_downloads(); - } - _ => {} - } - } - - fn handle_downloads_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Up | KeyCode::Char('k') - if !self.state.downloads.is_empty() => - { - let current = - self.state.downloads_table_state.selected().unwrap_or(0); - let new = if current == 0 { - self.state.downloads.len() - 1 - } else { - current - 1 - }; - self.state.downloads_table_state.select(Some(new)); - } - KeyCode::Down | KeyCode::Char('j') - if !self.state.downloads.is_empty() => - { - let current = - self.state.downloads_table_state.selected().unwrap_or(0); - let new = (current + 1) % self.state.downloads.len(); - self.state.downloads_table_state.select(Some(new)); - } - KeyCode::Char('p') => { - self.toggle_selected_download_pause(); - } - KeyCode::Char('d') => { - self.remove_selected_queued_download(); - } - KeyCode::Char('r') => { - self.retry_selected_download(); - } - KeyCode::Char('c') => { - self.clear_finished_downloads(); - } - _ => {} - } - } - - /// Re-queue the selected download if it failed or timed out. - fn retry_selected_download(&mut self) { - let Some(index) = self.state.downloads_table_state.selected() else { - return; - }; - let Some(entry) = self.state.downloads.get(index) else { - return; - }; - if !matches!( - entry.download.status, - DownloadStatus::Failed(_) | DownloadStatus::TimedOut - ) { - return; - } - let filename = entry.download.filename.clone(); - let username = entry.download.username.clone(); - let size = entry.download.size; - let directory = entry.download.download_directory.clone(); - - if self.state.downloads_receiver_channel.is_none() { - let (sender, receiver) = mpsc::channel(); - self.state.downloads_receiver_channel = Some(receiver); - self.state.downloads_sender_channel = Some(sender); - } - let client = self.client.clone(); - let sender = self.state.downloads_sender_channel.clone().unwrap(); - - // Drop the old failed entry from both the UI list and the client's - // download store; otherwise the stale entry (same md5-derived token and - // filename) shadows the retry and its completion is misrouted, leaving - // the retried download stuck showing "Queued". - self.state.downloads.remove(index); - self.select_download_after_removal(index); - let _ = self.client.remove_download(&username, &filename); - - thread::spawn(move || { - match client.download(filename.clone(), username, size, directory) { - Ok((download, rx)) => { - let _ = sender.send((download, rx)); - } - Err(e) => soulseek_rs::warn!("Failed to retry {filename}: {e}"), - } - }); - } - - /// Remove all completed / failed / timed-out downloads from the list. - fn clear_finished_downloads(&mut self) { - self.state.downloads.retain(|entry| { - !matches!( - entry.download.status, - DownloadStatus::Completed - | DownloadStatus::Failed(_) - | DownloadStatus::TimedOut - ) - }); - let len = self.state.downloads.len(); - if len == 0 { - self.state.downloads_table_state.select(None); - } else { - let selected = self - .state - .downloads_table_state - .selected() - .unwrap_or(0) - .min(len - 1); - self.state.downloads_table_state.select(Some(selected)); - } - } - - fn toggle_selected_download_pause(&self) { - let Some(index) = self.state.downloads_table_state.selected() else { - return; - }; - let Some(download_entry) = self.state.downloads.get(index) else { - return; - }; - - let download = &download_entry.download; - match download.status { - DownloadStatus::InProgress { .. } => { - let _ = self - .client - .pause_download(&download.username, &download.filename); - } - DownloadStatus::Paused { .. } => { - let _ = self - .client - .resume_download(&download.username, &download.filename); - } - _ => {} - } - } - - fn remove_selected_queued_download(&mut self) { - let Some(index) = self.state.downloads_table_state.selected() else { - return; - }; - let Some(download_entry) = self.state.downloads.get(index) else { - return; - }; - - let download = &download_entry.download; - if !matches!(download.status, DownloadStatus::Queued) { - return; - } - - if !self - .client - .remove_queued_download(&download.username, &download.filename) - { - return; - } - - self.state.downloads.remove(index); - self.select_download_after_removal(index); - } - - fn select_download_after_removal(&mut self, removed_index: usize) { - if self.state.downloads.is_empty() { - self.state.downloads_table_state.select(None); - return; - } - - let next_index = removed_index.min(self.state.downloads.len() - 1); - self.state.downloads_table_state.select(Some(next_index)); - } - - fn handle_mouse_event(&mut self, mouse: MouseEvent) { - if mouse.kind == MouseEventKind::Down(MouseButton::Left) { - let (col, row) = (mouse.column, mouse.row); - - // Check if click is within searches pane - if let Some(area) = self.state.searches_pane_area - && col >= area.x - && col < area.x + area.width - && row >= area.y - && row < area.y + area.height - { - self.state.focused_pane = FocusedPane::Searches; - return; - } - - // Check if click is within results pane - if let Some(area) = self.state.results_pane_area - && col >= area.x - && col < area.x + area.width - && row >= area.y - && row < area.y + area.height - { - self.state.focused_pane = FocusedPane::Results; - return; - } - - // Check if click is within downloads pane - if let Some(area) = self.state.downloads_pane_area - && col >= area.x - && col < area.x + area.width - && row >= area.y - && row < area.y + area.height - { - self.state.focused_pane = FocusedPane::Downloads; - } - } - } - - fn remove_search_at_index(&mut self, index: usize) { - if index >= self.state.searches.len() { - return; - } - - // Cancel the search if it's active - if let Some(search) = self.state.searches.get(index) { - search - .cancel_flag - .store(true, std::sync::atomic::Ordering::Relaxed); - } - - // Check if we're removing the currently active search - let was_active_search = self.state.selected_search_index == Some(index); - - // Remove the search - self.state.searches.remove(index); - - // Update selected_search_index - if let Some(current_idx) = self.state.selected_search_index { - if current_idx == index { - // Removed the active search - clear it - self.state.selected_search_index = None; - } else if current_idx > index { - // Active search was after removed one - decrement index - self.state.selected_search_index = Some(current_idx - 1); - } - // If current_idx < index, no change needed - } - - // If we removed the active search, clear results pane - if was_active_search { - self.clear_results_pane(); - } - - // Update table selection - if self.state.searches.is_empty() { - self.state.searches_table_state.select(None); - } else { - let new_selection = if index >= self.state.searches.len() { - self.state.searches.len() - 1 - } else { - index - }; - self.state.searches_table_state.select(Some(new_selection)); - } - } - - fn clear_all_searches(&mut self) { - // Cancel all active searches - for search in &self.state.searches { - search - .cancel_flag - .store(true, std::sync::atomic::Ordering::Relaxed); - } - - // Clear searches - self.state.searches.clear(); - self.state.searches_table_state.select(None); - self.state.selected_search_index = None; - - // Clear results pane - self.clear_results_pane(); - } - - fn clear_results_pane(&mut self) { - self.state.results_items.clear(); - self.state.results_filtered_items.clear(); - self.state.results_filtered_indices.clear(); - self.state.results_selected_indices.clear(); - self.state.results_table_state.select(None); - self.state.results_filter_query.clear(); - self.state.results_is_filtering = false; - } - - fn recompute_results_filter(&mut self) { - let (items, indices) = filter_results( - &self.state.results_items, - &self.state.results_filter_query, - ); - self.state.results_filtered_items = items; - self.state.results_filtered_indices = indices; - } - - fn apply_filter(&mut self) { - self.recompute_results_filter(); - if !self.state.results_filtered_items.is_empty() { - self.state.results_table_state.select(Some(0)); - } - } - - /// Drain any private messages received since the last tick into the inbox. - fn poll_private_messages(&mut self) { - for msg in self.client.take_private_messages() { - self.state.messages.push(ChatMessage { - direction: MessageDirection::Incoming, - peer: msg.username().to_string(), - text: msg.message().to_string(), - }); - // Badge the inbox when it isn't currently open. - if !self.state.show_messages { - self.state.unread_messages += 1; - } - } - } - - /// Request a user's shared files and open (or focus) their browse tab. - fn start_browse(&mut self, username: String) { - let username = username.trim().to_string(); - if username.is_empty() { - return; - } - // Open/focus the tab; only (re)issue the request when it's new or a - // previous attempt timed out. - if self.state.browse.open(&username) { - let _ = self.client.browse_user(&username); - } - self.state.show_browse = true; - self.sync_browse_selection(); - } - - /// The username of the highlighted search result (filter-aware). - fn highlighted_result_owner(&self) -> Option { - let selected = self.state.results_table_state.selected()?; - let items = if self.state.results_filter_query.is_empty() { - &self.state.results_items - } else { - &self.state.results_filtered_items - }; - items.get(selected).map(|f| f.username.clone()) - } - - /// Drain browse responses into any loading tabs, or time them out. - fn poll_browse_result(&mut self) { - // Which loading tabs are waiting, and for whom. - let loading: Vec<(usize, String, std::time::Instant)> = self - .state - .browse - .tabs - .iter() - .enumerate() - .filter(|(_, b)| b.status == BrowseStatus::Loading) - .map(|(i, b)| (i, b.username.clone(), b.requested_at)) - .collect(); - - for (idx, username, requested_at) in loading { - if let Some(directories) = self.client.take_browse_result(&username) - { - if let Some(browse) = self.state.browse.tabs.get_mut(idx) { - browse.load(&directories); - } - if idx == self.state.browse.active { - self.state.browse_table_state.select(Some(0)); - } - } else if requested_at.elapsed() > BROWSE_TIMEOUT - && let Some(browse) = self.state.browse.tabs.get_mut(idx) - { - browse.status = BrowseStatus::TimedOut; - } - } - } - - /// Parse a ` ` compose line and send it. - fn send_message_from_input(&mut self, input: &str) { - let Some((recipient, text)) = input.split_once(char::is_whitespace) - else { - return; - }; - let recipient = recipient.trim(); - let text = text.trim(); - if recipient.is_empty() || text.is_empty() { - return; - } - - 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(), - }), - Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), - } - } - - fn start_search(&mut self, query: String) { - let cancel_flag = Arc::new(AtomicBool::new(false)); - let search_entry = SearchEntry { - query: query.clone(), - status: SearchStatus::Active, - results: Vec::new(), - start_time: Instant::now(), - cancel_flag: cancel_flag.clone(), - }; - - self.state.searches.push(search_entry); - let search_index = self.state.searches.len() - 1; - self.state.searches_table_state.select(Some(search_index)); - - // Make this search the active one - self.state.selected_search_index = Some(search_index); - - // Initialize results display (empty at first) - self.state.results_items.clear(); - self.state.results_filtered_items.clear(); - self.state.results_filtered_indices.clear(); - self.state.results_selected_indices.clear(); - self.state.results_table_state.select(Some(0)); - - // Switch focus to Results pane - self.state.focused_pane = FocusedPane::Results; - - // Start search in background - let client = self.client.clone(); - let timeout = self.search_timeout; - - thread::spawn(move || { - match client.search_with_cancel( - &query, - timeout, - Some(cancel_flag.clone()), - ) { - Ok(_results) => { - // Results will be polled in update_search_results - } - Err(e) => { - soulseek_rs::warn!("Search failed: {e}"); - } - } - }); - } - - fn update_search_results(&mut self) { - let timeout = self.search_timeout; - let selected_search_index = self.state.selected_search_index; - - // Fetch all results in one go (single lock acquisition per query) - // Use try_get_search_results to avoid blocking the UI thread - let all_results: Vec<(usize, Vec<_>)> = self - .state - .searches - .iter() - .enumerate() - .map(|(idx, s)| (idx, s.query.clone())) - .filter_map(|(idx, query)| { - self.client - .try_get_search_results(&query) - .map(|results| (idx, results)) - }) - .collect(); - - // Now update state without holding any client locks - for (idx, search_results) in all_results { - if let Some(search) = self.state.searches.get_mut(idx) { - // Results only accumulate, so an unchanged file count means - // nothing new arrived: skip the rebuild, which clones the - // full result list several times and dominates frame time. - let total_files: usize = - search_results.iter().map(|r| r.files.len()).sum(); - if total_files != search.results.len() { - search.results.clear(); - for result in search_results { - for file in result.files { - search.results.push(FileDisplayData { - filename: file.name.clone(), - size: file.size, - username: result.username.clone(), - speed: result.speed, - slots: result.slots, - bitrate: file.attribs.get(&0).copied(), - length_seconds: file.attribs.get(&1).copied(), - }); - } - } - - // Update selected search if this is the active one. Re-derive - // the filtered view from the current query so an active - // filter is preserved as new results stream in, rather than - // being clobbered by the full unfiltered list. - if let Some(selected_idx) = selected_search_index - && selected_idx == idx - { - self.state.results_items = search.results.clone(); - let (items, indices) = filter_results( - &self.state.results_items, - &self.state.results_filter_query, - ); - self.state.results_filtered_items = items; - self.state.results_filtered_indices = indices; - } - } - - // Mark as completed after timeout - if search.status == SearchStatus::Active - && search.start_time.elapsed() > timeout - { - search.status = SearchStatus::Completed; - } - } - } - } - - fn queue_selected_downloads(&mut self) { - let selected_files: Vec = self - .state - .results_selected_indices - .iter() - .filter_map(|&idx| self.state.results_items.get(idx)) - .cloned() - .collect(); - - if selected_files.is_empty() { - return; - } - - if self.state.downloads_receiver_channel.is_none() { - let (sender, receiver) = mpsc::channel(); - self.state.downloads_receiver_channel = Some(receiver); - self.state.downloads_sender_channel = Some(sender); - } - - let client = self.client.clone(); - let download_dir = self.download_dir.clone(); - let sender = self.state.downloads_sender_channel.clone().unwrap(); - - thread::spawn(move || { - for file in selected_files { - let metadata = soulseek_rs::types::DownloadMetadata { - bitrate: file.bitrate, - length_seconds: file.length_seconds, - peer_upload_speed: Some(file.speed), - peer_free_slots: Some(file.slots), - }; - match client.download_with_metadata( - file.filename.clone(), - file.username.clone(), - file.size, - download_dir.clone(), - metadata, - ) { - Ok((download, rx)) => { - let _ = sender.send((download, rx)); - } - Err(e) => { - soulseek_rs::warn!( - "Failed to start download for {}: {}", - file.filename, - e - ); - } - } - } - }); - - // Clear selection - self.state.results_selected_indices.clear(); - } - - fn update_downloads(&mut self) { - // Check for new downloads from background thread - if let Some(ref receiver) = self.state.downloads_receiver_channel { - while let Ok((download, download_receiver)) = receiver.try_recv() { - self.state.downloads.push(DownloadEntry { - download, - receiver: Some(download_receiver), - }); - } - } - - // Update download states - self.state.active_downloads_count = 0; - for download_entry in &mut self.state.downloads { - if let Some(ref receiver) = download_entry.receiver { - while let Ok(status) = receiver.try_recv() { - download_entry.download.status = status; - } - } - - if matches!( - download_entry.download.status, - DownloadStatus::InProgress { .. } - ) { - self.state.active_downloads_count += 1; - } - } - } -} - -pub fn launch_main_tui( - terminal: DefaultTerminal, - client: Arc, - download_dir: String, - max_concurrent_downloads: usize, - search_timeout: Duration, -) -> Result<()> { - let tui = MainTui::new( - client, - download_dir, - max_concurrent_downloads, - search_timeout, - ); - tui.run(terminal) -} - -/// A `Rect` centered within `area`, sized to the given percentages. -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} - -const fn clamp_cursor_to_char_boundary( - input: &str, - cursor_position: usize, -) -> usize { - if cursor_position >= input.len() { - return input.len(); - } - - let mut cursor_position = cursor_position; - while cursor_position > 0 && !input.is_char_boundary(cursor_position) { - cursor_position -= 1; - } - cursor_position -} - -fn previous_char_boundary(input: &str, cursor_position: usize) -> usize { - let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); - - input[..cursor_position] - .char_indices() - .last() - .map_or(0, |(index, _)| index) -} - -fn next_char_boundary(input: &str, cursor_position: usize) -> usize { - let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); - - if cursor_position >= input.len() { - return input.len(); - } - - cursor_position - + input[cursor_position..] - .chars() - .next() - .map_or(0, char::len_utf8) -} - -fn visible_input_at_cursor( - input: &str, - cursor_position: usize, - width: u16, -) -> (String, u16) { - if width == 0 { - return (String::new(), 0); - } - - let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); - let cursor_character_index = input[..cursor_position].chars().count(); - let max_cursor_column = usize::from(width.saturating_sub(1)); - let start_character_index = - cursor_character_index.saturating_sub(max_cursor_column); - - let visible_input = input - .chars() - .skip(start_character_index) - .take(usize::from(width)) - .collect(); - let cursor_column = cursor_character_index - .saturating_sub(start_character_index) - .min(max_cursor_column); - - (visible_input, cursor_column as u16) -} - -/// Filter `items` by a case-insensitive substring match on filename or -/// username. Returns the matching items alongside their indices in the original -/// list, so callers can translate a filtered display index back to the -/// unfiltered results. An empty query returns everything (identity mapping). -fn filter_results( - items: &[FileDisplayData], - query: &str, -) -> (Vec, Vec) { - let query = query.to_lowercase(); - if query.is_empty() { - return (items.to_vec(), (0..items.len()).collect()); - } - - let mut filtered_items = Vec::new(); - let mut filtered_indices = Vec::new(); - for (idx, item) in items.iter().enumerate() { - if item.filename.to_lowercase().contains(&query) - || item.username.to_lowercase().contains(&query) - { - filtered_items.push(item.clone()); - filtered_indices.push(idx); - } - } - (filtered_items, filtered_indices) -} - -#[cfg(test)] -mod tests { - use super::filter_results; - use crate::models::FileDisplayData; - - fn file(filename: &str, username: &str) -> FileDisplayData { - FileDisplayData { - filename: filename.to_string(), - username: username.to_string(), - ..Default::default() - } - } - - #[test] - fn empty_query_returns_identity_mapping() { - let items = vec![file("a.mp3", "bob"), file("b.flac", "amy")]; - let (filtered, indices) = filter_results(&items, ""); - assert_eq!(filtered.len(), 2); - assert_eq!(indices, vec![0, 1]); - } - - #[test] - fn query_matches_filename_and_username_and_maps_indices() { - let items = vec![ - file("track.mp3", "bob"), - file("song.flac", "alice"), - file("alice_demo.mp3", "carol"), - ]; - // "alice" matches item 1 (username) and item 2 (filename). - let (filtered, indices) = filter_results(&items, "alice"); - assert_eq!(filtered.len(), 2); - assert_eq!(indices, vec![1, 2]); - assert_eq!(filtered[0].filename, "song.flac"); - assert_eq!(filtered[1].filename, "alice_demo.mp3"); - } - - #[test] - fn query_is_case_insensitive() { - let items = vec![file("The Weeknd.mp3", "dj")]; - let (filtered, indices) = filter_results(&items, "WEEKND"); - assert_eq!(filtered.len(), 1); - assert_eq!(indices, vec![0]); - } -} diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs new file mode 100644 index 0000000..8cf5650 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -0,0 +1,233 @@ +use super::MainTui; +use crate::models::{BrowseStatus, files_under, find_node}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use std::{thread, time::Duration}; + +/// How long to wait for a browse response before showing a timeout notice. +const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); + +impl MainTui { + pub(super) fn handle_browse_input(&mut self, key: KeyEvent) { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { + self.state.show_browse = false; + return; + } + + // Tab management: switch between browsed users or close the active tab. + match key.code { + KeyCode::Tab => { + self.state.browse.next_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::BackTab => { + self.state.browse.prev_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::Char('w') => { + if !self.state.browse.close_active() { + self.state.show_browse = false; + } + self.sync_browse_selection(); + return; + } + KeyCode::Char('r') => { + // Retry a timed-out browse. + if let Some(username) = self.state.browse.retry_active() { + let _ = self.client.browse_user(&username); + } + return; + } + _ => {} + } + + // Snapshot the flattened rows + current selection, then drop the borrow. + let (rows, sel, row) = { + let Some(browse) = self.state.browse.active_tab() else { + self.state.show_browse = false; + return; + }; + if browse.status != BrowseStatus::Loaded { + return; + } + let rows = browse.rows(); + if rows.is_empty() { + return; + } + let sel = browse.selected_row.min(rows.len() - 1); + let row = rows[sel].clone(); + (rows, sel, row) + }; + + // Downloads need `&self.client` free of the browse borrow. + match key.code { + KeyCode::Enter if !row.is_folder => { + self.queue_browse_files(vec![( + row.path.clone(), + row.size.unwrap_or(0), + )]); + return; + } + KeyCode::Char('d') => { + let files = if row.is_folder { + self.browse_folder_files(&row.path) + } else { + vec![(row.path.clone(), row.size.unwrap_or(0))] + }; + self.queue_browse_files(files); + return; + } + _ => {} + } + + // Navigation and expand/collapse mutate the browse state. + if let Some(browse) = self.state.browse.active_tab_mut() { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + browse.selected_row = sel.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + browse.selected_row = (sel + 1).min(rows.len() - 1); + } + KeyCode::Right | KeyCode::Char('l') => { + if row.is_folder && !row.expanded { + browse.expanded.insert(row.path.clone()); + } else if row.is_folder { + browse.selected_row = (sel + 1).min(rows.len() - 1); + } + } + KeyCode::Left | KeyCode::Char('h') => { + if row.is_folder && row.expanded { + browse.expanded.remove(&row.path); + } else if let Some(parent) = + (0..sel).rev().find(|&i| rows[i].depth < row.depth) + { + browse.selected_row = parent; + } + } + KeyCode::Enter => { + // Folder toggle (files handled above). + if row.expanded { + browse.expanded.remove(&row.path); + } else { + browse.expanded.insert(row.path.clone()); + } + } + _ => {} + } + // Re-clamp against the new flattened length. + let new_len = browse.rows().len(); + browse.selected_row = + browse.selected_row.min(new_len.saturating_sub(1)); + } + + self.sync_browse_selection(); + } + + /// Point the browse table cursor at the active tab's selected row. + fn sync_browse_selection(&mut self) { + let selected = self.state.browse.active_tab().map(|b| b.selected_row); + self.state.browse_table_state.select(selected); + } + + /// Files (`path`, `size`) under the active browse tab's folder at `path`. + fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { + self.state + .browse + .active_tab() + .and_then(|b| find_node(&b.tree, path)) + .map(files_under) + .unwrap_or_default() + } + + /// Queue downloads of `files` (path, size) from the active browse tab's user. + fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { + let Some(username) = + self.state.browse.active_tab().map(|b| b.username.clone()) + else { + return; + }; + if files.is_empty() { + return; + } + let sender = self.downloads_sender(); + let client = self.client.clone(); + let download_dir = self.download_dir.clone(); + thread::spawn(move || { + for (path, size) in files { + match client.download( + path.clone(), + username.clone(), + size, + download_dir.clone(), + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => { + soulseek_rs::warn!( + "Failed to queue download {path}: {e}" + ); + } + } + } + }); + } + + /// Request a user's shared files and open (or focus) their browse tab. + pub(super) fn start_browse(&mut self, username: String) { + let username = username.trim().to_string(); + if username.is_empty() { + return; + } + // Open/focus the tab; only (re)issue the request when it's new or a + // previous attempt timed out. + if self.state.browse.open(&username) { + let _ = self.client.browse_user(&username); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// The username of the highlighted search result (filter-aware). + pub(super) fn highlighted_result_owner(&self) -> Option { + let selected = self.state.results_table_state.selected()?; + let items = if self.state.results_filter_query.is_empty() { + &self.state.results_items + } else { + &self.state.results_filtered_items + }; + items.get(selected).map(|f| f.username.clone()) + } + + /// Drain browse responses into any loading tabs, or time them out. + pub(super) fn poll_browse_result(&mut self) { + // Which loading tabs are waiting, and for whom. + let loading: Vec<(usize, String, std::time::Instant)> = self + .state + .browse + .tabs + .iter() + .enumerate() + .filter(|(_, b)| b.status == BrowseStatus::Loading) + .map(|(i, b)| (i, b.username.clone(), b.requested_at)) + .collect(); + + for (idx, username, requested_at) in loading { + if let Some(directories) = self.client.take_browse_result(&username) + { + if let Some(browse) = self.state.browse.tabs.get_mut(idx) { + browse.load(&directories); + } + if idx == self.state.browse.active { + self.state.browse_table_state.select(Some(0)); + } + } else if requested_at.elapsed() > BROWSE_TIMEOUT + && let Some(browse) = self.state.browse.tabs.get_mut(idx) + { + browse.status = BrowseStatus::TimedOut; + } + } + } +} diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs new file mode 100644 index 0000000..2c8ddff --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -0,0 +1,218 @@ +use super::MainTui; +use crate::models::{DownloadEntry, FileDisplayData}; +use soulseek_rs::{DownloadStatus, types::Download}; +use std::{sync::mpsc, thread}; + +impl MainTui { + /// Channel for queued downloads, created on first use. + pub(super) fn downloads_sender( + &mut self, + ) -> mpsc::Sender<(Download, mpsc::Receiver)> { + if let Some(sender) = &self.state.downloads_sender_channel { + return sender.clone(); + } + let (sender, receiver) = mpsc::channel(); + self.state.downloads_receiver_channel = Some(receiver); + self.state.downloads_sender_channel = Some(sender.clone()); + sender + } + + /// Re-queue the selected download if it failed or timed out. + pub(super) fn retry_selected_download(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(entry) = self.state.downloads.get(index) else { + return; + }; + if !matches!( + entry.download.status, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut + ) { + return; + } + let filename = entry.download.filename.clone(); + let username = entry.download.username.clone(); + let size = entry.download.size; + let directory = entry.download.download_directory.clone(); + + let sender = self.downloads_sender(); + let client = self.client.clone(); + + // Drop the old failed entry from both the UI list and the client's + // download store; otherwise the stale entry (same md5-derived token and + // filename) shadows the retry and its completion is misrouted, leaving + // the retried download stuck showing "Queued". + self.state.downloads.remove(index); + self.select_download_after_removal(index); + let _ = self.client.remove_download(&username, &filename); + + thread::spawn(move || { + match client.download(filename.clone(), username, size, directory) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => soulseek_rs::warn!("Failed to retry {filename}: {e}"), + } + }); + } + + /// Remove all completed / failed / timed-out downloads from the list. + pub(super) fn clear_finished_downloads(&mut self) { + self.state.downloads.retain(|entry| { + !matches!( + entry.download.status, + DownloadStatus::Completed + | DownloadStatus::Failed(_) + | DownloadStatus::TimedOut + ) + }); + let len = self.state.downloads.len(); + if len == 0 { + self.state.downloads_table_state.select(None); + } else { + let selected = self + .state + .downloads_table_state + .selected() + .unwrap_or(0) + .min(len - 1); + self.state.downloads_table_state.select(Some(selected)); + } + } + + pub(super) fn toggle_selected_download_pause(&self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + match download.status { + DownloadStatus::InProgress { .. } => { + let _ = self + .client + .pause_download(&download.username, &download.filename); + } + DownloadStatus::Paused { .. } => { + let _ = self + .client + .resume_download(&download.username, &download.filename); + } + _ => {} + } + } + + pub(super) fn remove_selected_queued_download(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + if !matches!(download.status, DownloadStatus::Queued) { + return; + } + + if !self + .client + .remove_queued_download(&download.username, &download.filename) + { + return; + } + + self.state.downloads.remove(index); + self.select_download_after_removal(index); + } + + fn select_download_after_removal(&mut self, removed_index: usize) { + if self.state.downloads.is_empty() { + self.state.downloads_table_state.select(None); + return; + } + + let next_index = removed_index.min(self.state.downloads.len() - 1); + self.state.downloads_table_state.select(Some(next_index)); + } + + pub(super) fn queue_selected_downloads(&mut self) { + let selected_files: Vec = self + .state + .results_selected_indices + .iter() + .filter_map(|&idx| self.state.results_items.get(idx)) + .cloned() + .collect(); + + if selected_files.is_empty() { + return; + } + + let sender = self.downloads_sender(); + let client = self.client.clone(); + let download_dir = self.download_dir.clone(); + + thread::spawn(move || { + for file in selected_files { + let metadata = soulseek_rs::types::DownloadMetadata { + bitrate: file.bitrate, + length_seconds: file.length_seconds, + peer_upload_speed: Some(file.speed), + peer_free_slots: Some(file.slots), + }; + match client.download_with_metadata( + file.filename.clone(), + file.username.clone(), + file.size, + download_dir.clone(), + metadata, + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => { + soulseek_rs::warn!( + "Failed to start download for {}: {}", + file.filename, + e + ); + } + } + } + }); + + // Clear selection + self.state.results_selected_indices.clear(); + } + + pub(super) fn update_downloads(&mut self) { + if let Some(ref receiver) = self.state.downloads_receiver_channel { + while let Ok((download, download_receiver)) = receiver.try_recv() { + self.state.downloads.push(DownloadEntry { + download, + receiver: Some(download_receiver), + }); + } + } + + self.state.active_downloads_count = 0; + for download_entry in &mut self.state.downloads { + if let Some(ref receiver) = download_entry.receiver { + while let Ok(status) = receiver.try_recv() { + download_entry.download.status = status; + } + } + + if matches!( + download_entry.download.status, + DownloadStatus::InProgress { .. } + ) { + self.state.active_downloads_count += 1; + } + } + } +} diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs new file mode 100644 index 0000000..b5f6e2d --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -0,0 +1,476 @@ +use super::MainTui; +use crate::models::{CommandBarMode, FocusedPane}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; + +impl MainTui { + pub(super) fn handle_key_event(&mut self, key: KeyEvent) { + // Command bar takes priority + if self.state.command_bar_active { + return self.handle_command_bar_input(key); + } + + // Messages popup: any of i/Esc/q closes it. + if self.state.show_messages { + if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) { + self.state.show_messages = false; + } + return; + } + + // Browse popup takes over navigation while open. + if self.state.show_browse { + return self.handle_browse_input(key); + } + + // Rooms popup takes over navigation while open. + if self.state.show_rooms { + return self.handle_rooms_input(key); + } + + // Filter mode in Results pane + if self.state.results_is_filtering + && self.state.focused_pane == FocusedPane::Results + { + return self.handle_filter_input(key); + } + + // Global shortcuts + match key.code { + KeyCode::Char('q') => { + self.state.should_exit = true; + return; + } + KeyCode::Char('1') => { + self.state.focused_pane = FocusedPane::Searches; + return; + } + KeyCode::Char('2') => { + self.state.focused_pane = FocusedPane::Results; + return; + } + KeyCode::Char('3') => { + self.state.focused_pane = FocusedPane::Downloads; + return; + } + KeyCode::Char('s') => { + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Search; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + return; + } + KeyCode::Char('m') => { + 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; + return; + } + KeyCode::Char('i') => { + self.state.show_messages = true; + self.state.unread_messages = 0; + return; + } + // Chat rooms. In the Downloads pane `c` clears finished downloads, + // so only open chat from the other panes (like `b` is contextual). + KeyCode::Char('c') + if self.state.focused_pane != FocusedPane::Downloads => + { + self.start_rooms(); + return; + } + KeyCode::Char('b') => { + // From a highlighted search result, browse its owner directly; + // otherwise prompt for a username. + if self.state.focused_pane == FocusedPane::Results + && let Some(owner) = self.highlighted_result_owner() + { + self.start_browse(owner); + } else { + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Browse; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + return; + } + _ => {} + } + + // Pane-specific shortcuts + match self.state.focused_pane { + FocusedPane::Searches => self.handle_searches_input(key), + FocusedPane::Results => self.handle_results_input(key), + FocusedPane::Downloads => self.handle_downloads_input(key), + } + } + + fn handle_command_bar_input(&mut self, key: KeyEvent) { + self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + ); + + match key.code { + KeyCode::Enter => { + let input = self.state.command_bar_input.trim().to_string(); + if !input.is_empty() { + match self.state.command_bar_mode { + CommandBarMode::Search => self.start_search(input), + CommandBarMode::Message => { + self.send_message_from_input(&input); + } + CommandBarMode::Browse => self.start_browse(input), + } + } + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Esc => { + self.state.command_bar_active = false; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Backspace => { + let cursor_position = self.state.command_bar_cursor_position; + if cursor_position > 0 { + let previous_position = previous_char_boundary( + &self.state.command_bar_input, + cursor_position, + ); + self.state + .command_bar_input + .drain(previous_position..cursor_position); + self.state.command_bar_cursor_position = previous_position; + } + } + KeyCode::Delete => { + let cursor_position = self.state.command_bar_cursor_position; + if cursor_position < self.state.command_bar_input.len() { + let next_position = next_char_boundary( + &self.state.command_bar_input, + cursor_position, + ); + self.state + .command_bar_input + .drain(cursor_position..next_position); + } + } + KeyCode::Left => { + self.state.command_bar_cursor_position = previous_char_boundary( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + ); + } + KeyCode::Right => { + self.state.command_bar_cursor_position = next_char_boundary( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + ); + } + KeyCode::Home => { + self.state.command_bar_cursor_position = 0; + } + KeyCode::End => { + self.state.command_bar_cursor_position = + self.state.command_bar_input.len(); + } + KeyCode::Char('a') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_cursor_position = 0; + } + KeyCode::Char('e') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_cursor_position = + self.state.command_bar_input.len(); + } + KeyCode::Char('u') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + KeyCode::Char(c) + if !key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) => + { + let cursor_position = self.state.command_bar_cursor_position; + self.state.command_bar_input.insert(cursor_position, c); + self.state.command_bar_cursor_position = + cursor_position + c.len_utf8(); + } + _ => {} + } + } + + fn handle_filter_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.results_is_filtering = false; + self.state.results_filter_query.clear(); + self.state.results_filtered_items = + self.state.results_items.clone(); + self.state.results_filtered_indices = + (0..self.state.results_items.len()).collect(); + } + KeyCode::Char(c) => { + self.state.results_filter_query.push(c); + self.apply_filter(); + } + KeyCode::Backspace => { + self.state.results_filter_query.pop(); + self.apply_filter(); + } + _ => {} + } + } + + fn handle_searches_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') + if !self.state.searches.is_empty() => + { + let current = + self.state.searches_table_state.selected().unwrap_or(0); + let new = if current == 0 { + self.state.searches.len() - 1 + } else { + current - 1 + }; + self.state.searches_table_state.select(Some(new)); + } + KeyCode::Down | KeyCode::Char('j') + if !self.state.searches.is_empty() => + { + let current = + self.state.searches_table_state.selected().unwrap_or(0); + let new = (current + 1) % self.state.searches.len(); + self.state.searches_table_state.select(Some(new)); + } + KeyCode::Enter => { + if let Some(selected) = + self.state.searches_table_state.selected() + { + self.state.selected_search_index = Some(selected); + if let Some(search) = self.state.searches.get(selected) { + self.state.results_items = search.results.clone(); + self.state.results_filtered_items = + search.results.clone(); + self.state.results_filtered_indices = + (0..search.results.len()).collect(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(Some(0)); + self.state.focused_pane = FocusedPane::Results; + } + } + } + KeyCode::Char('d') => { + if let Some(selected) = + self.state.searches_table_state.selected() + { + self.remove_search_at_index(selected); + } + } + KeyCode::Char('C') => { + self.clear_all_searches(); + } + _ => {} + } + } + + fn handle_results_input(&mut self, key: KeyEvent) { + let items_count = if self.state.results_filter_query.is_empty() { + self.state.results_items.len() + } else { + self.state.results_filtered_items.len() + }; + + match key.code { + KeyCode::Up | KeyCode::Char('k') if items_count > 0 => { + let current = + self.state.results_table_state.selected().unwrap_or(0); + let new = if current == 0 { + items_count - 1 + } else { + current - 1 + }; + self.state.results_table_state.select(Some(new)); + } + KeyCode::Down | KeyCode::Char('j') if items_count > 0 => { + let current = + self.state.results_table_state.selected().unwrap_or(0); + let new = (current + 1) % items_count; + self.state.results_table_state.select(Some(new)); + } + KeyCode::Char(' ') => { + if let Some(current) = self.state.results_table_state.selected() + { + let actual_index = + if self.state.results_filter_query.is_empty() { + current + } else { + self.state.results_filtered_indices[current] + }; + + if self + .state + .results_selected_indices + .contains(&actual_index) + { + self.state + .results_selected_indices + .remove(&actual_index); + } else { + self.state + .results_selected_indices + .insert(actual_index); + } + } + } + KeyCode::Char('/') => { + self.state.results_is_filtering = true; + self.state.results_filter_query.clear(); + } + KeyCode::Char('a') => { + let indices: Vec = + if self.state.results_filter_query.is_empty() { + (0..self.state.results_items.len()).collect() + } else { + self.state.results_filtered_indices.clone() + }; + self.state.results_selected_indices.extend(indices); + } + KeyCode::Char('A') => { + self.state.results_selected_indices.clear(); + } + KeyCode::Enter => { + self.queue_selected_downloads(); + } + _ => {} + } + } + + fn handle_downloads_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Up | KeyCode::Char('k') + if !self.state.downloads.is_empty() => + { + let current = + self.state.downloads_table_state.selected().unwrap_or(0); + let new = if current == 0 { + self.state.downloads.len() - 1 + } else { + current - 1 + }; + self.state.downloads_table_state.select(Some(new)); + } + KeyCode::Down | KeyCode::Char('j') + if !self.state.downloads.is_empty() => + { + let current = + self.state.downloads_table_state.selected().unwrap_or(0); + let new = (current + 1) % self.state.downloads.len(); + self.state.downloads_table_state.select(Some(new)); + } + KeyCode::Char('p') => { + self.toggle_selected_download_pause(); + } + KeyCode::Char('d') => { + self.remove_selected_queued_download(); + } + KeyCode::Char('r') => { + self.retry_selected_download(); + } + KeyCode::Char('c') => { + self.clear_finished_downloads(); + } + _ => {} + } + } + + pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + let (col, row) = (mouse.column, mouse.row); + + // Check if click is within searches pane + if let Some(area) = self.state.searches_pane_area + && col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Searches; + return; + } + + // Check if click is within results pane + if let Some(area) = self.state.results_pane_area + && col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Results; + return; + } + + // Check if click is within downloads pane + if let Some(area) = self.state.downloads_pane_area + && col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.state.focused_pane = FocusedPane::Downloads; + } + } + } +} + +pub(super) const fn clamp_cursor_to_char_boundary( + input: &str, + cursor_position: usize, +) -> usize { + if cursor_position >= input.len() { + return input.len(); + } + + let mut cursor_position = cursor_position; + while cursor_position > 0 && !input.is_char_boundary(cursor_position) { + cursor_position -= 1; + } + cursor_position +} + +fn previous_char_boundary(input: &str, cursor_position: usize) -> usize { + let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); + + input[..cursor_position] + .char_indices() + .last() + .map_or(0, |(index, _)| index) +} + +fn next_char_boundary(input: &str, cursor_position: usize) -> usize { + let cursor_position = clamp_cursor_to_char_boundary(input, cursor_position); + + if cursor_position >= input.len() { + return input.len(); + } + + cursor_position + + input[cursor_position..] + .chars() + .next() + .map_or(0, char::len_utf8) +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs new file mode 100644 index 0000000..4513bc2 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -0,0 +1,122 @@ +mod browse; +mod downloads; +mod input; +mod render; +mod rooms; +mod search; + +use crate::models::AppState; +use color_eyre::Result; +use ratatui::{ + DefaultTerminal, + crossterm::event::{self, Event, KeyEventKind, poll}, +}; +use soulseek_rs::Client; +use std::{sync::Arc, time::Duration}; + +pub struct MainTui { + client: Arc, + state: AppState, + download_dir: String, + #[allow(dead_code)] + max_concurrent_downloads: usize, + search_timeout: Duration, + #[allow(dead_code)] + spinner_state: usize, +} + +impl MainTui { + pub fn new( + client: Arc, + download_dir: String, + max_concurrent_downloads: usize, + search_timeout: Duration, + ) -> Self { + Self { + client, + state: AppState::new(), + download_dir, + max_concurrent_downloads, + search_timeout, + spinner_state: 0, + } + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + use ratatui::crossterm::{event::DisableMouseCapture, execute}; + + // Run the event loop, then restore the terminal unconditionally: if the + // loop returns early with an error the terminal must still be taken out + // of raw mode / the alternate screen and mouse capture disabled, or the + // user is left with a corrupted terminal. + let result = self.run_event_loop(&mut terminal); + + let _ = execute!(std::io::stdout(), DisableMouseCapture); + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + + result + } + + fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { + while !self.state.should_exit { + terminal.draw(|frame| self.render(frame))?; + + // Poll for search results updates + self.update_search_results(); + + // Poll for download updates + self.update_downloads(); + + // Poll for incoming private messages + self.poll_private_messages(); + + // Poll for a browse (shared-file listing) response + self.poll_browse_result(); + + // Poll for chat-room events + self.poll_room_events(); + + self.spinner_state = (self.spinner_state + 1) % 10; + + // Drain every queued input event before the next draw: key + // autorepeat outpaces the frame time, and handling one event per + // frame makes the backlog keep scrolling for seconds after the + // key is released. + if poll(Duration::from_millis(100))? { + loop { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key_event(key); + } + Event::Mouse(mouse) => { + self.handle_mouse_event(mouse); + } + _ => {} + } + if self.state.should_exit || !poll(Duration::ZERO)? { + break; + } + } + } + } + + Ok(()) + } +} + +pub fn launch_main_tui( + terminal: DefaultTerminal, + client: Arc, + download_dir: String, + max_concurrent_downloads: usize, + search_timeout: Duration, +) -> Result<()> { + let tui = MainTui::new( + client, + download_dir, + max_concurrent_downloads, + search_timeout, + ); + tui.run(terminal) +} diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs new file mode 100644 index 0000000..d059101 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -0,0 +1,452 @@ +use super::MainTui; +use crate::models::{CommandBarMode, FocusedPane, MessageDirection, 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, +}; +use crate::ui::{ + border_style, border_type, format_shortcuts_styled, render_download_stats, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Position, Rect}, + widgets::{Block, Borders, Paragraph}, +}; + +const COMMAND_BAR_PREFIX: &str = "search: "; +const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): "; +const BROWSE_BAR_PREFIX: &str = "browse user: "; + +const fn command_bar_prefix(mode: CommandBarMode) -> &'static str { + match mode { + CommandBarMode::Search => COMMAND_BAR_PREFIX, + CommandBarMode::Message => MESSAGE_BAR_PREFIX, + CommandBarMode::Browse => BROWSE_BAR_PREFIX, + } +} + +impl MainTui { + pub(super) fn render(&mut self, frame: &mut Frame) { + let main_chunks = if self.state.command_bar_active { + Layout::vertical([ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + Constraint::Length(3), // Command bar + Constraint::Length(3), // Shortcuts + ]) + .split(frame.area()) + } else { + Layout::vertical([ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + Constraint::Length(3), // Shortcuts + ]) + .split(frame.area()) + }; + + render_download_stats( + frame, + main_chunks[0], + &self.state.downloads, + self.state.active_downloads_count, + ); + + self.render_content(frame, main_chunks[1]); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3]); + } else { + self.render_shortcuts(frame, main_chunks[2]); + } + + self.render_overlays(frame); + } + + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + // Split main content area + let content_chunks = Layout::horizontal([ + Constraint::Percentage(30), // Searches pane + Constraint::Percentage(70), // Results + Downloads + ]) + .split(area); + + // Store searches pane area + self.state.searches_pane_area = Some(content_chunks[0]); + + // Render Searches pane (left) + render_searches_pane( + frame, + content_chunks[0], + &self.state.searches, + &mut self.state.searches_table_state, + self.state.focused_pane == FocusedPane::Searches, + ); + + // Split right side into Results (top) and Downloads (bottom) + let right_chunks = Layout::vertical([ + Constraint::Percentage(60), // Results + Constraint::Percentage(40), // Downloads + ]) + .split(content_chunks[1]); + + // Split bottom-right into Downloads list and Info pane + let downloads_chunks = Layout::horizontal([ + Constraint::Percentage(60), // Downloads list + Constraint::Percentage(40), // Info pane + ]) + .split(right_chunks[1]); + + // Store results and downloads pane areas + self.state.results_pane_area = Some(right_chunks[0]); + self.state.downloads_pane_area = Some(downloads_chunks[0]); + + // Render Results pane. When a filter is active the rendered rows are a + // subset, so the pane also needs the mapping back to unfiltered indices + // to render the selection checkboxes correctly. + let (results_items, results_original_indices) = + if self.state.results_filter_query.is_empty() { + (&self.state.results_items, None) + } else { + ( + &self.state.results_filtered_items, + Some(self.state.results_filtered_indices.as_slice()), + ) + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + right_chunks[0], + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + original_indices: results_original_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused: self.state.focused_pane == FocusedPane::Results, + active_search_query, + }, + ); + + render_downloads_pane( + frame, + downloads_chunks[0], + &self.state.downloads, + &mut self.state.downloads_table_state, + self.state.focused_pane == FocusedPane::Downloads, + ); + + let selected_download = self + .state + .downloads_table_state + .selected() + .and_then(|idx| self.state.downloads.get(idx)); + render_download_info_pane( + frame, + downloads_chunks[1], + selected_download, + self.state.focused_pane == FocusedPane::Downloads, + ); + } + + fn render_overlays(&mut self, frame: &mut Frame) { + // Messages inbox overlays everything when open. + if self.state.show_messages { + self.render_messages_popup(frame); + } + + // Browse tree overlays everything when open. + if self.state.show_browse && !self.state.browse.is_empty() { + let area = centered_rect(80, 80, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + &self.state.browse, + &mut self.state.browse_table_state, + self.spinner_state, + ); + } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } + } + + fn render_messages_popup(&self, frame: &mut Frame) { + 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( + Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)) + .title(" Messages (m: compose, i/Esc: close) "), + ); + + frame.render_widget(ratatui::widgets::Clear, area); + frame.render_widget(popup, area); + } + + /// Context shortcuts for the chat-rooms popup. + fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { + if self.state.rooms.composing { + return vec![ + ("Type", "message"), + ("Enter", "send"), + ("Esc", "cancel"), + ]; + } + match self.state.rooms.view { + RoomsView::List => { + if self.state.rooms.list_is_filtering { + vec![ + ("Type", "filter"), + ("Enter", "join match"), + ("Esc", "clear filter"), + ] + } else { + vec![ + ("↑↓", "move"), + ("Enter", "join"), + ("/", "filter"), + ("Tab", "open rooms"), + ("Esc", "close"), + ] + } + } + RoomsView::Chat => vec![ + ("Enter", "say"), + ("↑↓", "pick user"), + ("b", "browse user"), + ("m", "message user"), + ("Tab", "switch room"), + ("l", "rooms"), + ("x", "leave"), + ], + } + } + + 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.show_rooms { + self.rooms_shortcuts() + } else if self.state.show_browse { + vec![ + ("↑↓", "move"), + ("→←", "expand/collapse"), + ("Enter", "open/download"), + ("d", "download folder"), + ("Tab", "switch user"), + ("r", "retry"), + ("w", "close tab"), + ("Esc", "hide"), + ] + } else if self.state.command_bar_active { + match self.state.command_bar_mode { + CommandBarMode::Search => vec![ + ("Type", "search term"), + ("←→", "move cursor"), + ("Backspace/Del", "edit"), + ("Enter", "search"), + ("Esc", "cancel"), + ], + CommandBarMode::Message => vec![ + ("Type", "recipient then message"), + ("Enter", "send"), + ("Esc", "cancel"), + ], + CommandBarMode::Browse => vec![ + ("Type", "username"), + ("Enter", "browse"), + ("Esc", "cancel"), + ], + } + } else { + match self.state.focused_pane { + FocusedPane::Searches => vec![ + ("s", "search"), + ("m", "message"), + ("i", inbox_label.as_str()), + ("c", chat_label.as_str()), + ("b", "browse user"), + ("1-3", "focus pane"), + ("↑↓", "navigate"), + ("Enter", "results"), + ("q", "quit"), + ], + FocusedPane::Results if self.state.results_is_filtering => { + vec![ + ("Type", "filter"), + ("Esc", "clear filter"), + ("1-3", "focus pane"), + ("q", "quit"), + ] + } + FocusedPane::Results => vec![ + ("Space", "select"), + ("Enter", "download"), + ("b", "browse owner"), + ("c", chat_label.as_str()), + ("/", "filter"), + ("a/A", "select all/none"), + ("1-3", "focus pane"), + ("q", "quit"), + ], + FocusedPane::Downloads => { + vec![ + ("p", "pause/resume"), + ("r", "retry failed"), + ("d", "delete queued"), + ("c", "clear finished"), + ("b", "browse user"), + ("1-3", "focus pane"), + ("q", "quit"), + ] + } + } + }; + + let shortcuts_line = format_shortcuts_styled(&shortcuts); + // Surface our own sharing status in the block title. + let title = format!( + "Shortcuts · Sharing: {}", + self.client.shared_directory().unwrap_or("off") + ); + let shortcuts_widget = Paragraph::new(shortcuts_line).block( + Block::default() + .borders(Borders::ALL) + .border_type(border_type(false)) + .title(title), + ); + + frame.render_widget(shortcuts_widget, area); + } + + fn render_command_bar(&self, frame: &mut Frame, area: Rect) { + let prefix = command_bar_prefix(self.state.command_bar_mode); + let content_width = area.width.saturating_sub(2); + let prefix_width = prefix.chars().count() as u16; + let input_width = content_width.saturating_sub(prefix_width); + let (visible_input, cursor_column) = visible_input_at_cursor( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + input_width, + ); + let command_text = format!("{prefix}{visible_input}"); + + let paragraph = Paragraph::new(command_text).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)), + ); + + frame.render_widget(paragraph, area); + + if area.width > 2 && area.height > 2 { + let cursor_x = area + .x + .saturating_add(1) + .saturating_add(prefix_width) + .saturating_add(cursor_column) + .min(area.x.saturating_add(area.width.saturating_sub(2))); + frame.set_cursor_position(Position::new(cursor_x, area.y + 1)); + } + } +} + +/// A `Rect` centered within `area`, sized to the given percentages. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +fn visible_input_at_cursor( + input: &str, + cursor_position: usize, + width: u16, +) -> (String, u16) { + if width == 0 { + return (String::new(), 0); + } + + let cursor_position = + super::input::clamp_cursor_to_char_boundary(input, cursor_position); + let cursor_character_index = input[..cursor_position].chars().count(); + let max_cursor_column = usize::from(width.saturating_sub(1)); + let start_character_index = + cursor_character_index.saturating_sub(max_cursor_column); + + let visible_input = input + .chars() + .skip(start_character_index) + .take(usize::from(width)) + .collect(); + let cursor_column = cursor_character_index + .saturating_sub(start_character_index) + .min(max_cursor_column); + + (visible_input, cursor_column as u16) +} diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs new file mode 100644 index 0000000..8b08e0f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -0,0 +1,229 @@ +use super::MainTui; +use crate::models::{CommandBarMode, RoomsView}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +impl MainTui { + /// Open the chat-rooms popup and refresh the room list. If rooms are + /// already open, jump straight to the chat view; otherwise show the list. + pub(super) fn start_rooms(&mut self) { + let _ = self.client.request_room_list(); + self.state.show_rooms = true; + if self.state.rooms.open.is_empty() { + self.state.rooms.view = RoomsView::List; + } else { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + } + + pub(super) fn handle_rooms_input(&mut self, key: KeyEvent) { + // Composing a message captures typing. + if self.state.rooms.composing { + self.handle_room_compose_input(key); + return; + } + // Filtering the room list captures typing. + if self.state.rooms.view == RoomsView::List + && self.state.rooms.list_is_filtering + { + self.handle_room_filter_input(key); + return; + } + + match self.state.rooms.view { + RoomsView::List => self.handle_rooms_list_input(key), + RoomsView::Chat => self.handle_rooms_chat_input(key), + } + } + + fn handle_rooms_list_input(&mut self, key: KeyEvent) { + let len = self.state.rooms.filtered_rooms().len(); + match key.code { + // Esc peels back one level: clear a lingering filter first (as the + // title's "Esc: clear" promises), otherwise close the popup. + KeyCode::Esc if !self.state.rooms.list_filter.is_empty() => { + self.state.rooms.list_filter.clear(); + self.state.rooms.list_selected = 0; + } + KeyCode::Esc | KeyCode::Char('q') => { + self.state.show_rooms = false; + } + KeyCode::Char('/') => { + self.state.rooms.list_is_filtering = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.list_selected = + self.state.rooms.list_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if len > 0 => { + self.state.rooms.list_selected = + (self.state.rooms.list_selected + 1).min(len - 1); + } + KeyCode::Enter => self.join_selected_room(), + KeyCode::Tab if !self.state.rooms.open.is_empty() => { + self.state.rooms.view = RoomsView::Chat; + self.state.rooms.mark_active_read(); + } + _ => {} + } + } + + fn handle_room_filter_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.rooms.list_is_filtering = false; + self.state.rooms.list_filter.clear(); + self.state.rooms.list_selected = 0; + } + KeyCode::Enter => { + self.state.rooms.list_is_filtering = false; + self.join_selected_room(); + } + KeyCode::Char(c) => { + self.state.rooms.list_filter.push(c); + self.state.rooms.list_selected = 0; + } + KeyCode::Backspace => { + self.state.rooms.list_filter.pop(); + self.state.rooms.list_selected = 0; + } + _ => {} + } + } + + fn handle_rooms_chat_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.state.show_rooms = false, + KeyCode::Esc | KeyCode::Char('l') => { + self.state.rooms.view = RoomsView::List; + } + KeyCode::Tab => self.state.rooms.next_tab(), + KeyCode::BackTab => self.state.rooms.prev_tab(), + KeyCode::Char('x') => self.leave_active_room(), + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.select_user_up(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.rooms.select_user_down(); + } + // Act on the highlighted member of the room. + KeyCode::Char('b') => self.browse_selected_room_user(), + KeyCode::Char('m') => self.message_selected_room_user(), + KeyCode::Enter if self.state.rooms.active_room().is_some() => { + self.state.rooms.composing = true; + } + _ => {} + } + } + + /// Browse the shares of the member highlighted in the active room. Closes + /// the rooms popup so the browse tree is the sole overlay. + fn browse_selected_room_user(&mut self) { + if let Some(user) = self.state.rooms.selected_user() { + self.state.show_rooms = false; + self.start_browse(user); + } + } + + /// 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. + 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(); + } + } + + fn handle_room_compose_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => self.send_room_message(), + KeyCode::Esc => { + self.state.rooms.composing = false; + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.clear(); + } + } + KeyCode::Backspace => { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.pop(); + } + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + if let Some(room) = + self.state.rooms.open.get_mut(self.state.rooms.active) + { + room.input.push(c); + } + } + _ => {} + } + } + + /// Join (or focus) the room highlighted in the list view. + fn join_selected_room(&mut self) { + if let Some(name) = self.state.rooms.selected_room_name() { + let newly_opened = self.state.rooms.focus_or_open(&name); + if newly_opened && let Err(e) = self.client.join_room(&name) { + soulseek_rs::warn!("Failed to join {name}: {e}"); + } + } + } + + /// Leave the active room and close its tab. + fn leave_active_room(&mut self) { + if let Some(name) = self.state.rooms.close_active() + && let Err(e) = self.client.leave_room(&name) + { + soulseek_rs::warn!("Failed to leave {name}: {e}"); + } + } + + /// Send the active room's compose buffer. The server echoes the message + /// back as a RoomEvent, which is what actually renders it in the log. + fn send_room_message(&mut self) { + let active = self.state.rooms.active; + let (room, text) = match self.state.rooms.open.get(active) { + Some(room) if !room.input.trim().is_empty() => { + (room.name.clone(), room.input.trim().to_string()) + } + _ => { + self.state.rooms.composing = false; + return; + } + }; + if let Err(e) = self.client.say_in_room(&room, &text) { + soulseek_rs::warn!("Failed to say in {room}: {e}"); + } + if let Some(room) = self.state.rooms.open.get_mut(active) { + room.input.clear(); + } + self.state.rooms.composing = false; + } + + /// Drain chat-room events into the rooms state, tracking unread badges. + pub(super) fn poll_room_events(&mut self) { + let viewing = if self.state.show_rooms + && self.state.rooms.view == RoomsView::Chat + { + self.state.rooms.active_room().map(|r| r.name.clone()) + } else { + None + }; + for event in self.client.take_room_events() { + self.state.rooms.apply_event(event, viewing.as_deref()); + } + } +} diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs new file mode 100644 index 0000000..db919ec --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -0,0 +1,324 @@ +use super::MainTui; +use crate::models::{ + ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, + SearchStatus, +}; +use std::{ + sync::{Arc, atomic::AtomicBool}, + thread, + time::Instant, +}; + +impl MainTui { + pub(super) fn remove_search_at_index(&mut self, index: usize) { + if index >= self.state.searches.len() { + return; + } + + // Cancel the search if it's active + if let Some(search) = self.state.searches.get(index) { + search + .cancel_flag + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + // Check if we're removing the currently active search + let was_active_search = self.state.selected_search_index == Some(index); + + self.state.searches.remove(index); + + if let Some(current_idx) = self.state.selected_search_index { + if current_idx == index { + // Removed the active search - clear it + self.state.selected_search_index = None; + } else if current_idx > index { + // Active search was after removed one - decrement index + self.state.selected_search_index = Some(current_idx - 1); + } + // If current_idx < index, no change needed + } + + // If we removed the active search, clear results pane + if was_active_search { + self.clear_results_pane(); + } + + if self.state.searches.is_empty() { + self.state.searches_table_state.select(None); + } else { + let new_selection = if index >= self.state.searches.len() { + self.state.searches.len() - 1 + } else { + index + }; + self.state.searches_table_state.select(Some(new_selection)); + } + } + + pub(super) fn clear_all_searches(&mut self) { + // Cancel all active searches + for search in &self.state.searches { + search + .cancel_flag + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + // Clear searches + self.state.searches.clear(); + self.state.searches_table_state.select(None); + self.state.selected_search_index = None; + + // Clear results pane + self.clear_results_pane(); + } + + fn clear_results_pane(&mut self) { + self.state.results_items.clear(); + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(None); + self.state.results_filter_query.clear(); + self.state.results_is_filtering = false; + } + + fn recompute_results_filter(&mut self) { + let (items, indices) = filter_results( + &self.state.results_items, + &self.state.results_filter_query, + ); + self.state.results_filtered_items = items; + self.state.results_filtered_indices = indices; + } + + pub(super) fn apply_filter(&mut self) { + self.recompute_results_filter(); + if !self.state.results_filtered_items.is_empty() { + self.state.results_table_state.select(Some(0)); + } + } + + /// Drain any private messages received since the last tick into the inbox. + pub(super) fn poll_private_messages(&mut self) { + for msg in self.client.take_private_messages() { + self.state.messages.push(ChatMessage { + direction: MessageDirection::Incoming, + peer: msg.username().to_string(), + text: msg.message().to_string(), + }); + // Badge the inbox when it isn't currently open. + if !self.state.show_messages { + self.state.unread_messages += 1; + } + } + } + + /// Parse a ` ` compose line and send it. + pub(super) fn send_message_from_input(&mut self, input: &str) { + let Some((recipient, text)) = input.split_once(char::is_whitespace) + else { + return; + }; + let recipient = recipient.trim(); + let text = text.trim(); + if recipient.is_empty() || text.is_empty() { + return; + } + + 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(), + }), + Err(e) => soulseek_rs::warn!("Failed to send message: {e}"), + } + } + + pub(super) fn start_search(&mut self, query: String) { + let cancel_flag = Arc::new(AtomicBool::new(false)); + let search_entry = SearchEntry { + query: query.clone(), + status: SearchStatus::Active, + results: Vec::new(), + start_time: Instant::now(), + cancel_flag: cancel_flag.clone(), + }; + + self.state.searches.push(search_entry); + let search_index = self.state.searches.len() - 1; + self.state.searches_table_state.select(Some(search_index)); + + // Make this search the active one + self.state.selected_search_index = Some(search_index); + + // Initialize results display (empty at first) + self.state.results_items.clear(); + self.state.results_filtered_items.clear(); + self.state.results_filtered_indices.clear(); + self.state.results_selected_indices.clear(); + self.state.results_table_state.select(Some(0)); + + // Switch focus to Results pane + self.state.focused_pane = FocusedPane::Results; + + let client = self.client.clone(); + let timeout = self.search_timeout; + + thread::spawn(move || { + match client.search_with_cancel( + &query, + timeout, + Some(cancel_flag.clone()), + ) { + Ok(_results) => { + // Results will be polled in update_search_results + } + Err(e) => { + soulseek_rs::warn!("Search failed: {e}"); + } + } + }); + } + + pub(super) fn update_search_results(&mut self) { + let timeout = self.search_timeout; + let selected_search_index = self.state.selected_search_index; + + // Fetch all results in one go (single lock acquisition per query) + // Use try_get_search_results to avoid blocking the UI thread + let all_results: Vec<(usize, Vec<_>)> = self + .state + .searches + .iter() + .enumerate() + .map(|(idx, s)| (idx, s.query.clone())) + .filter_map(|(idx, query)| { + self.client + .try_get_search_results(&query) + .map(|results| (idx, results)) + }) + .collect(); + + // Now update state without holding any client locks + for (idx, search_results) in all_results { + if let Some(search) = self.state.searches.get_mut(idx) { + // Results only accumulate, so an unchanged file count means + // nothing new arrived: skip the rebuild, which clones the + // full result list several times and dominates frame time. + let total_files: usize = + search_results.iter().map(|r| r.files.len()).sum(); + if total_files != search.results.len() { + search.results.clear(); + for result in search_results { + for file in result.files { + search.results.push(FileDisplayData { + filename: file.name.clone(), + size: file.size, + username: result.username.clone(), + speed: result.speed, + slots: result.slots, + bitrate: file.attribs.get(&0).copied(), + length_seconds: file.attribs.get(&1).copied(), + }); + } + } + + // Update selected search if this is the active one. Re-derive + // the filtered view from the current query so an active + // filter is preserved as new results stream in, rather than + // being clobbered by the full unfiltered list. + if let Some(selected_idx) = selected_search_index + && selected_idx == idx + { + self.state.results_items = search.results.clone(); + let (items, indices) = filter_results( + &self.state.results_items, + &self.state.results_filter_query, + ); + self.state.results_filtered_items = items; + self.state.results_filtered_indices = indices; + } + } + + // Mark as completed after timeout + if search.status == SearchStatus::Active + && search.start_time.elapsed() > timeout + { + search.status = SearchStatus::Completed; + } + } + } + } +} + +/// Filter `items` by a case-insensitive substring match on filename or +/// username. Returns the matching items alongside their indices in the original +/// list, so callers can translate a filtered display index back to the +/// unfiltered results. An empty query returns everything (identity mapping). +fn filter_results( + items: &[FileDisplayData], + query: &str, +) -> (Vec, Vec) { + let query = query.to_lowercase(); + if query.is_empty() { + return (items.to_vec(), (0..items.len()).collect()); + } + + let mut filtered_items = Vec::new(); + let mut filtered_indices = Vec::new(); + for (idx, item) in items.iter().enumerate() { + if item.filename.to_lowercase().contains(&query) + || item.username.to_lowercase().contains(&query) + { + filtered_items.push(item.clone()); + filtered_indices.push(idx); + } + } + (filtered_items, filtered_indices) +} + +#[cfg(test)] +mod tests { + use super::filter_results; + use crate::models::FileDisplayData; + + fn file(filename: &str, username: &str) -> FileDisplayData { + FileDisplayData { + filename: filename.to_string(), + username: username.to_string(), + ..Default::default() + } + } + + #[test] + fn empty_query_returns_identity_mapping() { + let items = vec![file("a.mp3", "bob"), file("b.flac", "amy")]; + let (filtered, indices) = filter_results(&items, ""); + assert_eq!(filtered.len(), 2); + assert_eq!(indices, vec![0, 1]); + } + + #[test] + fn query_matches_filename_and_username_and_maps_indices() { + let items = vec![ + file("track.mp3", "bob"), + file("song.flac", "alice"), + file("alice_demo.mp3", "carol"), + ]; + // "alice" matches item 1 (username) and item 2 (filename). + let (filtered, indices) = filter_results(&items, "alice"); + assert_eq!(filtered.len(), 2); + assert_eq!(indices, vec![1, 2]); + assert_eq!(filtered[0].filename, "song.flac"); + assert_eq!(filtered[1].filename, "alice_demo.mp3"); + } + + #[test] + fn query_is_case_insensitive() { + let items = vec![file("The Weeknd.mp3", "dj")]; + let (filtered, indices) = filter_results(&items, "WEEKND"); + assert_eq!(filtered.len(), 1); + assert_eq!(indices, vec![0]); + } +}