Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
Adds a lib target (soulseek_rs_tui) exposing cli/models/persist so
integration tests can drive the persistence pipeline: state and config
survive a simulated restart through the real files, with the incomplete
download flagged for auto-resume. The lib e2e suite gains a
register-then-relogin test against soulfind proving the stored-credential
flow's server assumptions. cargo clippy --all-targets and cargo fmt are
clean.
The main TUI now loads state on startup — search history restored as
inert entries, saved chat rooms rejoined on the server, and incomplete
downloads (including failed/timed-out ones) automatically re-enqueued —
and saves a diffed snapshot each loop tick and on exit, so only changed
files are rewritten.
The default TUI path now brings the terminal up first and runs a login
screen whenever no working credentials exist: a pure-state-machine form
(unit tested) driven by an async connect/login attempt on a background
thread. Soulseek registers unknown usernames on login, so the same form
covers registration. On success the username is saved to config.toml and
a form-entered password to the OS keychain; stored credentials skip the
form entirely. Failed logins show the server error and retry instead of
aborting.
- replace hand-rolled clamp_cursor_to_char_boundary with
str::floor_char_boundary at its four call sites, drop the helper
- dedupe the duplicated Layout::vertical constraint arms in render()
- drop stale #[allow(dead_code)] on spinner_state (field is read)
- remove 34 trivial restating comments (auto-fix)
- replace all 20 production .unwrap() calls with graceful handling:
?-propagation via LockPoisoned, unwrap_or_default on infallible
conversions, let _ = on actor-channel sends, and a downloads_sender()
helper in the TUI that also dedupes three channel-init blocks
- collapse nested if into match guard (clippy::collapsible_match)
- split client.rs (2275 lines) into a client/ module: connection,
search, downloads, uploads, rooms, operations
- split ui/main_tui.rs (2027 lines) into a main_tui/ module: render,
input, browse, rooms, downloads, search
- extract oversized functions: peer_actor/server_actor handle_message
into per-message helpers, main() into init_logging/run_default_tui,
download_selector and main_tui render into per-section helpers
Mechanical only, zero behavior change. Verified: clippy clean
(pedantic+nursery), fmt clean, 143 unit tests + 16 e2e tests against
soulfind pass, lint clean.
- 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.
Log lines were painting over the alternate-screen TUI because some
messages bypassed the buffered logger and wrote straight to the
terminal:
- a live println! in GetPeerAddressHandler fired on every peer-address
lookup (i.e. constantly while browsing/downloading) — routed to the
buffered debug! logger.
- the TUI's error handlers used eprintln! in the render loop and in
background search/download worker threads — routed to the buffered
warn! logger so failures no longer smear the screen.
The logger already defers to a buffer while the TUI holds the screen; the
fix is simply to go through it instead of print!/eprintln!.
- Retry actually retries: 'r' in the browse popup re-requests a
timed-out user (previously the 'Press b to retry' hint did nothing —
b wasn't handled inside the popup). BrowseTabs::retry_active is
unit-tested.
- Friendlier timeout copy that explains the likely cause: "Couldn't
reach <user>. They may be offline, or their connection can't be
reached (both of you may be behind a router/firewall). Press r to try
again."
- Replace protocol terminology in user-facing labels: 'Peer slots' ->
'Free slots', 'Peer upload speed' -> 'Upload speed'.
Replace the single browse view with BrowseTabs (one tab per user),
mirroring the chat-room tabs. Browsing a new user (from search results
or a chat room's member list) opens or focuses their tab; Tab/Shift-Tab
switch between open users, 'w' closes the active tab, Esc hides the
popup while keeping the tabs. A tab bar shows the open users when more
than one is open, and each tab polls/loads independently. Re-browsing a
timed-out user retries. BrowseTabs is unit-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).
Carry a human-readable reason on DownloadStatus::Failed so it flows
through the existing status channel to the TUI. The download Info pane
now shows an Error line for a failed download (connect failure, peer
disconnect, truncated transfer, peer-reported upload failure, or the
underlying transfer error). Failures without a known reason render as
before.
In the Downloads pane: 'r' re-queues the selected failed/timed-out download,
and 'c' clears all completed/failed/timed-out entries from the list. Shortcuts
bar updated.
Wire browsing a user's shared files into the TUI as a modal tree popup:
- press 'b' on a highlighted search result to browse that result's owner, or
'b' elsewhere to type a username (new CommandBarMode::Browse);
- a new models/browse.rs synthesizes the flat SharedDirectory list into a
nested, collapsible tree (intermediate folders reconstructed from the
backslash paths; file download paths kept byte-exact from the original names),
with expand/collapse state and a flatten-to-rows renderer — fully unit-tested;
- the popup polls take_browse_result each tick (with a timeout), navigates with
arrows/hjkl, expands/collapses folders, and queues downloads: Enter on a file,
'd' on a file or an entire folder subtree (recursively) — all funneled into
the existing Downloads pane;
- shortcuts bar advertises browse keys and surfaces our own sharing status.
Add private messaging to the TUI:
- 'm' opens the command bar in message mode; type '<recipient> <text>' and
Enter sends it via the client;
- incoming messages are polled each tick into an inbox;
- 'i' toggles a centered inbox popup listing sent/received messages.
The shared command bar now carries a mode (search vs message) so the prefix,
submit action and shortcut hints follow what is being typed.
Enable the full clippy all + pedantic + nursery groups as workspace warnings,
with a small documented allow-list for the pedantic/nursery lints this project
intentionally does not follow (rustdoc-style requirements, deliberate numeric
casts in the protocol/UI layers, and a few noisy nursery lints). Also enable
the rust unused_lifetimes and unsafe_op_in_unsafe_fn lints.
Fixed every resulting warning (527 -> 0) mechanically per clippy's suggestions:
inline format args, const fns, item ordering, let-else, redundant clones,
map_or, cast_lossless, Eq derives, associated-fn conversions for methods that
never used self, and removing unnecessary Result wrappers whose call sites could
never propagate an error. No runtime behavior changed; cargo fmt applied.
cargo clippy --workspace --all-targets: 0 warnings. All tests pass.
MainTui::run only disabled mouse capture and called ratatui::restore() after
the event loop exited normally; any error propagated by ? from draw/poll/read
returned early and left the terminal in raw mode, on the alternate screen, with
mouse capture still enabled. The loop body moved into a helper whose result is
returned only after the terminal is unconditionally restored.
Three related TUI correctness bugs:
- download_selector navigation (select_previous/next/last) computed
items.len() - 1, panicking with an arithmetic underflow when the list was
empty (e.g. a filter matched nothing). Index math is now in wrap-safe
helpers that return no selection for an empty list.
- update_search_results overwrote the filtered results view with the full
unfiltered list on every ~100ms poll, so an active filter silently stopped
working as new results streamed in. It now re-derives the filtered view
from the current query.
- The results pane rendered selection checkboxes by testing the display-row
index against a selection set that stores unfiltered indices, so under an
active filter the wrong rows showed as checked. It now maps the display
index back through the filtered-index table.
Filtering and index-mapping logic extracted into pure, unit-tested helpers.
- Add pub const BYTES_PER_MB to ui/utils.rs and replace 7 uses of the
bare 1_048_576.0 literal across the UI modules. Also pick up
uninlined-format-args cleanup in the same file while we're here.
- Replace Vec<i32> IP-byte accumulator in Peer::new_from_message with a
[u8; 4]; the .abs() calls on u8.into() were no-ops on always-positive
values. Network byte order ([3].[2].[1].[0]) preserved; tests cover
the wire format.
Splits the downloads area 60/40 with a new info pane that adapts to the
selected download's status: header (filename, user, size, status, save
path, bitrate, length) plus queued-specific fields (queue position, peer
slots, peer upload speed) or live progress/speed/ETA when transferring.
Plumbs queue position via a new ClientOperation::PlaceInQueueUpdate so
PlaceInQueueResponse messages from peers reach DownloadStore, and threads
search-time metadata (bitrate, length, peer slots/speed) onto Download
through a new download_with_metadata entry point.