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:
@@ -12,21 +12,47 @@ use ratatui::{
|
||||
widgets::{Paragraph, Wrap},
|
||||
};
|
||||
use soulseek_rs::DownloadStatus;
|
||||
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
||||
use soulseek_rs::utils::path::expand_tilde;
|
||||
|
||||
const LABEL_WIDTH: usize = 20;
|
||||
|
||||
/// Whichever transfer the table has highlighted. Rows are downloads first and
|
||||
/// uploads after, so a raw index has to be resolved against both lists.
|
||||
pub enum SelectedTransfer<'a> {
|
||||
Download(&'a DownloadEntry),
|
||||
Upload(&'a UploadInfo),
|
||||
}
|
||||
|
||||
/// Resolve the transfers table's selected row index into the download or
|
||||
/// upload it points at, matching the order [`render_downloads_pane`] renders.
|
||||
#[must_use]
|
||||
pub fn selected_transfer<'a>(
|
||||
index: Option<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(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
selected: Option<&DownloadEntry>,
|
||||
selected: Option<SelectedTransfer>,
|
||||
focused: bool,
|
||||
) {
|
||||
let block = pane_block(focused).title(pane_title("Info", "", focused));
|
||||
|
||||
let Some(entry) = selected else {
|
||||
let Some(selected) = selected else {
|
||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||
"Select a download for details.",
|
||||
"Select a transfer for details.",
|
||||
dimmed_style(),
|
||||
)))
|
||||
.block(block);
|
||||
@@ -34,7 +60,10 @@ pub fn render_download_info_pane(
|
||||
return;
|
||||
};
|
||||
|
||||
let lines = build_info_lines(&entry.download);
|
||||
let lines = match selected {
|
||||
SelectedTransfer::Download(entry) => build_info_lines(&entry.download),
|
||||
SelectedTransfer::Upload(upload) => build_upload_info_lines(upload),
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
@@ -42,6 +71,73 @@ pub fn render_download_info_pane(
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Details for a file we are serving to a peer, laid out like the download
|
||||
/// view so the pane reads the same whichever direction is selected.
|
||||
fn build_upload_info_lines(upload: &UploadInfo) -> Vec<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(
|
||||
download: &soulseek_rs::types::Download,
|
||||
) -> Vec<Line<'static>> {
|
||||
@@ -184,19 +280,30 @@ fn push_progress_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
bytes_downloaded: 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 ratio = bytes_downloaded as f64 / total_bytes as f64;
|
||||
let ratio = transferred as f64 / total_bytes as f64;
|
||||
(ratio, (ratio * 100.0) as u8)
|
||||
} else {
|
||||
(0.0, 0)
|
||||
};
|
||||
lines.push(format_progress_bar(progress, 20, percent));
|
||||
lines.push(label_value(
|
||||
"Downloaded",
|
||||
label,
|
||||
&format!(
|
||||
"{} / {}",
|
||||
format_bytes(bytes_downloaded),
|
||||
format_bytes(transferred),
|
||||
format_bytes(total_bytes)
|
||||
),
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user