The sideways keys the results had (h/l, arrows, 0 and $) now scroll a
transfer's file name and a query too, each list on its own. The browse tree
and the room list take Home/End, PgUp/PgDn and the vim jumps like the main
lists, and a chat log, room or private, pages back through its history and
holds its place while new messages arrive; what arrives meanwhile counts as
unread until the reader returns to the end.
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.
- Retry was broken for the common case: retry_selected_download removed
the failed download only from the UI list, not the client's
DownloadStore. Since Client::download re-derives the same md5 token
from the unchanged filename, retrying a still-Failed download left a
duplicate-token entry; the token-migration path then deleted both,
misrouting completion so the retried file downloaded to disk but the
UI showed 'Queued' forever. Add Client::remove_download /
DownloadStore::remove_by_file (status-agnostic) and clear the stale
entry before re-issuing. Regression-tested.
- Chat-room member selection could point out of range: RoomEvent::UserLeft
shrank the active room's member list without clamping user_selected, so
the highlight silently shifted to the wrong user (or vanished, making
'b'/'m' no-ops, and blanking the member pane). Clamp user_selected after
every applied room event. Regression-tested.
The member list in an open room is now selectable with the arrow keys.
Press 'b' to browse the highlighted user's shared files (opens the
browse tree) or 'm' to send them a private message (opens the compose
bar pre-filled with the recipient). Selection resets when switching
rooms; RoomsState navigation is unit-tested.
Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:
lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.
TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
(route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
clipped by wrapping.
Add a chat-rooms popup (open with 'c') satisfying the requested UX:
- Browsable, /-filterable room list showing per-room user counts,
busiest first; already-open rooms are marked.
- Multiple rooms open at once as tabs; Tab/Shift-Tab switch, 'x' leaves.
- Per-room chat log with a live member list and an inline compose line
(Enter to type, Enter to send; the server echo renders the message).
- Unread indicators: bold tab name + 'room (n)' badge per room, plus
'chat (n)' and 'inbox (n)' counters in the shortcut bar; viewing a
room clears its badge.
RoomsState is unit-tested (routing, unread accounting, tab lifecycle,
filtering).