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.
This commit is contained in:
@@ -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<String> {
|
||||
self.highlighted_result().map(|f| f.username.clone())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -297,6 +297,8 @@ mod tests {
|
||||
shares_set: std::sync::Mutex<Option<Vec<String>>>,
|
||||
/// What `change_password` was last asked for, if anything.
|
||||
password_set: std::sync::Mutex<Option<String>>,
|
||||
/// The share index this session serves to peers.
|
||||
listing: std::sync::Mutex<Vec<soulseek_rs::SharedDirectory>>,
|
||||
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
||||
@@ -559,6 +561,9 @@ mod tests {
|
||||
Some(password.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
|
||||
self.listing.lock().expect("not poisoned").clone()
|
||||
}
|
||||
fn shared_directories(&self) -> Vec<String> {
|
||||
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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user