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:
@@ -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},
|
||||
};
|
||||
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)
|
||||
),
|
||||
));
|
||||
|
||||
@@ -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()),
|
||||
])
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user