//! Model for browsing another user's shared files as a collapsible tree. //! //! The library returns a *flat* `Vec` where each directory //! carries its full backslash-separated path and only directories that directly //! contain files are present. This module synthesizes the intermediate folders //! into a real nested tree, and provides a flattened, expansion-aware row view //! for rendering and navigation. use soulseek_rs::SharedDirectory; use std::collections::{HashMap, HashSet}; use std::time::Instant; /// A node in the browse tree: a folder (with children) or a file leaf. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BrowseNode { Folder { /// Last path segment (display name). name: String, /// Full backslash path (expansion key + display). path: String, children: Vec, }, File { /// Basename (display name). name: String, /// Full backslash virtual path — the exact key used to download it. path: String, size: u64, }, } impl BrowseNode { fn sort_key(&self) -> String { match self { Self::Folder { name, .. } | Self::File { name, .. } => { name.to_lowercase() } } } } /// The synthesized tree plus counts for the title bar. #[derive(Debug, Default)] pub struct BuiltTree { pub nodes: Vec, pub file_count: usize, pub folder_count: usize, } /// Intermediate builder keyed by path segment while synthesizing folders. #[derive(Default)] struct Builder { full_path: String, folders: HashMap, /// `(basename, size, full_virtual_path)`. files: Vec<(String, u64, String)>, } impl Builder { fn into_nodes( self, file_count: &mut usize, folder_count: &mut usize, ) -> Vec { let mut folders: Vec = self .folders .into_iter() .map(|(name, child)| { *folder_count += 1; let path = child.full_path.clone(); let children = child.into_nodes(file_count, folder_count); BrowseNode::Folder { name, path, children, } }) .collect(); folders.sort_by_key(BrowseNode::sort_key); let mut files: Vec = self .files .into_iter() .map(|(name, size, path)| { *file_count += 1; BrowseNode::File { name, path, size } }) .collect(); files.sort_by_key(BrowseNode::sort_key); folders.extend(files); folders } } /// Build a nested tree from the peer's flat directory listing, synthesizing /// intermediate folders. File download paths are taken from the *original* /// directory name so they are byte-exact for the download request. #[must_use] pub fn build_browse_tree(directories: &[SharedDirectory]) -> BuiltTree { let mut root = Builder::default(); for dir in directories { // Walk/synthesize the folder chain, tolerating leading/trailing/doubled // backslashes by dropping empty segments. let mut node = &mut root; let mut parent_path = String::new(); for segment in dir.name.split('\\').filter(|s| !s.is_empty()) { let child_path = if parent_path.is_empty() { segment.to_string() } else { format!("{parent_path}\\{segment}") }; node = node.folders.entry(segment.to_string()).or_insert_with(|| { Builder { full_path: child_path.clone(), ..Builder::default() } }); parent_path = child_path; } // File paths come from the original name so downloads match exactly. let dir_path = dir.name.trim_end_matches('\\'); for file in &dir.files { let file_path = if dir_path.is_empty() { file.name.clone() } else { format!("{dir_path}\\{}", file.name) }; node.files.push((file.name.clone(), file.size, file_path)); } } let mut file_count = 0; let mut folder_count = 0; let nodes = root.into_nodes(&mut file_count, &mut folder_count); BuiltTree { nodes, file_count, folder_count, } } /// One rendered/navigable row of the flattened tree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BrowseRow { pub depth: usize, pub is_folder: bool, pub expanded: bool, pub name: String, pub path: String, pub size: Option, } /// Emit `nodes` as rows. Without a `needle`, every folder and file, a /// folder's contents only while it is open. With one, only the paths that /// contain it and the folders on the way there, open ones still deciding /// whether their contents show. `emit` false only counts. Says whether /// anything under `nodes` was kept. fn flatten_into( nodes: &[BrowseNode], depth: usize, expanded: &HashSet, needle: Option<&str>, rows: &mut Vec, emit: bool, ) -> bool { let mut kept = false; for node in nodes { match node { BrowseNode::Folder { name, path, children, } => { let open = expanded.contains(path); let mark = rows.len(); if emit { rows.push(BrowseRow { depth, is_folder: true, expanded: open, name: name.clone(), path: path.clone(), size: None, }); } let below = match needle { None => { if open { flatten_into( children, depth + 1, expanded, None, rows, emit, ); } true } Some(needle) => { flatten_into( children, depth + 1, expanded, Some(needle), rows, emit && open, ) || path_has(path, needle) } }; if below { kept = true; } else { rows.truncate(mark); } } BrowseNode::File { name, path, size } => { if needle.is_none_or(|needle| path_has(path, needle)) { kept = true; if emit { rows.push(BrowseRow { depth, is_folder: false, expanded: false, name: name.clone(), path: path.clone(), size: Some(*size), }); } } } } } kept } /// Whether `path` contains `needle`, which is already ASCII-lowercase. Case /// is folded for ASCII only, without allocating: a filter keystroke runs /// this over every path in a share. A letter beyond ASCII matches as typed. fn path_has(path: &str, needle: &str) -> bool { needle.is_empty() || path .as_bytes() .windows(needle.len()) .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) } /// Open every folder with a path containing `needle` somewhere under it, /// so a filter shows all it matched. Says whether anything under `nodes` /// matched. fn open_matching( nodes: &[BrowseNode], needle: &str, expanded: &mut HashSet, ) -> bool { let mut any = false; for node in nodes { match node { BrowseNode::Folder { path, children, .. } => { if open_matching(children, needle, expanded) || path_has(path, needle) { expanded.insert(path.clone()); any = true; } } BrowseNode::File { path, .. } => any |= path_has(path, needle), } } any } /// Find a node by its full path. fn find_node<'a>( nodes: &'a [BrowseNode], path: &str, ) -> Option<&'a BrowseNode> { for node in nodes { match node { BrowseNode::Folder { path: p, children, .. } => { if p == path { return Some(node); } if let Some(found) = find_node(children, path) { return Some(found); } } BrowseNode::File { path: p, .. } => { if p == path { return Some(node); } } } } None } /// All file leaves under `node` as `(full_path, size)`, descending the tree /// structurally (never string-prefix matching). fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { let mut out = Vec::new(); collect_files(node, &mut out); out } fn collect_files(node: &BrowseNode, out: &mut Vec<(String, u64)>) { match node { BrowseNode::File { path, size, .. } => out.push((path.clone(), *size)), BrowseNode::Folder { children, .. } => { for child in children { collect_files(child, out); } } } } /// Loading state of an in-flight browse. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowseStatus { Loading, Loaded, Empty, TimedOut, } /// 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, /// The tree flattened for the current expansion, kept between frames: /// a heavy sharer's tree runs to tens of thousands of rows. rows: Vec, /// Narrows the rows to paths containing it, case-insensitively. Setting /// it opens every folder with a match; clearing it puts the folders back /// the way they were, kept in `kept_expanded` meanwhile. filter: String, kept_expanded: Option>, /// Whether typing goes to the filter. pub filtering: bool, pub selected_row: usize, pub file_count: usize, pub folder_count: usize, pub requested_at: Instant, } impl BrowseState { #[must_use] pub fn loading(username: String) -> Self { Self { username, own: false, status: BrowseStatus::Loading, tree: Vec::new(), expanded: HashSet::new(), rows: Vec::new(), filter: String::new(), kept_expanded: None, filtering: false, selected_row: 0, file_count: 0, folder_count: 0, requested_at: Instant::now(), } } /// 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]) { let built = build_browse_tree(directories); self.expanded = built .nodes .iter() .filter_map(|n| match n { BrowseNode::Folder { path, .. } => Some(path.clone()), BrowseNode::File { .. } => None, }) .collect(); self.status = if built.nodes.is_empty() { BrowseStatus::Empty } else { BrowseStatus::Loaded }; self.tree = built.nodes; self.file_count = built.file_count; self.folder_count = built.folder_count; self.selected_row = 0; self.refresh(); } /// The flattened visible rows for the current expansion state. #[must_use] pub fn rows(&self) -> &[BrowseRow] { &self.rows } /// Open or close the folder at `path`. pub fn set_expanded(&mut self, path: &str, expanded: bool) { if expanded { self.expanded.insert(path.to_string()); } else { self.expanded.remove(path); } self.refresh(); } pub fn expand_all(&mut self) { folder_paths(&self.tree, &mut self.expanded); self.refresh(); } pub fn collapse_all(&mut self) { self.expanded.clear(); self.refresh(); } #[must_use] pub fn filter(&self) -> &str { &self.filter } /// Narrow the rows to `filter`, starting over from the top, with every /// folder holding a match open. An emptied filter puts the folders back /// as they were before it. pub fn set_filter(&mut self, filter: String) { if filter == self.filter { return; } if filter.is_empty() { if let Some(kept) = self.kept_expanded.take() { self.expanded = kept; } } else { if self.kept_expanded.is_none() { self.kept_expanded = Some(self.expanded.clone()); } self.expanded.clear(); open_matching( &self.tree, &filter.to_ascii_lowercase(), &mut self.expanded, ); } self.filter = filter; self.selected_row = 0; self.refresh(); } /// The files under the folder at `path`, only those the filter lets /// through: what `d` on a filtered folder downloads is what it shows. #[must_use] pub fn folder_files(&self, path: &str) -> Vec<(String, u64)> { let needle = self.filter.to_ascii_lowercase(); find_node(&self.tree, path) .map(files_under) .unwrap_or_default() .into_iter() .filter(|(path, _)| path_has(path, &needle)) .collect() } /// Re-flatten after the tree, the expansion or the filter changed under /// `rows`. fn refresh(&mut self) { let needle = (!self.filter.is_empty()).then(|| self.filter.to_ascii_lowercase()); self.rows.clear(); flatten_into( &self.tree, 0, &self.expanded, needle.as_deref(), &mut self.rows, true, ); self.selected_row = self.selected_row.min(self.rows.len().saturating_sub(1)); } } fn folder_paths(nodes: &[BrowseNode], out: &mut HashSet) { for node in nodes { if let BrowseNode::Folder { path, children, .. } = node { out.insert(path.clone()); folder_paths(children, out); } } } /// A set of open browse views (tabs), one per user, like the chat-room tabs. #[derive(Default)] pub struct BrowseTabs { pub tabs: Vec, pub active: usize, } impl BrowseTabs { #[must_use] pub const fn new() -> Self { Self { tabs: Vec::new(), active: 0, } } #[must_use] pub const fn is_empty(&self) -> bool { self.tabs.is_empty() } #[must_use] pub fn active_tab(&self) -> Option<&BrowseState> { self.tabs.get(self.active) } #[must_use] pub fn active_tab_mut(&mut self) -> Option<&mut BrowseState> { self.tabs.get_mut(self.active) } /// Focus the tab for `username`, or open a fresh loading one. Returns /// `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.own && t.username == username) { self.active = idx; // Re-request only if the previous attempt gave up. if self.tabs[idx].status == BrowseStatus::TimedOut { self.tabs[idx] = BrowseState::loading(username.to_string()); true } else { false } } else { self.tabs.push(BrowseState::loading(username.to_string())); self.active = self.tabs.len() - 1; true } } /// 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. pub fn retry_active(&mut self) -> Option { let tab = self.tabs.get_mut(self.active)?; if tab.status == BrowseStatus::TimedOut { let username = tab.username.clone(); *tab = BrowseState::loading(username.clone()); Some(username) } else { None } } /// Close the active tab, returning `false` when no tabs remain. pub fn close_active(&mut self) -> bool { if self.active < self.tabs.len() { self.tabs.remove(self.active); } if self.active >= self.tabs.len() { self.active = self.tabs.len().saturating_sub(1); } !self.tabs.is_empty() } pub const fn next_tab(&mut self) { if !self.tabs.is_empty() { self.active = (self.active + 1) % self.tabs.len(); } } pub const fn prev_tab(&mut self) { if !self.tabs.is_empty() { self.active = (self.active + self.tabs.len() - 1) % self.tabs.len(); } } } #[cfg(test)] mod tests { use super::*; /// The tree flattened for `expanded`, unfiltered. fn flatten_browse( nodes: &[BrowseNode], expanded: &HashSet, ) -> Vec { let mut rows = Vec::new(); flatten_into(nodes, 0, expanded, None, &mut rows, true); rows } fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { SharedDirectory { name: name.to_string(), files: files .iter() .map(|(n, s)| soulseek_rs::SharedFileEntry { name: (*n).to_string(), size: *s, attributes: Vec::new(), }) .collect(), } } #[test] fn intermediate_folders_are_synthesized() { let tree = build_browse_tree(&[dir("share\\music\\album", &[("a.mp3", 10)])]); // share -> music -> album -> a.mp3 assert_eq!(tree.file_count, 1); assert_eq!(tree.folder_count, 3); let BrowseNode::Folder { name, path, children, } = &tree.nodes[0] else { panic!("expected a folder"); }; assert_eq!(name, "share"); assert_eq!(path, "share"); assert!( matches!(&children[0], BrowseNode::Folder { name, .. } if name == "music") ); } #[test] fn same_named_dirs_merge_regardless_of_order() { let tree = build_browse_tree(&[ dir("share\\music\\album", &[("a.mp3", 1)]), dir("share\\music", &[("b.mp3", 2)]), ]); // A single "share" -> single "music" holding album/ and b.mp3. assert_eq!(tree.nodes.len(), 1); let BrowseNode::Folder { children: share_kids, .. } = &tree.nodes[0] else { panic!(); }; assert_eq!(share_kids.len(), 1); // just "music" let BrowseNode::Folder { children: music_kids, .. } = &share_kids[0] else { panic!(); }; // folders first (album), then files (b.mp3) assert_eq!(music_kids.len(), 2); assert!( matches!(&music_kids[0], BrowseNode::Folder { name, .. } if name == "album") ); assert!( matches!(&music_kids[1], BrowseNode::File { name, .. } if name == "b.mp3") ); } #[test] fn file_paths_are_byte_exact_from_original_name() { // Trailing backslash and a root-level (empty name) directory. let tree = build_browse_tree(&[ dir("share\\music\\", &[("song.flac", 5)]), dir("", &[("root.txt", 3)]), ]); let files = collect_all(&tree.nodes); assert!(files.contains(&("share\\music\\song.flac".to_string(), 5))); assert!(files.contains(&("root.txt".to_string(), 3))); } fn collect_all(nodes: &[BrowseNode]) -> Vec<(String, u64)> { let mut out = Vec::new(); for n in nodes { out.extend(files_under(n)); } out } #[test] fn flatten_only_descends_into_expanded_folders() { let tree = build_browse_tree(&[dir("a\\b", &[("f.mp3", 1)])]); let mut expanded = HashSet::new(); // Nothing expanded: only the top folder "a" shows. assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 1); expanded.insert("a".to_string()); assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 2); // a, b expanded.insert("a\\b".to_string()); assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 3); // a, b, f } #[test] fn files_under_collects_all_descendant_leaves() { let tree = build_browse_tree(&[ dir("a\\b", &[("x.mp3", 1)]), dir("a\\c", &[("y.mp3", 2), ("z.mp3", 3)]), ]); let all = files_under(&tree.nodes[0]); // folder "a" assert_eq!(all.len(), 3); } #[test] fn browse_tabs_open_focus_close_and_cycle() { let mut tabs = BrowseTabs::new(); assert!(tabs.is_empty()); // New users open new tabs and request a browse. assert!(tabs.open("alice")); assert!(tabs.open("bob")); assert_eq!(tabs.tabs.len(), 2); assert_eq!(tabs.active, 1); assert_eq!(tabs.active_tab().unwrap().username, "bob"); // Re-opening a live tab just focuses it (no new request). assert!(!tabs.open("alice")); assert_eq!(tabs.active, 0); assert_eq!(tabs.tabs.len(), 2); // Cycling wraps. tabs.next_tab(); assert_eq!(tabs.active, 1); tabs.next_tab(); assert_eq!(tabs.active, 0); tabs.prev_tab(); assert_eq!(tabs.active, 1); // Closing the active tab returns whether any remain. assert!(tabs.close_active()); assert_eq!(tabs.tabs.len(), 1); assert!(!tabs.close_active()); 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(); tabs.open("alice"); tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; // Re-opening a timed-out tab retries (returns true) and resets it. assert!(tabs.open("alice")); assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading); } #[test] fn browse_tabs_retry_active_only_when_timed_out() { let mut tabs = BrowseTabs::new(); tabs.open("alice"); assert_eq!(tabs.retry_active(), None); tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; // Timed-out tab retries and resets to loading. assert_eq!(tabs.retry_active().as_deref(), Some("alice")); assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading); // Now loading again, no further retry. assert_eq!(tabs.retry_active(), None); } #[test] fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() { let mut state = BrowseState::loading("bob".to_string()); state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]); assert_eq!(state.rows().len(), 2, "music open, album closed"); state.set_expanded("music\\album", true); assert_eq!(state.rows().len(), 4); state.selected_row = 3; state.set_expanded("music\\album", false); assert_eq!(state.rows().len(), 2); assert_eq!(state.selected_row, 1, "pulled back onto the last row"); } #[test] fn a_filter_opens_every_folder_with_a_match_and_folds_still_work() { let mut state = BrowseState::loading("bob".to_string()); state.load(&[ dir("music\\alpha", &[("a1.mp3", 1), ("a2.flac", 2)]), dir("music\\beta", &[("b1.mp3", 3)]), ]); let names = |state: &BrowseState| -> Vec { state.rows().iter().map(|row| row.name.clone()).collect() }; state.set_filter("beta".to_string()); assert_eq!(names(&state), ["music", "beta", "b1.mp3"]); state.set_filter("FLAC".to_string()); assert_eq!(names(&state), ["music", "alpha", "a2.flac"], "any case"); assert_eq!( state.folder_files("music"), [("music\\alpha\\a2.flac".to_string(), 2)], "a folder downloads what it shows" ); state.set_expanded("music\\alpha", false); assert_eq!(names(&state), ["music", "alpha"], "folded under a filter"); state.set_filter(String::new()); assert_eq!(state.rows().len(), 3, "the folders as they were before"); state.expand_all(); assert_eq!(state.rows().len(), 6); state.collapse_all(); assert_eq!(state.rows().len(), 1); } #[test] fn browse_state_load_auto_expands_top_level() { let mut state = BrowseState::loading("bob".to_string()); state.load(&[dir("music\\album", &[("a.mp3", 1)])]); assert_eq!(state.status, BrowseStatus::Loaded); // Top-level "music" auto-expanded → music and album rows visible. assert!(state.rows().len() >= 2); let mut empty = BrowseState::loading("nobody".to_string()); empty.load(&[]); assert_eq!(empty.status, BrowseStatus::Empty); } }