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