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
+93 -2
View File
@@ -2,15 +2,27 @@
//! parts of it). Live handles (channels, cancel flags) never leave the
//! process; only plain data goes to disk.
use super::state::PersistedDownload;
use crate::models::{AppState, SearchEntry, SearchStatus};
use super::state::{PersistedDownload, PersistedMessage};
use crate::models::{
AppState, ChatMessage, MessageDirection, SearchEntry, SearchStatus,
};
use soulseek_rs::DownloadStatus;
/// How much private-message history survives a restart.
/// a flat cap on the whole log rather than per conversation; make
/// it per-peer if one chatty contact starts evicting everyone else.
const MESSAGE_HISTORY_LIMIT: usize = 500;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Snapshot {
pub downloads: Vec<PersistedDownload>,
pub queries: Vec<String>,
pub rooms: Vec<String>,
pub messages: Vec<PersistedMessage>,
/// Unread private-message count, so the badge survives a restart. Room
/// unread is deliberately not persisted: room chat logs are not saved, so
/// a restored room count would flag messages that no longer exist.
pub unread_messages: usize,
}
impl Snapshot {
@@ -48,14 +60,44 @@ impl Snapshot {
.map(|room| room.name.clone())
.collect();
// Keep the newest messages; older ones fall off the end.
let start = state.messages.len().saturating_sub(MESSAGE_HISTORY_LIMIT);
let messages = state.messages[start..]
.iter()
.map(|message| PersistedMessage {
peer: message.peer.clone(),
outgoing: message.direction == MessageDirection::Outgoing,
text: message.text.clone(),
at: message.at,
})
.collect();
Self {
downloads,
queries,
rooms,
messages,
unread_messages: state.unread_messages,
}
}
}
/// Rebuild the private-message log from disk.
pub fn restore_messages(state: &mut AppState, messages: &[PersistedMessage]) {
for message in messages {
state.messages.push(ChatMessage {
direction: if message.outgoing {
MessageDirection::Outgoing
} else {
MessageDirection::Incoming
},
peer: message.peer.clone(),
text: message.text.clone(),
at: message.at,
});
}
}
/// Rebuild search history entries from persisted query strings. Restored
/// searches are inert (completed, no results) — selecting one shows an
/// empty result set until it is re-run.
@@ -155,6 +197,13 @@ mod tests {
);
}
#[test]
fn capture_carries_the_unread_count() {
let mut state = AppState::new();
state.unread_messages = 3;
assert_eq!(Snapshot::capture(&state).unread_messages, 3);
}
#[test]
fn capture_takes_open_room_names() {
let mut state = AppState::new();
@@ -180,6 +229,48 @@ mod tests {
assert!(state.searches[0].results.is_empty());
}
#[test]
fn messages_round_trip_through_a_snapshot() {
let mut state = AppState::new();
state.messages.push(ChatMessage {
direction: MessageDirection::Incoming,
peer: "alice".into(),
text: "hey".into(),
at: chrono::Local::now(),
});
state.messages.push(ChatMessage {
direction: MessageDirection::Outgoing,
peer: "alice".into(),
text: "hey yourself".into(),
at: chrono::Local::now(),
});
let snapshot = Snapshot::capture(&state);
let mut restored = AppState::new();
restore_messages(&mut restored, &snapshot.messages);
assert_eq!(Snapshot::capture(&restored).messages, snapshot.messages);
assert_eq!(restored.messages[1].text, "hey yourself");
assert_eq!(restored.messages[1].direction, MessageDirection::Outgoing);
assert_eq!(restored.active_chat_peer(), Some("alice"));
}
#[test]
fn capture_keeps_only_the_newest_messages() {
let mut state = AppState::new();
for i in 0..MESSAGE_HISTORY_LIMIT + 10 {
state.messages.push(ChatMessage {
direction: MessageDirection::Incoming,
peer: "alice".into(),
text: i.to_string(),
at: chrono::Local::now(),
});
}
let snapshot = Snapshot::capture(&state);
assert_eq!(snapshot.messages.len(), MESSAGE_HISTORY_LIMIT);
assert_eq!(snapshot.messages[0].text, "10", "oldest are dropped");
}
#[test]
fn capture_of_restored_searches_round_trips() {
let mut state = AppState::new();
+61 -1
View File
@@ -1,4 +1,5 @@
//! Versioned JSON state files (downloads, search queries, open rooms).
//! Versioned JSON state files (downloads, search queries, open rooms,
//! private-message history).
//!
//! Each file is an envelope `{ "version": N, "data": ... }`. On load the
//! data passes through the migration chain from its stored version up to
@@ -24,6 +25,16 @@ pub struct PersistedDownload {
pub completed: bool,
}
/// One line of private-message history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct PersistedMessage {
pub peer: String,
/// True when we sent it, false when the peer did.
pub outgoing: bool,
pub text: String,
pub at: chrono::DateTime<chrono::Local>,
}
pub struct StateStore {
dir: PathBuf,
}
@@ -72,6 +83,31 @@ impl StateStore {
&rooms,
)
}
pub fn load_messages(&self) -> Vec<PersistedMessage> {
load(&self.dir.join("messages.json"), MESSAGES_MIGRATIONS)
}
pub fn save_messages(&self, messages: &[PersistedMessage]) -> Result<()> {
save(
&self.dir.join("messages.json"),
MESSAGES_MIGRATIONS.len() as u32,
&messages,
)
}
/// The unread private-message count, so the badge survives a restart.
pub fn load_unread(&self) -> usize {
load(&self.dir.join("unread.json"), UNREAD_MIGRATIONS)
}
pub fn save_unread(&self, unread: usize) -> Result<()> {
save(
&self.dir.join("unread.json"),
UNREAD_MIGRATIONS.len() as u32,
&unread,
)
}
}
/// Per-file migration chains. `data` at version `i` is upgraded by
@@ -80,6 +116,8 @@ impl StateStore {
const DOWNLOADS_MIGRATIONS: &[Migration] = &[];
const SEARCHES_MIGRATIONS: &[Migration] = &[];
const ROOMS_MIGRATIONS: &[Migration] = &[];
const MESSAGES_MIGRATIONS: &[Migration] = &[];
const UNREAD_MIGRATIONS: &[Migration] = &[];
/// Load `data` from an envelope file, migrating old versions forward.
/// Missing, corrupt, or newer-than-known files all yield `T::default()`.
@@ -178,6 +216,28 @@ mod tests {
assert_eq!(store.load_downloads(), downloads);
}
#[test]
fn messages_round_trip_including_the_timestamp() {
let (_tmp, store) = store();
let messages = vec![PersistedMessage {
peer: "alice".into(),
outgoing: true,
text: "hey".into(),
at: chrono::Local::now(),
}];
store.save_messages(&messages).unwrap();
assert_eq!(store.load_messages(), messages);
}
#[test]
fn unread_count_round_trips_and_defaults_to_zero() {
let (_tmp, store) = store();
// Missing file → no unread, so a fresh install shows no badge.
assert_eq!(store.load_unread(), 0);
store.save_unread(4).unwrap();
assert_eq!(store.load_unread(), 4);
}
#[test]
fn searches_and_rooms_round_trip() {
let (_tmp, store) = store();