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.
This commit is contained in:
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -74,7 +74,6 @@ impl ActorSystem {
|
||||
let (sender, receiver) = channel::<ActorMessage<A::Message>>();
|
||||
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
|
||||
|
||||
@@ -199,6 +199,62 @@ impl PeerActor {
|
||||
self.send_message(message);
|
||||
}
|
||||
PeerMessage::FileSearchResult(file_search) => {
|
||||
self.handle_file_search_result(file_search);
|
||||
}
|
||||
PeerMessage::TransferRequest(transfer) => {
|
||||
self.handle_transfer_request(transfer);
|
||||
}
|
||||
PeerMessage::TransferResponse {
|
||||
token,
|
||||
allowed,
|
||||
reason,
|
||||
} => {
|
||||
self.handle_transfer_response(token, allowed, reason);
|
||||
}
|
||||
PeerMessage::PlaceInQueueResponse { filename, place } => {
|
||||
self.handle_place_in_queue_response(filename, place);
|
||||
}
|
||||
PeerMessage::SetUsername(username) => {
|
||||
self.handle_set_username(username);
|
||||
}
|
||||
PeerMessage::QueueUpload(filename) => {
|
||||
let message =
|
||||
MessageFactory::build_queue_upload_message(&filename);
|
||||
self.send_message(message);
|
||||
}
|
||||
PeerMessage::IncomingQueueUpload(filename) => {
|
||||
self.handle_incoming_queue_upload(filename);
|
||||
}
|
||||
PeerMessage::ServeUpload {
|
||||
token,
|
||||
filename,
|
||||
size,
|
||||
} => {
|
||||
self.handle_serve_upload(token, filename, size);
|
||||
}
|
||||
PeerMessage::ShareListRequested => {
|
||||
self.handle_share_list_requested();
|
||||
}
|
||||
PeerMessage::ShareListReceived(directories) => {
|
||||
self.handle_share_list_received(directories);
|
||||
}
|
||||
PeerMessage::RequestTransfer(download) => {
|
||||
let message = MessageFactory::build_transfer_request_message(
|
||||
&download.filename,
|
||||
download.token,
|
||||
);
|
||||
self.send_message(message);
|
||||
}
|
||||
PeerMessage::ProcessRead => {
|
||||
self.process_read();
|
||||
}
|
||||
PeerMessage::UploadFailed(username, filename) => {
|
||||
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))
|
||||
@@ -210,19 +266,18 @@ impl PeerActor {
|
||||
);
|
||||
}
|
||||
}
|
||||
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(
|
||||
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
|
||||
@@ -242,11 +297,13 @@ impl PeerActor {
|
||||
);
|
||||
}
|
||||
}
|
||||
PeerMessage::TransferResponse {
|
||||
token,
|
||||
allowed,
|
||||
reason,
|
||||
} => {
|
||||
|
||||
fn handle_transfer_response(
|
||||
&mut self,
|
||||
token: u32,
|
||||
allowed: bool,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
let username = self.peer_username();
|
||||
debug!(
|
||||
"[peer:{}] transfer response token: {} allowed: {}",
|
||||
@@ -273,13 +330,13 @@ impl PeerActor {
|
||||
let Some(peer_snapshot) = self.peer_snapshot() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = self.client_channel.send(
|
||||
ClientOperation::DownloadFromPeer(
|
||||
if let Err(e) =
|
||||
self.client_channel.send(ClientOperation::DownloadFromPeer(
|
||||
token,
|
||||
peer_snapshot,
|
||||
allowed,
|
||||
),
|
||||
) {
|
||||
))
|
||||
{
|
||||
error!(
|
||||
"[peer:{}] failed to send DownloadFromPeer: {}",
|
||||
username, e
|
||||
@@ -292,31 +349,30 @@ impl PeerActor {
|
||||
);
|
||||
}
|
||||
}
|
||||
PeerMessage::PlaceInQueueResponse { filename, place } => {
|
||||
|
||||
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 {
|
||||
if let Err(e) =
|
||||
self.client_channel
|
||||
.send(ClientOperation::PlaceInQueueUpdate {
|
||||
username: username.clone(),
|
||||
filename,
|
||||
place,
|
||||
},
|
||||
) {
|
||||
})
|
||||
{
|
||||
error!(
|
||||
"[peer:{}] failed to forward PlaceInQueueUpdate: {}",
|
||||
username, e
|
||||
);
|
||||
}
|
||||
}
|
||||
PeerMessage::SetUsername(username) => {
|
||||
trace!(
|
||||
"[peer:{}] SetUsername: {}",
|
||||
self.peer_username(),
|
||||
username
|
||||
);
|
||||
|
||||
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) => {
|
||||
@@ -324,36 +380,28 @@ impl PeerActor {
|
||||
}
|
||||
}
|
||||
}
|
||||
PeerMessage::QueueUpload(filename) => {
|
||||
let message =
|
||||
MessageFactory::build_queue_upload_message(&filename);
|
||||
self.send_message(message);
|
||||
}
|
||||
PeerMessage::IncomingQueueUpload(filename) => {
|
||||
|
||||
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 {
|
||||
if let Err(e) = self.client_channel.send(ClientOperation::QueueUpload {
|
||||
requester_key,
|
||||
filename,
|
||||
})
|
||||
{
|
||||
}) {
|
||||
error!("[peer_actor] forward IncomingQueueUpload: {}", e);
|
||||
}
|
||||
}
|
||||
PeerMessage::ServeUpload {
|
||||
token,
|
||||
filename,
|
||||
size,
|
||||
} => {
|
||||
|
||||
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);
|
||||
}
|
||||
PeerMessage::ShareListRequested => {
|
||||
|
||||
fn handle_share_list_requested(&self) {
|
||||
let requester_key = self.peer_username();
|
||||
if let Err(e) = self
|
||||
.client_channel
|
||||
@@ -362,7 +410,8 @@ impl PeerActor {
|
||||
error!("[peer_actor] forward ShareListRequested: {}", e);
|
||||
}
|
||||
}
|
||||
PeerMessage::ShareListReceived(directories) => {
|
||||
|
||||
fn handle_share_list_received(&self, directories: Vec<SharedDirectory>) {
|
||||
let username = self.peer_username();
|
||||
if let Err(e) =
|
||||
self.client_channel.send(ClientOperation::BrowseResult {
|
||||
@@ -373,27 +422,13 @@ impl PeerActor {
|
||||
error!("[peer_actor] forward BrowseResult: {}", e);
|
||||
}
|
||||
}
|
||||
PeerMessage::RequestTransfer(download) => {
|
||||
let message = MessageFactory::build_transfer_request_message(
|
||||
&download.filename,
|
||||
download.token,
|
||||
);
|
||||
self.send_message(message);
|
||||
}
|
||||
PeerMessage::ProcessRead => {
|
||||
self.process_read();
|
||||
}
|
||||
PeerMessage::UploadFailed(username, filename) => {
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
error!("[peer_actor] failed to forward UploadFailed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
if let Err(e) = self.client_channel.send(
|
||||
ClientOperation::GetPeerAddressResponse {
|
||||
self.handle_get_peer_address_response(
|
||||
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,6 +486,105 @@ impl ServerActor {
|
||||
password,
|
||||
response,
|
||||
} => {
|
||||
self.handle_login(username, password, response);
|
||||
}
|
||||
ServerMessage::FileSearch { token, query } => {
|
||||
self.file_search(token, &query);
|
||||
}
|
||||
ServerMessage::FileSearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
} => {
|
||||
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<Result<bool, SoulseekRs>>,
|
||||
) {
|
||||
self.queue_message(MessageFactory::build_login_message(
|
||||
&username, &password,
|
||||
));
|
||||
@@ -573,14 +621,13 @@ impl ServerActor {
|
||||
}
|
||||
});
|
||||
}
|
||||
ServerMessage::FileSearch { token, query } => {
|
||||
self.file_search(token, &query);
|
||||
}
|
||||
ServerMessage::FileSearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
} => {
|
||||
|
||||
fn handle_file_search_request(
|
||||
&self,
|
||||
username: String,
|
||||
token: u32,
|
||||
query: String,
|
||||
) {
|
||||
if let Err(e) =
|
||||
self.client_channel.send(ClientOperation::IncomingSearch {
|
||||
username,
|
||||
@@ -591,8 +638,6 @@ impl ServerActor {
|
||||
error!("[server] forward IncomingSearch: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_read(&mut self) {
|
||||
if self.reader.buffer_len() > 0 {
|
||||
@@ -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())
|
||||
|
||||
@@ -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<ClientOperation>,
|
||||
Receiver<ClientOperation>,
|
||||
) = 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<bool> {
|
||||
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<RwLock<ClientContext>>,
|
||||
own_username: String,
|
||||
stream: Option<TcpStream>,
|
||||
) {
|
||||
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<RwLock<ClientContext>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Download> {
|
||||
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<DownloadStatus>)> {
|
||||
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<DownloadStatus>)> {
|
||||
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<DownloadStatus>,
|
||||
Receiver<DownloadStatus>,
|
||||
) = 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<RwLock<ClientContext>>,
|
||||
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<DownloadStatus>)> = 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<crate::message::Message> {
|
||||
let matches = shares.search(query);
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entries: Vec<FileEntry> = 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<String>,
|
||||
}
|
||||
|
||||
impl ClientSettings {
|
||||
pub fn new(
|
||||
username: impl Into<String>,
|
||||
password: impl Into<String>,
|
||||
) -> 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<SoulseekRs>),
|
||||
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<ServerMessage>),
|
||||
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<SharedDirectory>,
|
||||
},
|
||||
/// 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<PeerRegistry>,
|
||||
pub downloads: DownloadStore,
|
||||
sender: Option<Sender<ClientOperation>>,
|
||||
server_sender: Option<Sender<ServerMessage>>,
|
||||
searches: HashMap<String, Search>,
|
||||
private_messages: Vec<UserMessage>,
|
||||
/// 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<u32, String>,
|
||||
/// Files we share with peers (read-only after connect).
|
||||
pub shares: Arc<Shares>,
|
||||
/// Peer listen addresses learned from GetPeerAddress responses.
|
||||
peer_addresses: HashMap<String, (String, u32)>,
|
||||
/// Peer messages waiting for a control connection to that peer.
|
||||
pending_peer_messages: HashMap<String, Vec<crate::message::Message>>,
|
||||
/// Uploads we have offered, keyed by our transfer token.
|
||||
uploads: HashMap<u32, UploadJob>,
|
||||
/// Upload tokens waiting for the downloader's address to be resolved.
|
||||
pending_serves: HashMap<String, Vec<u32>>,
|
||||
/// Shared-file listings received from peers we browsed.
|
||||
browse_results: HashMap<String, Vec<SharedDirectory>>,
|
||||
/// Latest snapshot of the public chat-room list (from `RoomList`, code 64).
|
||||
room_list: Vec<RoomInfo>,
|
||||
/// Chat-room events awaiting consumption by the client/UI.
|
||||
room_events: Vec<RoomEvent>,
|
||||
actor_system: Arc<ActorSystem>,
|
||||
}
|
||||
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<u32> {
|
||||
self.downloads.tokens()
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn get_downloads(&self) -> &Vec<Download> {
|
||||
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<RoomInfo> {
|
||||
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<RoomEvent> {
|
||||
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<crate::message::Message> {
|
||||
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<SharedDirectory>,
|
||||
) {
|
||||
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<Vec<SharedDirectory>> {
|
||||
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<String> {
|
||||
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<UserMessage> {
|
||||
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<String>,
|
||||
server_handle: Option<ActorHandle<ServerMessage>>,
|
||||
context: Arc<RwLock<ClientContext>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(
|
||||
username: impl Into<String>,
|
||||
password: impl Into<String>,
|
||||
) -> 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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<RoomInfo> {
|
||||
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<RoomEvent> {
|
||||
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<Vec<SharedDirectory>> {
|
||||
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<UserMessage> {
|
||||
match self.context.write_safe() {
|
||||
Ok(mut ctx) => ctx.take_private_messages(),
|
||||
Err(e) => {
|
||||
error!("[client] take_private_messages: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<SearchResult>> {
|
||||
self.search_with_cancel(query, timeout, None)
|
||||
}
|
||||
|
||||
pub fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
cancel_flag: Option<Arc<AtomicBool>>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
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<SearchResult> {
|
||||
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<Vec<SearchResult>> {
|
||||
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<String, Search> {
|
||||
self.context
|
||||
.read_safe()
|
||||
.map(|ctx| ctx.searches.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -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<RwLock<ClientContext>>,
|
||||
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<RwLock<ClientContext>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -59,9 +59,7 @@ impl MessageHandler<PeerMessage> for FileSearchResponse {
|
||||
return;
|
||||
};
|
||||
|
||||
sender
|
||||
.send(PeerMessage::FileSearchResult(file_search))
|
||||
.unwrap();
|
||||
let _ = sender.send(PeerMessage::FileSearchResult(file_search));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,6 @@ impl MessageHandler<PeerMessage> for PeerInit {
|
||||
username, connection_type, token
|
||||
);
|
||||
|
||||
sender.send(PeerMessage::SetUsername(username)).unwrap();
|
||||
let _ = sender.send(PeerMessage::SetUsername(username));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ impl MessageHandler<PeerMessage> 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@ impl MessageHandler<PeerMessage> for TransferRequest {
|
||||
fn handle(&self, message: &mut Message, sender: Sender<PeerMessage>) {
|
||||
let transfer = Transfer::new_from_message(message);
|
||||
|
||||
sender.send(PeerMessage::TransferRequest(transfer)).unwrap();
|
||||
let _ = sender.send(PeerMessage::TransferRequest(transfer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ impl MessageHandler<PeerMessage> for TransferResponse {
|
||||
let allowed = message.read_int8();
|
||||
let reason = (allowed == 0).then(|| message.read_string());
|
||||
|
||||
sender
|
||||
.send(PeerMessage::TransferResponse {
|
||||
let _ = sender.send(PeerMessage::TransferResponse {
|
||||
token,
|
||||
allowed: allowed == 1,
|
||||
reason,
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,12 @@ impl MessageHandler<ServerMessage> for GetPeerAddressHandler {
|
||||
let obfuscated_port = message.read_int32() as u16;
|
||||
crate::debug!("GetPeerAddressHandler: {username:?}");
|
||||
|
||||
sender
|
||||
.send(ServerMessage::GetPeerAddressResponse {
|
||||
let _ = sender.send(ServerMessage::GetPeerAddressResponse {
|
||||
username,
|
||||
host,
|
||||
port,
|
||||
obfuscation_type,
|
||||
obfuscated_port,
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ impl MessageHandler<ServerMessage> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -297,7 +297,6 @@ fn compute_md5_digest(mut v: Vec<u8>) -> 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<u8>) -> 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<u8>) -> 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<u8>) -> 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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+62
-91
@@ -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,76 +97,62 @@ 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 => {
|
||||
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},
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -181,34 +160,29 @@ fn main() -> Result<()> {
|
||||
.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)
|
||||
})?;
|
||||
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 _ =
|
||||
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),
|
||||
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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,8 +484,13 @@ impl FileSelector {
|
||||
frame.buffer_mut(),
|
||||
&mut self.state,
|
||||
);
|
||||
}
|
||||
|
||||
if self.items.is_empty() {
|
||||
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<Span> = Vec::new();
|
||||
@@ -526,7 +550,7 @@ impl FileSelector {
|
||||
frame.render_widget(loading_widget, horizontal[1]);
|
||||
}
|
||||
|
||||
if let Some(info_area) = info_area {
|
||||
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),
|
||||
@@ -565,10 +589,6 @@ impl FileSelector {
|
||||
frame.render_widget(info_widget, info_area);
|
||||
}
|
||||
|
||||
// Render controls footer
|
||||
self.render_controls(frame, controls_area);
|
||||
}
|
||||
|
||||
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
let controls_line = if self.is_filtering {
|
||||
format_shortcuts_styled(&[
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DownloadStatus>)> {
|
||||
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<FileDisplayData> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<usize> =
|
||||
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)
|
||||
}
|
||||
@@ -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<Client>,
|
||||
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<Client>,
|
||||
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<Client>,
|
||||
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)
|
||||
}
|
||||
@@ -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<ratatui::text::Line> = 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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `<recipient> <text>` 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<FileDisplayData>, Vec<usize>) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user