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:
Michel de Graaf
2026-07-24 21:18:20 +02:00
parent 105c798855
commit be5dc6c064
24 changed files with 1503 additions and 159 deletions
+114 -23
View File
@@ -300,6 +300,32 @@ impl MultiDownloadProgress {
}
}
/// Bytes transferred, bytes expected and combined speed across the downloads
/// that are actually running — or `None` when none are, so the caller can hide
/// the progress readout entirely.
///
/// Only `InProgress` downloads count. A queued download has not started and a
/// failed one never will, so adding their size to the total just pins the bar
/// near zero for the rest of the session.
fn active_progress(downloads: &[DownloadEntry]) -> Option<(u64, u64, f64)> {
let mut transferred = 0;
let mut expected = 0;
let mut speed = 0.0;
let mut running = false;
for entry in downloads {
if !matches!(entry.download.status, DownloadStatus::InProgress { .. }) {
continue;
}
running = true;
transferred += entry.download.bytes_downloaded();
expected += entry.download.size;
speed += entry.download.speed_bytes_per_sec();
}
running.then_some((transferred, expected, speed))
}
/// Renders download statistics in a reusable way
pub fn render_download_stats(
frame: &mut Frame,
@@ -329,29 +355,7 @@ pub fn render_download_stats(
.filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. }))
.count();
let total_downloaded: u64 = downloads
.iter()
.map(|d| d.download.bytes_downloaded())
.sum();
let total_size: u64 = downloads.iter().map(|d| d.download.size).sum();
let overall_progress = if total_size > 0 {
(total_downloaded as f64 / total_size as f64 * 100.0) as u8
} else {
0
};
let progress_ratio = if total_size > 0 {
total_downloaded as f64 / total_size as f64
} else {
0.0
};
let total_speed: f64 = downloads
.iter()
.filter(|d| {
matches!(d.download.status, DownloadStatus::InProgress { .. })
})
.map(|d| d.download.speed_bytes_per_sec())
.sum();
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
let active = active_progress(downloads);
let block = pane_block(false).title(plain_title("Status", false));
@@ -387,6 +391,23 @@ pub fn render_download_stats(
let stats_paragraph = Paragraph::new(stats_line);
frame.render_widget(stats_paragraph, chunks[0]);
// Nothing is transferring, so there is no progress to draw: leave the
// right-hand half blank instead of showing an idle 0 B / 0 B bar.
let Some((total_downloaded, total_size, total_speed)) = active else {
return;
};
let overall_progress = if total_size > 0 {
(total_downloaded as f64 / total_size as f64 * 100.0) as u8
} else {
0
};
let progress_ratio = if total_size > 0 {
total_downloaded as f64 / total_size as f64
} else {
0.0
};
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
let right_width = chunks[1].width as usize;
let bar_width = right_width.saturating_sub(42).max(10);
let progress_bar =
@@ -453,3 +474,73 @@ pub fn show_multi_download_progress(
result
}
#[cfg(test)]
mod tests {
use super::{DownloadEntry, DownloadStatus, active_progress};
use soulseek_rs::types::{Download, DownloadMetadata};
fn entry(size: u64, status: DownloadStatus) -> DownloadEntry {
let (sender, _receiver) = std::sync::mpsc::channel();
DownloadEntry {
download: Download {
username: "peer".to_string(),
filename: "song.flac".to_string(),
token: 1,
size,
download_directory: "/tmp".to_string(),
status,
sender,
queue_position: None,
metadata: DownloadMetadata::default(),
},
receiver: None,
}
}
#[test]
fn only_running_downloads_count_towards_the_bar() {
let downloads = vec![
entry(
100,
DownloadStatus::InProgress {
bytes_downloaded: 40,
total_bytes: 100,
speed_bytes_per_sec: 512.0,
},
),
// None of these may reach the bar: a queued file has not started,
// and a failed or timed-out one never will. Counting their size
// would drag the bar down for the rest of the session.
entry(9_000, DownloadStatus::Queued),
entry(9_000, DownloadStatus::Failed(None)),
entry(9_000, DownloadStatus::TimedOut),
];
let (transferred, expected, speed) =
active_progress(&downloads).expect("one download is running");
assert_eq!(transferred, 40);
assert_eq!(expected, 100, "queued and failed sizes must be excluded");
assert!((speed - 512.0).abs() < f64::EPSILON);
}
#[test]
fn nothing_running_hides_the_bar() {
assert!(active_progress(&[]).is_none(), "no downloads at all");
// Completed and paused transfers are not processing either, so the
// readout stays hidden rather than showing a stale bar.
let idle = vec![
entry(100, DownloadStatus::Completed),
entry(100, DownloadStatus::Queued),
entry(
100,
DownloadStatus::Paused {
bytes_downloaded: 50,
total_bytes: 100,
},
),
];
assert!(active_progress(&idle).is_none());
}
}