Commit Graph
52 Commits
Author SHA1 Message Date
Michel de Graaf 9b87bd6a0f feat!: wishlist, upload-queue privilege recognition, and search filters (#20)
Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.

Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.

Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.

Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.

Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
2026-07-26 14:20:07 +02:00
Michel de Graaf c6d41796c9 feat(tui): give the panes the colour the demo promised
The cursor row set a foreground, and ratatui only skips a cell's colour
when that field is None — so selecting a row repainted its gold sizes,
blue usernames and status glyphs into one flat cream. Table cursors now
tint the background only, over a warm oxide wash instead of neutral.

Browse files, their sizes and the settings popup rendered in the raw
terminal foreground; they take the palette now, and free slots read
green when a peer has one.
2026-07-25 03:44:07 +02:00
Michel de Graaf 9d58bb9b64 refactor: clear the quality-scan findings
`render_shortcuts` was 144 lines of "which shortcuts apply" wrapped around 40
lines of drawing; the decision moves into a `shortcuts` method beside the
`rooms_shortcuts` helper that already worked this way. Three identically
shaped comments over the popup guards collapse into one that carries the part
worth knowing — an open popup owns the keyboard, so the order matters. The
state tests trade bare `unwrap()` for `expect()` so a failure names the step.
2026-07-25 02:32:11 +02:00
Michel de Graaf be6581ee6c feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.

The commands are now built for scripts and agents:

  search  download  get  browse  room list|say|listen  message send|read

stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.

Defects found and fixed along the way, each covered by a test:

- a download with an unknown size was finalized against zero bytes and saved
  as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
  peers shadowed itself and two files sharing a basename overwrote each
  other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
  wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
  server went quiet

Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.

BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
2026-07-25 01:30:48 +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 de Graaf 105c798855 feat(ui): theme the TUI with the soulseek-rs design system
Replace the ANSI Light* palette with the design system's tokens
(tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm
semantics, paper text on dust chrome. Only foregrounds and the row
highlight are painted so the user's terminal background shows through.

Route every bordered pane through a single pane_block() helper carrying
one column of horizontal padding, and give legends a consistent
[n] Title form with the tag in signal. The command bar now derives its
cursor position from the block's inner rect instead of a hardcoded
border offset.

Also collapses the download-status glyph match that was duplicated
across three render sites into download_status_glyph().
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 be8197fd31 feat: update shared directories at runtime
Client::set_shared_directories rescans the given roots into a fresh
in-memory index, swaps it for peers, and re-announces the folder/file
counts to the server. The current directory list moves into the client
context so the running set is queryable. e2e-verified: a browser sees a
directory that was added after login.
2026-07-20 20:49:01 +02:00
michel a605431a10 feat: multiple shared directories
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.
2026-07-20 20:47:23 +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 c945bd5d5e feat: persist and restore TUI state across restarts
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.
2026-07-20 20:17:44 +02:00
michel d913c5cc71 feat: in-TUI login/registration screen
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.
2026-07-20 20:14:17 +02:00
Michel de Graaf c463d80b3c refactor: apply review cuts
- 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)
2026-07-20 11:02:43 +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 de Graaf 17864ac9b0 fix: tui lag keyboard navigation 2026-07-20 10:25:42 +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 9f74300c7a fix(tui): stop raw prints from corrupting the TUI
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!.
2026-07-19 21:22:38 +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 ae28f4236a feat(tui): surface the failure reason on failed downloads
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.
2026-07-19 19:36:15 +02:00
michel 4809dd7059 feat(tui): retry failed downloads and clear finished ones
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.
2026-07-19 19:29:43 +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 b149a8ecf2 fix: always restore the terminal when the TUI loop errors
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.
2026-07-18 14:37:35 +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 d06aa2d3de style: apply cargo fmt 2026-04-26 17:28:43 +02:00
michel 9d6a33da34 refactor: centralize BYTES_PER_MB and tidy peer IP parsing
- 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.
2026-04-26 17:20:45 +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 e1ee3ac493 fix clippy collapsible match warnings 2026-04-26 12:18:00 +02:00
Michel de Graaf dfdd60f1c8 Merge pull request #1 from adalessandro/dev
soulseek-rs: main_tui: Restore terminal status before exiting
2026-04-26 11:57:54 +02:00
michel bff7ca4bd4 chore: change copyright date 2026-04-26 11:55:40 +02:00
michel de58c8cbe2 Add TUI download pause and queued delete 2026-04-26 11:50:37 +02:00
michel 807dea5f17 Fix TUI search cursor editing 2026-04-26 11:43:44 +02:00
Ariel D'Alessandro 9627fa475d soulseek-rs: main_tui: Restore terminal status before exiting
Signed-off-by: Ariel D'Alessandro <ariel.dalessandro@collabora.com>
2026-01-29 17:16:19 -03:00
Michel de Graaf dfe6f532fa chore: formatting 2026-01-04 20:37:03 +01:00
mayor 16c8a4bde9 chore: update to Rust 2024 edition 2026-01-04 20:08:16 +01:00
Michel de Graaf f5b65053e2 fix: hanging TUI on queueing downloads 2025-11-19 09:13:03 +01:00
Michel de Graaf 9f174c4c5e fix: linting 2025-11-19 08:40:41 +01:00
Michel de Graaf bb86e50b4f fix: queuing downloads 2025-11-19 08:27:50 +01:00
Michel de Graaf b7a5037edb chore: keep updating search results 2025-11-18 16:02:19 +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 cafa40a09d chore: refactor to support multi search 2025-11-13 10:28:16 +01:00
Michel de Graaf 4a34583f9c chore: improve download selector 2025-11-13 10:08:16 +01:00
Michel de Graaf 69a044b1fa chore: UI tweaks 2025-11-12 14:52:31 +01:00
Michel de Graaf 406def31d9 chore: rename label from files to download 2025-11-12 11:56:32 +01:00