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
+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();