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:
Michel de Graaf
2026-09-10 11:44:57 +02:00
parent d6d870b39f
commit 26b551d997
24 changed files with 606 additions and 57 deletions
+43 -1
View File
@@ -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())
+6
View File
@@ -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.
+67
View File
@@ -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());
+24 -6
View File
@@ -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 {
+15 -6
View File
@@ -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 {
+65 -13
View File
@@ -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);