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
+53 -5
View File
@@ -11,12 +11,9 @@ impl MainTui {
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 matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) {
self.state.show_messages = false;
}
return;
return self.handle_chat_input(key);
}
// 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) {
self.state.command_bar_cursor_position = self
.state
+15 -1
View File
@@ -8,7 +8,7 @@ mod settings;
use crate::models::AppState;
use crate::persist::{
snapshot::{Snapshot, restore_searches},
snapshot::{Snapshot, restore_messages, restore_searches},
state::StateStore,
};
use color_eyre::Result;
@@ -61,6 +61,10 @@ impl MainTui {
let Some(store) = &self.store else { return };
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() {
if self.state.rooms.focus_or_open(&room)
@@ -139,6 +143,16 @@ impl MainTui {
{
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;
}
+140 -62
View File
@@ -1,17 +1,18 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, MessageDirection, RoomsView};
use crate::models::{CommandBarMode, FocusedPane, RoomsView};
use crate::ui::panes::{
ResultsPaneParams, render_browse_pane, render_download_info_pane,
render_downloads_pane, render_results_pane, render_rooms_pane,
render_searches_pane,
ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane,
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
accent_style, format_shortcuts_styled, pane_block, plain_title,
primary_style, render_download_stats,
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
plain_title, primary_style, render_download_stats, warning_style,
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Position, Rect},
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
@@ -141,15 +142,15 @@ impl MainTui {
self.state.focused_pane == FocusedPane::Downloads,
);
let selected_download = self
.state
.downloads_table_state
.selected()
.and_then(|idx| self.state.downloads.get(idx));
let selected_transfer = selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
);
render_download_info_pane(
frame,
downloads_chunks[1],
selected_download,
selected_transfer,
self.state.focused_pane == FocusedPane::Downloads,
);
}
@@ -251,38 +252,11 @@ impl MainTui {
}
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 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(popup, area);
render_chat_pane(frame, area, &self.state, self.client.username());
}
/// Context shortcuts for the chat-rooms popup.
@@ -325,19 +299,6 @@ impl MainTui {
}
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() {
vec![
("↑↓", "move"),
@@ -347,6 +308,21 @@ impl MainTui {
("r", "re-index"),
("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 {
self.rooms_shortcuts()
} else if self.state.show_browse {
@@ -385,8 +361,8 @@ impl MainTui {
FocusedPane::Searches => vec![
("s", "search"),
("m", "message"),
("i", inbox_label.as_str()),
("c", chat_label.as_str()),
("i", "inbox"),
("c", "chat"),
("b", "browse user"),
("1-3", "focus pane"),
("↑↓", "navigate"),
@@ -405,7 +381,7 @@ impl MainTui {
("Space", "select"),
("Enter", "download"),
("b", "browse owner"),
("c", chat_label.as_str()),
("c", "chat"),
("/", "filter"),
("a/A", "select all/none"),
("1-3", "focus pane"),
@@ -434,10 +410,37 @@ impl MainTui {
more => format!("{} folders", more.len()),
};
let title = format!("Shortcuts · Sharing: {sharing}");
let shortcuts_widget = Paragraph::new(shortcuts_line)
.block(pane_block(false).title(plain_title(title, false)));
// Themed block (master's design system), but we take its inner area so
// 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) {
@@ -487,6 +490,38 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
.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(
input: &str,
cursor_position: usize,
@@ -513,3 +548,46 @@ fn visible_input_at_cursor(
(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"
);
}
}
+3 -8
View File
@@ -1,5 +1,5 @@
use super::MainTui;
use crate::models::{CommandBarMode, RoomsView};
use crate::models::RoomsView;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
impl MainTui {
@@ -126,16 +126,11 @@ impl MainTui {
}
/// 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
/// the recipient.
/// Closes the rooms popup and opens the chat box on that conversation.
fn message_selected_room_user(&mut self) {
if let Some(user) = self.state.rooms.selected_user() {
self.state.show_rooms = false;
self.state.command_bar_active = true;
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();
self.state.open_chat(user);
}
}
+34 -2
View File
@@ -3,6 +3,7 @@ use crate::models::{
ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry,
SearchStatus,
};
use chrono::Local;
use std::{
sync::{Arc, atomic::AtomicBool},
thread,
@@ -105,6 +106,7 @@ impl MainTui {
direction: MessageDirection::Incoming,
peer: msg.username().to_string(),
text: msg.message().to_string(),
at: Local::now(),
});
// Badge the inbox when it isn't currently open.
if !self.state.show_messages {
@@ -126,13 +128,43 @@ impl MainTui {
}
match self.client.send_private_message(recipient, text) {
Ok(()) => {
self.state.messages.push(ChatMessage {
direction: MessageDirection::Outgoing,
peer: recipient.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: recipient.to_string(),
text: text.to_string(),
peer,
text,
at: Local::now(),
}),
Err(e) => soulseek_rs::warn!("Failed to send message: {e}"),
}
self.state.chat_input.clear();
}
pub(super) fn start_search(&mut self, query: String) {
+3
View File
@@ -57,6 +57,9 @@ impl MainTui {
let result = crate::persist::config::FileConfig::load(&path)
.and_then(|mut config| {
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.save(&path)
});