Commit Graph
36 Commits
Author SHA1 Message Date
Michel de Graaf 26b551d997 feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so
"am I actually sharing that?" had no answer short of asking a friend to
browse you. The tree is already there; what was missing was feeding it
from our own index.

B opens it — the same collapsible tree, filter, and navigation the peer
browse offers, built from `Shares::directories()`, which is the listing
GetShareFileList answers with. So what it shows is what the network sees,
not what the config meant to share. r re-scans the disk rather than
re-asking a peer, o steps across to the folders that feed it, and nothing
here queues a download from ourselves.

Attached to a daemon the index lives on the daemon's host, so it comes
back over a new `shares.files` method rather than being scanned locally;
`shares files [--filter]` prints the same listing headless, through the
`emit_listing` that `browse` now shares with it — the two are only
comparable while they are built the same way.

Two allocation fixes came out of the review, both on paths this reaches:
the daemon hands its listing to the DTO instead of deep-copying one it
already owns, and `Shares::directories()` allocates its map key once per
folder rather than once per file, which the peer-facing serve path has
been paying for all along.
2026-09-10 11:44:57 +02:00
Michel de Graaf d6d870b39f feat(tui): account settings — stats, password change, and logout
The settings popup opens with an Account block: who the session is logged in
as, how much it shares, the port peers reach it on, and the daemon it borrows
when the window is attached to one. Two rows follow, both confirmed before
they act. A password is typed twice, because the server sends no verdict on a
change and a typo entered once would leave the account with a password nobody
knows. Logging out closes the window and comes back to the login screen with
the stored password deleted, so the next start asks rather than walking
straight back in. A window attached to a daemon offers no logout: the login is
the daemon's.

"Change it on the server, then store it where the login lives" is one function
over the session and the secret store, so the window, the command and the
daemon share the rule rather than each spelling out its own idea of which
process owns the password.

Headless the same two acts are `account password` and `account logout`, and
the daemon answers `account.password` so a routed run is not a dead end —
`whoami` already reported who is logged in and what they share.
2026-09-10 11:10:58 +02:00
Michel de Graaf 19791d1699 feat(rooms): private room membership, operators and refusals
Private rooms were unreachable: the client could ask to join one but had
no way to invite, promote, resign or be told what happened, and a room
name already owned by someone else came back as silence rather than a
refusal (code 1003).

Adds the rosters the server pushes after login (133/148), the changes it
narrates to them (134/135, 143/144), our own standing (139/140, 145/146),
and the messages that drive them, with the rosters kept current in client
state. The daemon protocol and the TUI room transcript carry the new
events rather than dropping them.
2026-09-09 16:33:59 +00:00
Michel de Graaf 374a81bec1 feat(tui): resize panes by dragging pane borders
Press and drag the border under Results or the seam at a row pane's
right edge; the panes resize live and release ends the drag. Dragged
sizes are cell counts on PaneLayout, so W resets panes, zoom and sizes
in one key, and the reset-layout hint shows once anything is dragged.
Undragged panes keep their default Fill proportions.
2026-09-09 17:20:12 +02:00
Michel de Graaf 607532cc44 feat(tui): search a room's log and members, and a conversation
A busy room's log scrolls past and its member list runs to a thousand
names, with nothing to narrow either. In a room / now filters the log
to lines mentioning the text and u filters the member list, the same
way the room list and the results filter: type, Enter keeps it, Esc
clears it before it leaves the room. The inbox takes / over the open
conversation. The member keys, b and m among them, act on the narrowed
list, and the titles say what is filtered and how many made it.
2026-09-08 11:24:53 +02:00
Michel de Graaf 6c4e60bb0e perf(tui): wrap a chat log as it arrives instead of on every frame
A room's chat log was wrapped to the pane width from the first line on
every frame, so a busy room lagged more the longer it stayed open: in
the harness 30,000 lines cost 16.6ms a frame. The wrapped rows now
live with the log and a draw only wraps the lines that arrived since
the last one, starting over when the width changes. The same cache
serves the private-message box, and the room list draws only its
visible page like the other tables. The 30,000-line room now draws in
0.4ms.
2026-09-08 11:24:46 +02:00
Michel de Graaf b8da8668b4 feat(tui): folder jumps, open-all and a path filter in the browse popup
A browsed share only moved a row at a time, so a folder of a few
hundred files stood between the user and the next folder. J and K now
jump between folder rows, H and L close or open every folder, and /
filters the whole tree by path the way the results and the room list
filter: type to narrow, Enter keeps it, Esc clears it, then hides. A
folder under a filter shows the paths that match and d downloads just
those. The keys popup gains a browse section and the bar lists them.
2026-09-08 11:24:41 +02:00
Michel de Graaf 0931bb01b4 perf(tui): flatten a browsed share once and draw one page of it
Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.

The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.

The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
2026-09-08 11:24:36 +02:00
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00
Michel de Graaf d4e86187d2 refactor(tui): fold the keys overlay onto LogView and dedupe the pane lookups
The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.

One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
2026-09-07 10:51:41 +02:00
Michel de Graaf 478d976776 feat(tui): scroll long names in every list and page through popups and chat
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.
2026-09-07 08:16:20 +00:00
Michel de Graaf da8ab45303 feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.

Follows up on #55.
2026-09-07 07:49:32 +00:00
Michel de Graaf 3193c73f5b feat(tui): scroll long result names and page through results
Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
2026-09-06 20:10:59 +02:00
Michel de Graaf f1b667f7f9 refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).

The bigger cuts:

- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
  sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
  wrote a peer-init frame down the server socket, and the struct only
  they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
  recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
  UploadFailed, registry and actor getters, build_watch_user,
  strip_ansi_codes, delegating shims on ClientContext. Removing public
  items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
  a migration, border-type helpers that all answered Rounded, the TUI's
  max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
  navigation blocks into cycle, hit tests into Rect::contains, char
  walkers into floor/ceil_char_boundary, five Download literals into
  one helper, the suites' shared scaffolding into tests/common.

Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.

Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
2026-07-28 12:52:18 +02:00
Michel de Graaf 011eafaa7f feat(daemon): make every window a view of one shared session
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.

The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.

Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.

Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.

A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
2026-07-27 20:58:49 +02:00
Michel de Graaf be5dc6c064 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.
2026-07-24 21:18:20 +02:00
michel 724796c1eb feat: settings popup and uploads in the transfers pane
Press 'o' for a settings popup: edit the download folder, add/remove
share paths (applied live via a rescan and persisted to config.toml),
and force a re-index with 'r' for files changed on disk. The state
machine is unit-tested; feedback shows the shared file/folder counts.

The downloads pane is now Downloads/Uploads: uploads being served are
listed after downloads with an up-arrow marker, live progress, and
status; 'x' cancels an in-progress upload. Client::shared_counts exposes
the index size for the status line.
2026-07-20 20:56:39 +02:00
michel 36819d4c42 test: persistence e2e and registration-relogin e2e; lint clean
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.
2026-07-20 20:20:54 +02:00
Michel de Graaf 2918b0ce75 refactor: resolve all lint findings
- 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.
2026-07-20 10:53:57 +02:00
michel 6821fd1907 fix: two medium bugs from the final adversarial review
- 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.
2026-07-20 07:26:03 +02:00
michel e59c2bc199 fix(tui): make browse retry work and de-jargon the copy
- 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'.
2026-07-19 20:49:32 +02:00
michel 60896619d7 feat(tui): tabbed browsing — browse several users at once
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.
2026-07-19 20:38:34 +02:00
michel 5a3bc6fcd5 feat(tui): browse or PM a user from a chat room's member list
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.
2026-07-19 20:22:33 +02:00
michel 9595afe9fb fix(rooms): harden chat-room parsing and TUI state from review findings
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.
2026-07-19 20:15:42 +02:00
michel 08272ac867 feat(tui): chat rooms — browsable list, multiple open rooms, unread badges
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).
2026-07-19 19:58:32 +02:00
michel aa8f4182d3 feat(tui): hierarchical browse tree with file/folder downloads
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.
2026-07-19 19:27:05 +02:00
michel 05b06ec2ec feat(tui): compose and read private messages
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.
2026-07-19 13:38:36 +02:00
michel e72c8a5c07 style: crank clippy to pedantic+nursery and fix all lints
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.
2026-07-19 00:09:17 +02:00
michel bdaeb33a4f fix: TUI selection underflow and filtered-results desync
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.
2026-07-18 14:30:02 +02:00
michel 574833bf61 feat: add download info pane to TUI
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.
2026-04-26 17:19:26 +02:00
michel 807dea5f17 Fix TUI search cursor editing 2026-04-26 11:43:44 +02:00
Michel de Graaf dfe6f532fa chore: formatting 2026-01-04 20:37:03 +01:00
Michel de Graaf bb86e50b4f fix: queuing downloads 2025-11-19 08:27:50 +01:00
Michel de Graaf 4c6d7c6850 chore: refactor UI to use Downloads struct 2025-11-18 14:33:33 +01:00
Michel de Graaf 9a4ad6c38a feat: main TUI 2025-11-13 14:12:37 +01:00
Michel de Graaf 5816d9b632 chore: refactor CLI app with new modules for seperation of concerns, reusable components and consistent styling 2025-11-12 02:02:35 +01:00