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
+135
View File
@@ -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);
}
+114 -7
View File
@@ -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)
),
));
+9 -2
View File
@@ -138,10 +138,17 @@ pub fn render_downloads_pane(
percent
)
}
UploadStatus::Completed => format_bytes(upload.size),
UploadStatus::Completed => "Completed".to_string(),
UploadStatus::Cancelled => "Cancelled".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
.filename
.rsplit(['\\', '/'])
@@ -152,7 +159,7 @@ pub fn render_downloads_pane(
Cell::from(basename.to_string()).style(body_style()),
Cell::from(upload.username.clone()).style(info_style()),
Cell::from(progress_text).style(primary_style()),
Cell::from(String::new()),
Cell::from(speed_text).style(warning_style()),
])
}));
+3 -1
View File
@@ -1,4 +1,5 @@
mod browse_pane;
mod chat_pane;
mod download_info_pane;
mod downloads_pane;
mod results_pane;
@@ -6,7 +7,8 @@ mod rooms_pane;
mod searches_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 results_pane::{ResultsPaneParams, render_results_pane};
pub use rooms_pane::render_rooms_pane;
+14 -6
View File
@@ -181,12 +181,20 @@ fn render_messages(
let start = lines.len().saturating_sub(height.max(1));
let rendered: Vec<Line> = lines[start..]
.iter()
.map(|l| match &l.username {
Some(user) => Line::from(vec![
Span::styled(format!("<{user}> "), info_style()),
Span::styled(l.text.clone(), primary_style()),
]),
None => Line::from(Span::styled(l.text.clone(), dimmed_style())),
.map(|l| {
let time =
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
match &l.username {
Some(user) => Line::from(vec![
time,
Span::styled(format!("<{user}> "), info_style()),
Span::styled(l.text.clone(), primary_style()),
]),
None => Line::from(vec![
time,
Span::styled(l.text.clone(), dimmed_style()),
]),
}
})
.collect();
frame.render_widget(Paragraph::new(rendered), area);