From aa8f4182d3f2036f971bd95355313b17752322f6 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 19:27:05 +0200 Subject: [PATCH] feat(tui): hierarchical browse tree with file/folder downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire browsing a user's shared files into the TUI as a modal tree popup: - press 'b' on a highlighted search result to browse that result's owner, or 'b' elsewhere to type a username (new CommandBarMode::Browse); - a new models/browse.rs synthesizes the flat SharedDirectory list into a nested, collapsible tree (intermediate folders reconstructed from the backslash paths; file download paths kept byte-exact from the original names), with expand/collapse state and a flatten-to-rows renderer — fully unit-tested; - the popup polls take_browse_result each tick (with a timeout), navigates with arrows/hjkl, expands/collapses folders, and queues downloads: Enter on a file, 'd' on a file or an entire folder subtree (recursively) — all funneled into the existing Downloads pane; - shortcuts bar advertises browse keys and surfaces our own sharing status. --- soulseek-rs/src/models/app_state.rs | 12 +- soulseek-rs/src/models/browse.rs | 442 ++++++++++++++++++++++++ soulseek-rs/src/models/mod.rs | 2 + soulseek-rs/src/ui/main_tui.rs | 267 +++++++++++++- soulseek-rs/src/ui/panes/browse_pane.rs | 93 +++++ soulseek-rs/src/ui/panes/mod.rs | 2 + 6 files changed, 808 insertions(+), 10 deletions(-) create mode 100644 soulseek-rs/src/models/browse.rs create mode 100644 soulseek-rs/src/ui/panes/browse_pane.rs diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 1d14c0e..6cbab4d 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,4 +1,4 @@ -use crate::models::FileDisplayData; +use crate::models::{BrowseState, FileDisplayData}; use ratatui::{layout::Rect, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; @@ -37,6 +37,7 @@ pub enum FocusedPane { pub enum CommandBarMode { Search, Message, + Browse, } /// Direction of a private message relative to the local user. @@ -91,6 +92,11 @@ pub struct AppState { pub messages: Vec, pub show_messages: bool, + // Browse a user's shared files + pub browse: Option, + pub show_browse: bool, + pub browse_table_state: TableState, + // Pane areas for mouse interaction pub searches_pane_area: Option, pub results_pane_area: Option, @@ -137,6 +143,10 @@ impl AppState { messages: Vec::new(), show_messages: false, + browse: None, + show_browse: false, + browse_table_state: TableState::default(), + searches_pane_area: None, results_pane_area: None, downloads_pane_area: None, diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs new file mode 100644 index 0000000..5075d73 --- /dev/null +++ b/soulseek-rs/src/models/browse.rs @@ -0,0 +1,442 @@ +//! 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 (basename, size) in &dir.files { + let file_path = if dir_path.is_empty() { + basename.clone() + } else { + format!("{dir_path}\\{basename}") + }; + node.files.push((basename.clone(), *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, +} + +/// Flatten the tree into visible rows, descending only into expanded folders. +#[must_use] +pub fn flatten_browse( + nodes: &[BrowseNode], + expanded: &HashSet, +) -> Vec { + let mut rows = Vec::new(); + flatten_into(nodes, 0, expanded, &mut rows); + rows +} + +fn flatten_into( + nodes: &[BrowseNode], + depth: usize, + expanded: &HashSet, + rows: &mut Vec, +) { + for node in nodes { + match node { + BrowseNode::Folder { + name, + path, + children, + } => { + let is_expanded = expanded.contains(path); + rows.push(BrowseRow { + depth, + is_folder: true, + expanded: is_expanded, + name: name.clone(), + path: path.clone(), + size: None, + }); + if is_expanded { + flatten_into(children, depth + 1, expanded, rows); + } + } + BrowseNode::File { name, path, size } => { + rows.push(BrowseRow { + depth, + is_folder: false, + expanded: false, + name: name.clone(), + path: path.clone(), + size: Some(*size), + }); + } + } + } +} + +/// Find a node by its full path. +#[must_use] +pub 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). +#[must_use] +pub 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, + pub expanded: HashSet, + 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(), + 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; + } + + /// The flattened visible rows for the current expansion state. + #[must_use] + pub fn rows(&self) -> Vec { + flatten_browse(&self.tree, &self.expanded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { + SharedDirectory { + name: name.to_string(), + files: files.iter().map(|(n, s)| ((*n).to_string(), *s)).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_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); + } +} diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index ec06964..399e4c3 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -1,8 +1,10 @@ mod app_state; +mod browse; mod file_display_data; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, MessageDirection, SearchEntry, SearchStatus, }; +pub use browse::{BrowseState, BrowseStatus, files_under, find_node}; pub use file_display_data::FileDisplayData; diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index d0d5f5b..e94baeb 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -1,10 +1,11 @@ use crate::models::{ - AppState, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData, - FocusedPane, MessageDirection, SearchEntry, SearchStatus, + AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode, + DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, SearchEntry, + SearchStatus, files_under, find_node, }; use crate::ui::panes::{ - ResultsPaneParams, render_download_info_pane, render_downloads_pane, - render_results_pane, render_searches_pane, + ResultsPaneParams, render_browse_pane, render_download_info_pane, + render_downloads_pane, render_results_pane, render_searches_pane, }; use crate::ui::{ border_style, border_type, format_shortcuts_styled, render_download_stats, @@ -32,11 +33,16 @@ use std::{ const COMMAND_BAR_PREFIX: &str = "search: "; const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): "; +const BROWSE_BAR_PREFIX: &str = "browse user: "; + +/// How long to wait for a browse response before showing a timeout notice. +const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); const fn command_bar_prefix(mode: CommandBarMode) -> &'static str { match mode { CommandBarMode::Search => COMMAND_BAR_PREFIX, CommandBarMode::Message => MESSAGE_BAR_PREFIX, + CommandBarMode::Browse => BROWSE_BAR_PREFIX, } } @@ -97,6 +103,9 @@ impl MainTui { // Poll for incoming private messages self.poll_private_messages(); + // Poll for a browse (shared-file listing) response + self.poll_browse_result(); + // Update spinner self.spinner_state = (self.spinner_state + 1) % 10; @@ -249,6 +258,21 @@ impl MainTui { if self.state.show_messages { self.render_messages_popup(frame); } + + // Browse tree overlays everything when open. + if self.state.show_browse + && let Some(browse) = self.state.browse.as_ref() + { + let area = centered_rect(80, 80, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + browse, + &mut self.state.browse_table_state, + self.spinner_state, + ); + } } fn render_messages_popup(&self, frame: &mut Frame) { @@ -289,7 +313,15 @@ impl MainTui { } fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { - let shortcuts = if self.state.command_bar_active { + let shortcuts = if self.state.show_browse { + vec![ + ("↑↓", "move"), + ("→←", "expand/collapse"), + ("Enter", "open/download"), + ("d", "download folder"), + ("Esc", "close"), + ] + } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ ("Type", "search term"), @@ -303,6 +335,11 @@ impl MainTui { ("Enter", "send"), ("Esc", "cancel"), ], + CommandBarMode::Browse => vec![ + ("Type", "username"), + ("Enter", "browse"), + ("Esc", "cancel"), + ], } } else { match self.state.focused_pane { @@ -310,10 +347,10 @@ impl MainTui { ("s", "search"), ("m", "message"), ("i", "inbox"), + ("b", "browse user"), ("1-3", "focus pane"), ("↑↓", "navigate"), ("Enter", "view results"), - ("d", "remove search"), ("q", "quit"), ], FocusedPane::Results if self.state.results_is_filtering => { @@ -326,15 +363,16 @@ impl MainTui { } FocusedPane::Results => vec![ ("Space", "select"), + ("Enter", "download"), + ("b", "browse owner"), ("/", "filter"), ("a/A", "select all/none"), - ("Enter", "download"), ("1-3", "focus pane"), - ("↑↓", "navigate"), ("q", "quit"), ], FocusedPane::Downloads => { vec![ + ("b", "browse user"), ("1-3", "focus pane"), ("↑↓", "navigate"), ("p", "pause/resume"), @@ -346,11 +384,16 @@ impl MainTui { }; let shortcuts_line = format_shortcuts_styled(&shortcuts); + // Surface our own sharing status in the block title. + let title = format!( + "Shortcuts · Sharing: {}", + self.client.shared_directory().unwrap_or("off") + ); let shortcuts_widget = Paragraph::new(shortcuts_line).block( Block::default() .borders(Borders::ALL) .border_type(border_type(false)) - .title("Shortcuts"), + .title(title), ); frame.render_widget(shortcuts_widget, area); @@ -402,6 +445,11 @@ impl MainTui { return; } + // Browse popup takes over navigation while open. + if self.state.show_browse { + return self.handle_browse_input(key); + } + // Filter mode in Results pane if self.state.results_is_filtering && self.state.focused_pane == FocusedPane::Results @@ -445,6 +493,21 @@ impl MainTui { self.state.show_messages = true; return; } + KeyCode::Char('b') => { + // From a highlighted search result, browse its owner directly; + // otherwise prompt for a username. + if self.state.focused_pane == FocusedPane::Results + && let Some(owner) = self.highlighted_result_owner() + { + self.start_browse(owner); + } else { + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Browse; + self.state.command_bar_input.clear(); + self.state.command_bar_cursor_position = 0; + } + return; + } _ => {} } @@ -456,6 +519,143 @@ impl MainTui { } } + fn handle_browse_input(&mut self, key: KeyEvent) { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { + self.state.show_browse = false; + return; + } + + // Snapshot the flattened rows + current selection, then drop the borrow. + let (rows, sel, row) = { + let Some(browse) = self.state.browse.as_ref() else { + self.state.show_browse = false; + return; + }; + if browse.status != BrowseStatus::Loaded { + return; + } + let rows = browse.rows(); + if rows.is_empty() { + return; + } + let sel = browse.selected_row.min(rows.len() - 1); + let row = rows[sel].clone(); + (rows, sel, row) + }; + + // Downloads need `&self.client` free of the browse borrow. + match key.code { + KeyCode::Enter if !row.is_folder => { + self.queue_browse_files(vec![( + row.path.clone(), + row.size.unwrap_or(0), + )]); + return; + } + KeyCode::Char('d') => { + let files = if row.is_folder { + self.browse_folder_files(&row.path) + } else { + vec![(row.path.clone(), row.size.unwrap_or(0))] + }; + self.queue_browse_files(files); + return; + } + _ => {} + } + + // Navigation and expand/collapse mutate the browse state. + if let Some(browse) = self.state.browse.as_mut() { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + browse.selected_row = sel.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + browse.selected_row = (sel + 1).min(rows.len() - 1); + } + KeyCode::Right | KeyCode::Char('l') => { + if row.is_folder && !row.expanded { + browse.expanded.insert(row.path.clone()); + } else if row.is_folder { + browse.selected_row = (sel + 1).min(rows.len() - 1); + } + } + KeyCode::Left | KeyCode::Char('h') => { + if row.is_folder && row.expanded { + browse.expanded.remove(&row.path); + } else if let Some(parent) = + (0..sel).rev().find(|&i| rows[i].depth < row.depth) + { + browse.selected_row = parent; + } + } + KeyCode::Enter => { + // Folder toggle (files handled above). + if row.expanded { + browse.expanded.remove(&row.path); + } else { + browse.expanded.insert(row.path.clone()); + } + } + _ => {} + } + // Re-clamp against the new flattened length. + let new_len = browse.rows().len(); + browse.selected_row = + browse.selected_row.min(new_len.saturating_sub(1)); + } + + let selected = self.state.browse.as_ref().map(|b| b.selected_row); + self.state.browse_table_state.select(selected); + } + + /// Files (`path`, `size`) under the browse-tree folder at `path`. + fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { + self.state + .browse + .as_ref() + .and_then(|b| find_node(&b.tree, path)) + .map(files_under) + .unwrap_or_default() + } + + /// Queue downloads of `files` (path, size) from the currently-browsed user. + fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { + let Some(username) = + self.state.browse.as_ref().map(|b| b.username.clone()) + else { + return; + }; + if files.is_empty() { + return; + } + if self.state.downloads_receiver_channel.is_none() { + let (sender, receiver) = mpsc::channel(); + self.state.downloads_receiver_channel = Some(receiver); + self.state.downloads_sender_channel = Some(sender); + } + let client = self.client.clone(); + let download_dir = self.download_dir.clone(); + let sender = self.state.downloads_sender_channel.clone().unwrap(); + thread::spawn(move || { + for (path, size) in files { + match client.download( + path.clone(), + username.clone(), + size, + download_dir.clone(), + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => { + eprintln!("Failed to queue download {path}: {e}"); + } + } + } + }); + } + fn handle_command_bar_input(&mut self, key: KeyEvent) { self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary( &self.state.command_bar_input, @@ -471,6 +671,7 @@ impl MainTui { CommandBarMode::Message => { self.send_message_from_input(&input); } + CommandBarMode::Browse => self.start_browse(input), } } self.state.command_bar_active = false; @@ -938,6 +1139,54 @@ impl MainTui { } } + /// Request a user's shared files and open the browse popup. + fn start_browse(&mut self, username: String) { + let username = username.trim().to_string(); + if username.is_empty() { + return; + } + let _ = self.client.browse_user(&username); + self.state.browse = Some(BrowseState::loading(username)); + self.state.show_browse = true; + self.state.browse_table_state.select(Some(0)); + } + + /// The username of the highlighted search result (filter-aware). + fn highlighted_result_owner(&self) -> Option { + let selected = self.state.results_table_state.selected()?; + let items = if self.state.results_filter_query.is_empty() { + &self.state.results_items + } else { + &self.state.results_filtered_items + }; + items.get(selected).map(|f| f.username.clone()) + } + + /// Drain a browse response into the browse state, or time it out. + fn poll_browse_result(&mut self) { + let Some((username, status, requested_at)) = self + .state + .browse + .as_ref() + .map(|b| (b.username.clone(), b.status, b.requested_at)) + else { + return; + }; + if status != BrowseStatus::Loading { + return; + } + if let Some(directories) = self.client.take_browse_result(&username) { + if let Some(browse) = self.state.browse.as_mut() { + browse.load(&directories); + } + self.state.browse_table_state.select(Some(0)); + } else if requested_at.elapsed() > BROWSE_TIMEOUT + && let Some(browse) = self.state.browse.as_mut() + { + browse.status = BrowseStatus::TimedOut; + } + } + /// Parse a ` ` compose line and send it. fn send_message_from_input(&mut self, input: &str) { let Some((recipient, text)) = input.split_once(char::is_whitespace) diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs new file mode 100644 index 0000000..2dd05f6 --- /dev/null +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -0,0 +1,93 @@ +use crate::models::{BrowseState, BrowseStatus}; +use crate::ui::{ + HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes, + get_spinner_char, highlight_style, primary_style, +}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + text::Line, + widgets::{ + Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, + TableState, + }, +}; + +/// Render the browse popup: a collapsible tree of a peer's shared files. +pub fn render_browse_pane( + frame: &mut Frame, + area: Rect, + browse: &BrowseState, + table_state: &mut TableState, + spinner_state: usize, +) { + let title = match browse.status { + BrowseStatus::Loaded => format!( + " Browse {} — {} files, {} folders (Enter/d: download, Esc: close) ", + browse.username, browse.file_count, browse.folder_count + ), + _ => format!(" Browse {} ", browse.username), + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(true)) + .border_type(border_type(true)) + .title(title); + + match browse.status { + BrowseStatus::Loading => { + let text = format!( + "{} Requesting shared files from {}…", + get_spinner_char(spinner_state), + browse.username + ); + frame.render_widget(Paragraph::new(text).block(block), area); + } + BrowseStatus::Empty => { + let text = format!("{} is not sharing any files.", browse.username); + frame.render_widget(Paragraph::new(text).block(block), area); + } + BrowseStatus::TimedOut => { + let text = Line::styled( + "Timed out waiting for the file list. Press b to retry.", + error_style(), + ); + frame.render_widget(Paragraph::new(text).block(block), area); + } + BrowseStatus::Loaded => { + let rows: Vec = browse + .rows() + .iter() + .map(|row| { + let indent = " ".repeat(row.depth); + let (label, size) = if row.is_folder { + let glyph = if row.expanded { "▾" } else { "▸" }; + ( + Cell::from(format!("{indent}{glyph} {}", row.name)) + .style(primary_style()), + Cell::from(String::new()), + ) + } else { + ( + Cell::from(format!("{indent} {}", row.name)), + Cell::from( + row.size.map(format_bytes).unwrap_or_default(), + ), + ) + }; + Row::new(vec![label, size]) + }) + .collect(); + + let table = + Table::new(rows, [Constraint::Fill(1), Constraint::Length(12)]) + .row_highlight_style(highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL) + .highlight_spacing(HighlightSpacing::Always) + .block(block); + + table_state.select(Some(browse.selected_row)); + frame.render_stateful_widget(table, area, table_state); + } + } +} diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs index 109efb9..43f1a87 100644 --- a/soulseek-rs/src/ui/panes/mod.rs +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -1,8 +1,10 @@ +mod browse_pane; mod download_info_pane; mod downloads_pane; mod results_pane; mod searches_pane; +pub use browse_pane::render_browse_pane; pub use download_info_pane::render_download_info_pane; pub use downloads_pane::render_downloads_pane; pub use results_pane::{ResultsPaneParams, render_results_pane};