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