From 26b551d997fdeea31d30e53b7ad4900a44681135 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 10:50:34 +0200 Subject: [PATCH] feat: browse your own shares as a peer receives them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- soulseek-rs-lib/src/client/mod.rs | 11 ++++ soulseek-rs-lib/src/shares/mod.rs | 20 +++--- soulseek-rs/docs/openrpc.json | 27 +++++++++ soulseek-rs/src/SKILL.md | 5 ++ soulseek-rs/src/api.rs | 8 +++ soulseek-rs/src/cli.rs | 7 +++ soulseek-rs/src/commands/settings.rs | 22 +++++++ soulseek-rs/src/commands/social.rs | 53 +++++++++++----- soulseek-rs/src/daemon/openrpc.rs | 9 ++- soulseek-rs/src/daemon/proto/dto.rs | 20 ++++++ soulseek-rs/src/daemon/proto/method.rs | 8 +++ soulseek-rs/src/daemon/proto/mod.rs | 1 + soulseek-rs/src/daemon/server/mod.rs | 16 ++++- soulseek-rs/src/models/browse.rs | 75 ++++++++++++++++++++++- soulseek-rs/src/remote/mod.rs | 16 ++++- soulseek-rs/src/run.rs | 3 + soulseek-rs/src/ui/main_tui/browse.rs | 44 +++++++++++++- soulseek-rs/src/ui/main_tui/input.rs | 6 ++ soulseek-rs/src/ui/main_tui/mod.rs | 67 ++++++++++++++++++++ soulseek-rs/src/ui/main_tui/render.rs | 30 +++++++-- soulseek-rs/src/ui/main_tui/settings.rs | 21 +++++-- soulseek-rs/src/ui/panes/browse_pane.rs | 78 ++++++++++++++++++++---- soulseek-rs/tests/cli_e2e.rs | 81 +++++++++++++++++++++++++ soulseek-rs/tests/tui_e2e.rs | 35 +++++++++++ 24 files changed, 606 insertions(+), 57 deletions(-) diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a9ea8d7..a8ff5c9 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -1474,6 +1474,17 @@ impl Client { .unwrap_or_default() } + /// The share index as a peer receives it: the same directory listing + /// `GetShareFileList` answers with, so what this returns is what the + /// network sees. + #[must_use] + pub fn shared_listing(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shares.directories()) + .unwrap_or_default() + } + /// `(folders, files)` currently shared with peers. #[must_use] pub fn shared_counts(&self) -> (u32, u32) { diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index fb85657..807fbe3 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -146,14 +146,18 @@ impl Shares { .virtual_path .rsplit_once('\\') .unwrap_or(("", file.virtual_path.as_str())); - by_dir - .entry(dir.to_string()) - .or_default() - .push(SharedFileEntry { - name: base.to_string(), - size: file.size, - attributes: file.attributes.clone(), - }); + let entry = SharedFileEntry { + name: base.to_string(), + size: file.size, + attributes: file.attributes.clone(), + }; + // Looked up before inserting: `entry()` would allocate the key + // for every file, and a folder holds many. + if let Some(files) = by_dir.get_mut(dir) { + files.push(entry); + } else { + by_dir.insert(dir.to_string(), vec![entry]); + } } by_dir .into_iter() diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 1702a7a..cbf59a6 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -1243,6 +1243,21 @@ ], "type": "object" }, + "SharedListing": { + "description": "This session's own share index, as a peer receives it — what\n`shares.files` answers with.", + "properties": { + "directories": { + "items": { + "$ref": "#/components/schemas/SharedDirectoryDto" + }, + "type": "array" + } + }, + "required": [ + "directories" + ], + "type": "object" + }, "SharesStatus": { "description": "What the share index holds, from `shares.status` / `shares.reindex`.", "properties": { @@ -2361,6 +2376,18 @@ }, "summary": "What the share index currently holds." }, + { + "name": "shares.files", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedListing" + } + }, + "summary": "Every indexed file, in the listing a browsing peer receives." + }, { "name": "shares.set", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 2e79643..2dc21fc 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -216,6 +216,11 @@ config file; they print nothing. With a daemon running they also update it in place, so the folder is on the network at once rather than after a restart. **`shares status`** and **`shares reindex`** — `folders`, `files`, `directories`, counted by logging in and scanning. +**`shares files [--filter ]`** — the index file by file, in the same +records `browse` emits for a peer (`user`, `directory`, `path`, `size`, +`bitrate`, `duration`), so what the network sees of this account can be diffed +against what it sees of anyone else. Use it to check that a folder is really +being served before blaming the network. **`config path`**, **`config list`**, **`config get `**, and **`config set `** — `key`, `value`. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 48d5d1e..e3a10ba 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -168,6 +168,10 @@ pub trait SessionApi: Send + Sync { fn shared_counts(&self) -> (u32, u32); // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn shared_directories(&self) -> Vec; + /// The share index in the shape a peer receives it, for showing this + /// session's own files exactly as the network sees them. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn shared_listing(&self) -> Vec; // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_shared_directories(&self, directories: Vec) -> Result<()>; @@ -416,6 +420,10 @@ impl SessionApi for Client { Self::shared_directories(self) } + fn shared_listing(&self) -> Vec { + Self::shared_listing(self) + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { Self::set_shared_directories(self, directories) } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index 0c8f581..8c5efd0 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -407,6 +407,13 @@ pub enum SharesCommand { directory: String, }, + /// List every indexed file, the way `browse` lists a peer's + Files { + /// Only paths containing this text, case-insensitively + #[arg(long)] + filter: Option, + }, + /// Re-index the shares and report what the network will see Reindex, diff --git a/soulseek-rs/src/commands/settings.rs b/soulseek-rs/src/commands/settings.rs index 01057d2..c85970e 100644 --- a/soulseek-rs/src/commands/settings.rs +++ b/soulseek-rs/src/commands/settings.rs @@ -163,6 +163,28 @@ pub fn shares_status(ctx: &Ctx) -> CliResult { Ok(()) } +/// List the share index file by file, in the same records `browse` emits for +/// a peer — so what the network sees of us can be read, and diffed, exactly +/// the way it is read of anyone else. +pub fn shares_files(ctx: &Ctx, filter: Option<&str>) -> CliResult { + let session = Session::open(ctx)?; + let needle = filter.map(str::to_lowercase); + let found = super::social::emit_listing( + ctx, + &session.client.username(), + &session.client.shared_listing(), + needle.as_deref(), + ); + + if found == 0 { + return Err(CliError::no_results(match filter { + Some(filter) => format!("no shared file matches '{filter}'"), + None => "no files are indexed".to_string(), + })); + } + Ok(()) +} + /// Re-scan the configured folders in a live session, so files added since the /// last run become visible without waiting for the next one. pub fn shares_reindex(ctx: &Ctx) -> CliResult { diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index f9b897c..e9f543c 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -48,6 +48,42 @@ pub fn fetch_listing( }) } +/// Emit `user`'s listing one file per record, keeping only the paths that +/// contain `needle` (already lowercased; `None` keeps all). Returns how many +/// were emitted. +/// +/// Shared with `shares files`, which prints this session's own index: the two +/// are only comparable while they are built the same way, and a field added +/// here has to reach both. +pub fn emit_listing( + ctx: &Ctx, + user: &str, + directories: &[SharedDirectory], + needle: Option<&str>, +) -> usize { + let mut emitted = 0usize; + for directory in directories { + for file in &directory.files { + let path = full_path(&directory.name, &file.name); + if needle + .is_some_and(|needle| !path.to_lowercase().contains(needle)) + { + continue; + } + emitted += 1; + ctx.out.emit(&BrowseRecord { + user: user.to_string(), + directory: directory.name.clone(), + path, + size: file.size, + bitrate: file.attribute(super::transfer::ATTR_BITRATE), + duration: file.attribute(super::transfer::ATTR_DURATION), + }); + } + } + emitted +} + pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { let session = Session::open(ctx)?; ctx.out @@ -59,22 +95,7 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { Duration::from_secs(args.timeout), )?; - let mut files = 0usize; - for directory in &directories { - for file in &directory.files { - files += 1; - ctx.out.emit(&BrowseRecord { - user: args.user.clone(), - directory: directory.name.clone(), - path: full_path(&directory.name, &file.name), - size: file.size, - bitrate: file.attribute(super::transfer::ATTR_BITRATE), - duration: file.attribute(super::transfer::ATTR_DURATION), - }); - } - } - - if files == 0 { + if emit_listing(ctx, &args.user, &directories, None) == 0 { return Err(CliError::no_results(format!( "{} shares no files", args.user diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 5312075..46ca070 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -14,8 +14,8 @@ use super::proto::{ Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Searches, Seconds, SessionLossEvent, - SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, - UserMessageDto, UserRef, UserResult, Watched, + SharedListing, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserMessageDto, UserRef, UserResult, Watched, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -74,6 +74,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::MessageHistory | Method::SearchList | Method::SharesStatusOf + | Method::SharesFiles | Method::SharesReindex | Method::UserWatched => json!({ "type": "null" }), } @@ -102,6 +103,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { schema_of::(generator) } + Method::SharesFiles => schema_of::(generator), // Everything else acknowledges and says nothing more. Method::DaemonStop | Method::SearchStart @@ -212,6 +214,9 @@ fn summary(method: Method) -> &'static str { } Method::UserInfoOf => "What the server has said about a user so far.", Method::SharesStatusOf => "What the share index currently holds.", + Method::SharesFiles => { + "Every indexed file, in the listing a browsing peer receives." + } Method::SharesSet => "Replace the shared folders and re-index.", Method::SharesReindex => "Re-scan the configured folders.", } diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 32fd958..2598859 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -770,6 +770,26 @@ impl From<&SharedDirectory> for SharedDirectoryDto { } } +/// The same, consuming the listing. The daemon owns what `shared_listing` +/// hands it and drops it straight after, so copying every name and attribute +/// out of it would double the cost of answering `shares.files`. +impl From for SharedDirectoryDto { + fn from(directory: SharedDirectory) -> Self { + Self { + name: directory.name, + files: directory + .files + .into_iter() + .map(|file| SharedFileEntryDto { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), + } + } +} + impl From for SharedDirectory { fn from(dto: SharedDirectoryDto) -> Self { Self { diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index 48f4976..de93784 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -212,6 +212,14 @@ pub struct BrowseEvent { pub directories: Vec, } +/// This session's own share index, as a peer receives it — what +/// `shares.files` answers with. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedListing { + pub directories: Vec, +} + /// One step in a transfer this client started. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[cfg_attr(test, derive(schemars::JsonSchema))] diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index 9f254f4..9f24d7c 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -113,6 +113,7 @@ wire_vocabulary! { UserWatched => "user.watched", AccountPassword => "account.password", SharesStatusOf => "shares.status", + SharesFiles => "shares.files", SharesSet => "shares.set", SharesReindex => "shares.reindex", } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 0396798..ca7fe3c 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -13,8 +13,9 @@ use super::proto::{ MemberStats, Members, MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto, RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary, - Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, - Uploads, UserInfoDto, UserRef, UserResult, Watched, + Searches, Seconds, SharedDirectoryDto, SharedListing, SharesStatus, + SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef, + UserResult, Watched, }; use crate::api::SessionApi; use crate::output::Exit; @@ -372,6 +373,14 @@ impl Daemon { } Method::SharesStatusOf => ok(self.shares()), + Method::SharesFiles => ok(SharedListing { + directories: self + .session + .shared_listing() + .into_iter() + .map(SharedDirectoryDto::from) + .collect(), + }), Method::SharesSet => { let directories: DirectoriesParams = parse(params)?; self.session @@ -1018,6 +1027,9 @@ mod tests { fn shared_counts(&self) -> (u32, u32) { (0, 0) } + fn shared_listing(&self) -> Vec { + Vec::new() + } fn shared_directories(&self) -> Vec { Vec::new() } diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 59ac1db..f428f79 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -332,6 +332,9 @@ pub enum BrowseStatus { /// UI state for browsing one user. pub struct BrowseState { pub username: String, + /// This session's own share index rather than a peer's: filled from the + /// local index, re-indexed rather than re-requested, and not downloadable. + pub own: bool, pub status: BrowseStatus, pub tree: Vec, expanded: HashSet, @@ -356,6 +359,7 @@ impl BrowseState { pub fn loading(username: String) -> Self { Self { username, + own: false, status: BrowseStatus::Loading, tree: Vec::new(), expanded: HashSet::new(), @@ -370,6 +374,15 @@ impl BrowseState { } } + /// An empty view of this session's own shares, waiting for the index. + #[must_use] + pub fn loading_own(username: String) -> Self { + Self { + own: true, + ..Self::loading(username) + } + } + /// Populate from a received listing: build the tree, auto-expand the top /// level so structure is visible, and set Loaded/Empty. pub fn load(&mut self, directories: &[SharedDirectory]) { @@ -528,7 +541,10 @@ impl BrowseTabs { /// `true` if the caller should (re)issue a browse request — i.e. the tab is /// new or was retried after a timeout. pub fn open(&mut self, username: &str) -> bool { - if let Some(idx) = self.tabs.iter().position(|t| t.username == username) + if let Some(idx) = self + .tabs + .iter() + .position(|t| !t.own && t.username == username) { self.active = idx; // Re-request only if the previous attempt gave up. @@ -545,6 +561,40 @@ impl BrowseTabs { } } + /// Where this session's own share tab sits, if it is open. + #[must_use] + pub fn own_index(&self) -> Option { + self.tabs.iter().position(|tab| tab.own) + } + + /// Focus the tab showing this session's own shares, opening it if it is + /// not there yet. Returns `true` when it still needs its listing. + pub fn open_own(&mut self, username: &str) -> bool { + if let Some(idx) = self.own_index() { + self.active = idx; + return false; + } + self.tabs + .push(BrowseState::loading_own(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + + /// Whether the focused tab is this session's own share index. + #[must_use] + pub fn active_is_own(&self) -> bool { + self.active_tab().is_some_and(|tab| tab.own) + } + + /// Who the active tab's files would be downloaded from, if anyone. The + /// own tab names nobody: its files are already on this disk. + #[must_use] + pub fn download_target(&self) -> Option<&str> { + self.active_tab() + .filter(|tab| !tab.own) + .map(|tab| tab.username.as_str()) + } + /// Retry the active tab if it timed out: reset it to loading and return the /// username so the caller can re-issue the request. Returns `None` if the /// active tab isn't in a retryable (timed-out) state. @@ -740,6 +790,29 @@ mod tests { assert!(tabs.is_empty()); } + #[test] + fn the_own_tab_is_opened_once_and_is_not_a_peer_of_the_same_name() { + let mut tabs = BrowseTabs::new(); + // The first open needs a listing; focusing it again does not. + assert!(tabs.open_own("tester")); + assert!(!tabs.open_own("tester")); + assert_eq!(tabs.tabs.len(), 1); + assert_eq!(tabs.own_index(), Some(0)); + assert!(tabs.active_tab().expect("tab").own); + + // Browsing the account's own name over the network is a separate + // tab: the own tab holds the local index, not a peer's answer. + assert!(tabs.open("tester")); + assert_eq!(tabs.tabs.len(), 2); + assert!(!tabs.active_tab().expect("tab").own); + assert_eq!(tabs.own_index(), Some(0)); + + // The peer of that name is downloadable; we are not. + assert_eq!(tabs.download_target(), Some("tester")); + tabs.active = 0; + assert_eq!(tabs.download_target(), None); + } + #[test] fn browse_tabs_reopen_after_timeout_requests_again() { let mut tabs = BrowseTabs::new(); diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 73d4ee1..6339eb1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -14,8 +14,8 @@ use crate::daemon::proto::{ DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, PasswordParams, QueryParams, Request, Response, RoomRef, RpcError, - SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, - TransferRef, Uploads, UserRef, UserResult, Watched, + SayParams, SearchResults, Searches, Seconds, SharedListing, SharesStatus, + SlotsParams, TransferRef, Uploads, UserRef, UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -823,6 +823,18 @@ impl SessionApi for RemoteSession { self.shares().directories } + // ponytail: a blocking round-trip that carries the whole index — a very + // large share is tens of MB of JSON before the next redraw. It is only + // ever a keypress, never a frame; if that stops being tolerable, push it + // as an event the way `browse.user` answers with `event.browse`. + fn shared_listing(&self) -> Vec { + self.request::<_, SharedListing>(Method::SharesFiles, ()) + .map(|listing| { + listing.directories.into_iter().map(Into::into).collect() + }) + .unwrap_or_default() + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { self.tell(Method::SharesSet, DirectoriesParams { directories }) } diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 2a0ea7b..b0e8b58 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -167,6 +167,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Shares(SharesCommand::Status) => { crate::commands::settings::shares_status(&ctx) } + Commands::Shares(SharesCommand::Files { ref filter }) => { + crate::commands::settings::shares_files(&ctx, filter.as_deref()) + } Commands::Shares(SharesCommand::Reindex) => { crate::commands::settings::shares_reindex(&ctx) } diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index e9fc1bd..2727014 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -54,6 +54,14 @@ impl MainTui { self.sync_browse_selection(); return; } + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } + KeyCode::Char('r') if self.state.browse.active_is_own() => { + self.reindex_shares(); + return; + } KeyCode::Char('r') => { // Retry a timed-out browse. if let Some(username) = self.state.browse.retry_active() { @@ -61,6 +69,13 @@ impl MainTui { } return; } + // The shared folders themselves are the settings pane's business, + // so adding or dropping one is a keystroke away from seeing it. + KeyCode::Char('o') if self.state.browse.active_is_own() => { + self.state.show_browse = false; + self.open_settings(); + return; + } _ => {} } @@ -192,9 +207,11 @@ impl MainTui { } /// Queue downloads of `files` (path, size) from the active browse tab's user. + /// + /// Our own shares are already on this disk, so the own tab queues nothing. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = - self.state.browse.active_tab().map(|b| b.username.clone()) + self.state.browse.download_target().map(str::to_string) else { return; }; @@ -240,6 +257,31 @@ impl MainTui { self.sync_browse_selection(); } + /// Open (or focus) the view of what this session shares, filled from the + /// index the network is served from. + pub(super) fn show_own_shares(&mut self) { + if self.state.browse.open_own(&self.client.username()) { + self.load_own_shares(); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// Put a fresh copy of the share index into the own tab, if it is open. + /// A re-index or an added folder would otherwise leave it showing what + /// the network saw a scan ago. + pub(super) fn load_own_shares(&mut self) { + // The early return matters: without an own tab there is nothing to + // fill, and against a daemon asking anyway is a whole index over the + // socket for nobody. + let Some(idx) = self.state.browse.own_index() else { + return; + }; + let listing = self.client.shared_listing(); + self.state.browse.tabs[idx].load(&listing); + self.sync_browse_selection(); + } + /// The username of the highlighted search result (filter-aware). pub(super) fn highlighted_result_owner(&self) -> Option { self.highlighted_result().map(|f| f.username.clone()) diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index bed4c20..1377491 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -144,6 +144,12 @@ impl MainTui { self.open_settings(); return; } + // The same tree a peer gets, from our own index: what we look + // like to the network, rather than what we meant to share. + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } KeyCode::Char('b') => { // From a highlighted search result, browse its owner directly; // otherwise prompt for a username. diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 034dc5e..67178af 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -297,6 +297,8 @@ mod tests { shares_set: std::sync::Mutex>>, /// What `change_password` was last asked for, if anything. password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, @@ -559,6 +561,9 @@ mod tests { Some(password.to_string()); Ok(()) } + fn shared_listing(&self) -> Vec { + self.listing.lock().expect("not poisoned").clone() + } fn shared_directories(&self) -> Vec { Vec::new() } @@ -1902,6 +1907,68 @@ mod tests { let _ = screen_of(tui); } + #[test] + fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { + let session = Arc::new(TalkativeSession::default()); + *session.listing.lock().expect("not poisoned") = + vec![soulseek_rs::SharedDirectory { + name: "Music\\Album".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "track.mp3".to_string(), + size: 4096, + attributes: Vec::new(), + }], + }]; + let mut tui = attach(session.clone()); + + press(&mut tui, KeyCode::Char('B')); + assert!(tui.state.show_browse, "the popup opens"); + let tab = tui.state.browse.active_tab().expect("tab"); + assert!(tab.own, "on this session's own index"); + assert_eq!(tab.username, "tester"); + assert_eq!(tab.file_count, 1); + let screen = screen_of(&mut tui); + assert!(screen.contains("My shares"), "{screen}"); + press(&mut tui, KeyCode::Char('L')); + let screen = screen_of(&mut tui); + assert!( + screen.contains("Album") && screen.contains("track.mp3"), + "the virtual path a peer would see, folder by folder: {screen}" + ); + + // Nothing here is downloadable from ourselves. + press(&mut tui, KeyCode::Char('d')); + assert!(tui.state.downloads.is_empty()); + + // `r` re-scans the disk rather than re-asking a peer, and the tab + // picks up what the fresh scan found. + session.listing.lock().expect("not poisoned").clear(); + press(&mut tui, KeyCode::Char('r')); + assert!( + session.shares_set.lock().expect("not poisoned").is_some(), + "the session was asked to re-index" + ); + assert_eq!( + tui.state.browse.active_tab().expect("tab").file_count, + 0, + "the view follows the new index" + ); + + // A second B focuses the tab it already opened. + press(&mut tui, KeyCode::Esc); + press(&mut tui, KeyCode::Char('B')); + assert_eq!(tui.state.browse.tabs.len(), 1); + } + + #[test] + fn o_leaves_the_share_view_for_the_folders_that_feed_it() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + press(&mut tui, KeyCode::Char('B')); + press(&mut tui, KeyCode::Char('o')); + assert!(!tui.state.show_browse, "the popup steps aside"); + assert!(tui.state.settings.is_some(), "for the settings pane"); + } + #[test] fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { let session = Arc::new(TalkativeSession::default()); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 34a312d..a397e4b 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -39,6 +39,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ &[ ("s", "search the network"), ("b", "browse a user's files"), + ("B", "browse what you share yourself"), ("m", "compose a private message"), ("i", "inbox"), ("o", "settings"), @@ -121,7 +122,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("/", "filter by path, Enter keeps it, Esc clears"), ("Enter", "open a folder, or download a file"), ("d", "download a file, or a folder's files"), - ("r", "ask again after a timeout"), + ("r", "ask again after a timeout, re-index your own"), + ("o", "shared folders (your own tab)"), ], ), ]; @@ -730,19 +732,35 @@ impl MainTui { // Typing goes to the filter, so no letter keys are on offer. filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { - vec![ + // Nothing of ours is downloadable from ourselves, and `r` + // re-scans the disk instead of re-asking a peer. + let acts = if self.state.browse.active_is_own() { + [ + ("Enter", "open folder"), + ("r", "re-index"), + ("o", "shared folders"), + ] + } else { + [ + ("Enter", "open/download"), + ("d", "download folder"), + ("r", "retry"), + ] + }; + let mut keys = vec![ ("↑↓", "move"), ("J/K", "next/prev folder"), ("→←", "expand/collapse"), ("H/L", "collapse/expand all"), ("/", "filter"), - ("Enter", "open/download"), - ("d", "download folder"), + ]; + keys.extend(acts); + keys.extend([ ("Tab", "switch user"), - ("r", "retry"), ("w", "close tab"), ("Esc", "hide"), - ] + ]); + keys } } else if self.state.command_bar_active { match self.state.command_bar_mode { diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 63f568e..40717bc 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -128,6 +128,7 @@ impl MainTui { } } self.set_settings_status(status); + self.load_own_shares(); } /// Bring the account block's share count back in step, and hand the same @@ -147,14 +148,22 @@ impl MainTui { } /// Re-scan the current share paths (picks up files changed on disk). - fn reindex_shares(&mut self) { + // ponytail: the scan runs on the UI thread, so a very large share set + // freezes the window for its duration. Move it to a worker if that bites. + pub(super) fn reindex_shares(&mut self) { let dirs = self.client.shared_directories(); let result = self.client.set_shared_directories(dirs); - let counts = self.refresh_share_counts(); - self.set_settings_status(match result { - Ok(()) => format!("Re-indexed · sharing {counts}"), - Err(e) => format!("Re-index failed: {e}"), - }); + // Only the settings pane shows these, and `r` in the share view + // calls us with the pane closed — where the counts would be a + // round-trip against the daemon for a string nobody reads. + if self.state.settings.is_some() { + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { + Ok(()) => format!("Re-indexed · sharing {counts}"), + Err(e) => format!("Re-index failed: {e}"), + }); + } + self.load_own_shares(); } fn share_counts(&self) -> String { diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 692f952..cc95259 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -63,7 +63,8 @@ fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { } else { dimmed_style() }; - spans.push(Span::styled(format!(" {} ", tab.username), style)); + let you = if tab.own { " (you)" } else { "" }; + spans.push(Span::styled(format!(" {}{you} ", tab.username), style)); spans.push(Span::raw(" ")); } frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -78,24 +79,33 @@ fn render_browse_one( spinner_state: usize, downloads: &[DownloadEntry], ) { + // Our own index is not downloadable and re-scans instead of re-asking, + // so it names itself and offers its own keys. + let heading = if browse.own { + "My shares".to_string() + } else { + format!("Browse {}", browse.username) + }; let title = match browse.status { BrowseStatus::Loaded => { + let keys = if browse.own { + "/: filter, r: re-index, o: folders, w: close, Esc: hide" + } else { + "Enter/d: download, /: filter, Tab: user, w: close, Esc: hide" + }; let shown = browse.rows().iter().filter(|row| !row.is_folder).count(); filter_title( - &format!( - "Browse {} — {shown}/{} files", - browse.username, browse.file_count - ), + &format!("{heading} — {shown}/{} files", browse.file_count), browse.filter(), browse.filtering, &format!( - " Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ", - browse.username, browse.file_count, browse.folder_count + " {heading} — {} files, {} folders ({keys}) ", + browse.file_count, browse.folder_count ), ) } - _ => format!(" Browse {} ", browse.username), + _ => format!(" {heading} "), }; let block = pane_block(true).title(title); @@ -112,7 +122,12 @@ fn render_browse_one( ); } BrowseStatus::Empty => { - let text = format!("{} is not sharing any files.", browse.username); + let text = if browse.own { + "You are not sharing any files. Press o to add a folder." + .to_string() + } else { + format!("{} is not sharing any files.", browse.username) + }; frame.render_widget( Paragraph::new(text).style(dimmed_style()).block(block), area, @@ -333,14 +348,17 @@ mod tests { } } - fn screen_with(downloads: &[DownloadEntry]) -> String { - let mut browse = BrowseState::loading("bob".to_string()); - browse.load(&[share()]); + /// One tab drawn onto a `width`-wide buffer. + fn screen( + browse: BrowseState, + downloads: &[DownloadEntry], + width: u16, + ) -> String { let mut tabs = BrowseTabs::default(); tabs.tabs.push(browse); let mut state = TableState::default(); let mut terminal = - Terminal::new(TestBackend::new(60, 8)).expect("backend"); + Terminal::new(TestBackend::new(width, 8)).expect("backend"); terminal .draw(|frame| { render_browse_pane( @@ -356,6 +374,12 @@ mod tests { terminal.backend().to_string() } + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + screen(browse, downloads, 60) + } + #[test] fn a_downloading_file_carries_its_transfer_glyph() { let screen = screen_with(&[ @@ -424,6 +448,34 @@ mod tests { assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); } + /// The own tab, rendered with `directories` as its index. Wider than the + /// peer screens because the title carries the keys it offers. + fn own_screen(directories: &[SharedDirectory]) -> String { + let mut browse = BrowseState::loading_own("tester".to_string()); + browse.load(directories); + screen(browse, &[], 90) + } + + #[test] + fn the_own_tab_names_itself_and_offers_no_download() { + let screen = own_screen(&[share()]); + assert!(screen.contains("My shares"), "{screen}"); + assert!(!screen.contains("Browse tester"), "{screen}"); + assert!(screen.contains("re-index"), "r re-scans instead: {screen}"); + assert!( + !screen.contains("download"), + "nothing here is downloadable from ourselves: {screen}" + ); + assert!(screen.contains("plain.mp3"), "the tree still shows"); + } + + #[test] + fn sharing_nothing_says_how_to_start() { + let screen = own_screen(&[]); + assert!(screen.contains("not sharing any files"), "{screen}"); + assert!(screen.contains("Press o to add a folder"), "{screen}"); + } + #[test] fn another_user_s_transfers_mark_nothing() { let mut theirs = download("active.mp3", DownloadStatus::Completed); diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index b045e31..b05bf93 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -2299,6 +2299,87 @@ fn shares_status_reports_what_the_network_will_see() { assert!(record["folders"].as_u64().is_some_and(|n| n >= 1)); } +/// The index, file by file, in the records `browse` gives for a peer — the +/// point being that the two are comparable: what the network sees of us +/// reads exactly like what it sees of anyone else. +#[test] +fn shares_files_lists_the_index_as_a_peer_would_receive_it() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write(share.path().join("top.bin"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("album").join("deep.bin"), probe_bytes()) + .expect("share file"); + + let listed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + ], + ); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + + let paths: Vec = records(&listed) + .iter() + .map(|line| { + serde_json::from_str::(line).expect("JSON") + ["path"] + .as_str() + .expect("a path") + .to_string() + }) + .collect(); + assert_eq!(paths.len(), 2, "both files, got {paths:?}"); + assert!( + paths.iter().any(|p| p.ends_with("album\\deep.bin")), + "the nested file carries the backslash path peers download by: \ + {paths:?}" + ); + + let first = json_record(&listed); + assert_eq!(first["user"], "cli_e2e_share_files"); + assert!(first["size"].as_u64().is_some_and(|size| size > 0)); + + // The filter narrows to matching paths, and finding nothing is exit 4 + // rather than an empty success. + let narrowed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "DEEP", + ], + ); + assert_eq!(code(&narrowed), EXIT_OK, "stderr: {}", stderr(&narrowed)); + assert_eq!(records(&narrowed).len(), 1, "case-insensitively"); + + let missing = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "nothing-like-this", + ], + ); + assert_eq!(code(&missing), EXIT_NO_RESULTS); +} + #[test] fn shares_reindex_picks_up_a_file_added_after_startup() { let server = server_or_skip!(); diff --git a/soulseek-rs/tests/tui_e2e.rs b/soulseek-rs/tests/tui_e2e.rs index 6436c57..10db6b1 100644 --- a/soulseek-rs/tests/tui_e2e.rs +++ b/soulseek-rs/tests/tui_e2e.rs @@ -126,6 +126,41 @@ fn finished(tui: &MainTui, _: &str) -> bool { tui.state().searches[0].status == SearchStatus::Completed } +/// The window's answer to "am I actually sharing that?", against a real +/// scan of a real folder rather than a stand-in listing. +#[test] +fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() { + let server = server_or_skip!(); + + let share = tempfile::tempdir().expect("share dir"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write( + share.path().join("album").join("tui_probe_own.bin"), + [3u8; 32], + ) + .expect("share file"); + + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + let me = Arc::new(server.client( + "tui_e2e_own_shares", + vec![share.path().display().to_string()], + )); + let mut window = Window::open(me, state.path(), downloads.path()); + + window.press(KeyCode::Char('B')); + window.press(KeyCode::Char('L')); + let screen = window.frame(); + assert!(screen.contains("My shares"), "{screen}"); + assert!( + screen.contains("album") && screen.contains("tui_probe_own.bin"), + "the scanned tree, folder by folder:\n{screen}" + ); + + window.press(KeyCode::Esc); + assert!(!window.tui.state().show_browse, "Esc hides the popup"); +} + #[test] fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() { let server = server_or_skip!();