A browsed share only moved a row at a time, so a folder of a few hundred files stood between the user and the next folder. J and K now jump between folder rows, H and L close or open every folder, and / filters the whole tree by path the way the results and the room list filter: type to narrow, Enter keeps it, Esc clears it, then hides. A folder under a filter shows the paths that match and d downloads just those. The keys popup gains a browse section and the bar lists them.
821 lines
26 KiB
Rust
821 lines
26 KiB
Rust
//! Model for browsing another user's shared files as a collapsible tree.
|
|
//!
|
|
//! The library returns a *flat* `Vec<SharedDirectory>` 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<Self>,
|
|
},
|
|
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<BrowseNode>,
|
|
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<String, Self>,
|
|
/// `(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<BrowseNode> {
|
|
let mut folders: Vec<BrowseNode> = 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<BrowseNode> = 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<u64>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
needle: Option<&str>,
|
|
rows: &mut Vec<BrowseRow>,
|
|
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<String>,
|
|
) -> 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,
|
|
pub status: BrowseStatus,
|
|
pub tree: Vec<BrowseNode>,
|
|
expanded: HashSet<String>,
|
|
/// The tree flattened for the current expansion, kept between frames:
|
|
/// a heavy sharer's tree runs to tens of thousands of rows.
|
|
rows: Vec<BrowseRow>,
|
|
/// 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<HashSet<String>>,
|
|
/// 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,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// 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<String>) {
|
|
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<BrowseState>,
|
|
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.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
|
|
}
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<String>,
|
|
) -> Vec<BrowseRow> {
|
|
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 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<String> {
|
|
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);
|
|
}
|
|
}
|