From b8da8668b46cbc4c9af003c51238bc6c85031054 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Tue, 8 Sep 2026 10:45:30 +0200 Subject: [PATCH] feat(tui): folder jumps, open-all and a path filter in the browse popup 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. --- README.md | 5 + soulseek-rs/src/models/browse.rs | 267 ++++++++++++++++++++---- soulseek-rs/src/models/mod.rs | 4 +- soulseek-rs/src/ui/main_tui/browse.rs | 78 +++++-- soulseek-rs/src/ui/main_tui/mod.rs | 71 +++++++ soulseek-rs/src/ui/main_tui/render.rs | 47 ++++- soulseek-rs/src/ui/panes/browse_pane.rs | 23 +- 7 files changed, 430 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index ad6ba05..ddded7b 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,11 @@ busiest first, `Enter` to join, several rooms open at once as tabs through the member list with `b` to browse someone or `m` to message them. Unread counts appear on the tabs and on the `c chat (n)` shortcut. +`b` browses a user's shared files as a tree: `←`/`→` close and open a folder, +`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters +the whole tree by path, and `Enter` or `d` downloads a file or a folder's +files (under a filter, only the ones it shows). + ### Connectivity Browsing and downloading are peer-to-peer, so one side has to accept an incoming diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index d56d282..59ac1db 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -154,23 +154,20 @@ pub struct BrowseRow { 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 -} - +/// 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 { @@ -178,36 +175,107 @@ fn flatten_into( 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); + 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 } => { - rows.push(BrowseRow { - depth, - is_folder: false, - expanded: false, - name: name.clone(), - path: path.clone(), - size: Some(*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. -#[must_use] -pub fn find_node<'a>( +fn find_node<'a>( nodes: &'a [BrowseNode], path: &str, ) -> Option<&'a BrowseNode> { @@ -235,8 +303,7 @@ pub fn find_node<'a>( /// 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)> { +fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { let mut out = Vec::new(); collect_files(node, &mut out); out @@ -271,6 +338,13 @@ pub struct BrowseState { /// 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, @@ -286,6 +360,9 @@ impl BrowseState { 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, @@ -333,14 +410,89 @@ impl BrowseState { self.refresh(); } - /// Re-flatten after the tree or the expansion changed under `rows`. - pub fn refresh(&mut self) { - self.rows = flatten_browse(&self.tree, &self.expanded); + 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 { @@ -435,6 +587,16 @@ impl BrowseTabs { 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(), @@ -614,6 +776,35 @@ mod tests { 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()); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index bbe1a7b..2630d63 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -8,9 +8,7 @@ pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, MessageDirection, PaneLayout, SearchEntry, SearchStatus, }; -pub use browse::{ - BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, -}; +pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; pub use rooms::{RoomLine, RoomsState, RoomsView}; pub use settings::{SettingsAction, SettingsMode, SettingsState}; diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 5d16d91..9588f13 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -1,6 +1,6 @@ use super::MainTui; use super::input::{jumped, list_jump}; -use crate::models::{BrowseStatus, files_under, find_node}; +use crate::models::BrowseStatus; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::{thread, time::Duration}; @@ -9,6 +9,20 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); impl MainTui { pub(super) fn handle_browse_input(&mut self, key: KeyEvent) { + // Typing goes to the filter; the other keys still move around. + if self.state.browse.active_tab().is_some_and(|b| b.filtering) + && self.handle_browse_filter_input(key) + { + return; + } + // Esc peels back one level: a filter first, then the popup. + if key.code == KeyCode::Esc + && let Some(browse) = self.state.browse.active_tab_mut() + && !browse.filter().is_empty() + { + browse.set_filter(String::new()); + return; + } if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { self.state.show_browse = false; return; @@ -84,7 +98,11 @@ impl MainTui { } KeyCode::Char('d') => { let files = if row.is_folder { - self.browse_folder_files(&row.path) + self.state + .browse + .active_tab() + .map(|b| b.folder_files(&row.path)) + .unwrap_or_default() } else { vec![(row.path.clone(), row.size.unwrap_or(0))] }; @@ -103,6 +121,24 @@ impl MainTui { KeyCode::Down | KeyCode::Char('j') => { browse.selected_row = (sel + 1).min(len - 1); } + // The folder-sized steps: the next or previous folder row. + KeyCode::Char('J') => { + if let Some(next) = + (sel + 1..len).find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = next; + } + } + KeyCode::Char('K') => { + if let Some(previous) = + (0..sel).rev().find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = previous; + } + } + KeyCode::Char('H') => browse.collapse_all(), + KeyCode::Char('L') => browse.expand_all(), + KeyCode::Char('/') => browse.filtering = true, KeyCode::Right | KeyCode::Char('l') => { if row.is_folder && !row.expanded { browse.set_expanded(&row.path, true); @@ -131,22 +167,40 @@ impl MainTui { self.sync_browse_selection(); } + /// The keys that edit the filter while it is being typed. Says whether + /// `key` was one of them; the rest move around the narrowed rows. + fn handle_browse_filter_input(&mut self, key: KeyEvent) -> bool { + let Some(browse) = self.state.browse.active_tab_mut() else { + return false; + }; + match key.code { + KeyCode::Esc => { + browse.filtering = false; + browse.set_filter(String::new()); + } + KeyCode::Enter => browse.filtering = false, + KeyCode::Char(c) => { + let mut filter = browse.filter().to_string(); + filter.push(c); + browse.set_filter(filter); + } + KeyCode::Backspace => { + let mut filter = browse.filter().to_string(); + filter.pop(); + browse.set_filter(filter); + } + _ => return false, + } + self.sync_browse_selection(); + true + } + /// Point the browse table cursor at the active tab's selected row. fn sync_browse_selection(&mut self) { let selected = self.state.browse.active_tab().map(|b| b.selected_row); self.state.browse_table_state.select(selected); } - /// Files (`path`, `size`) under the active browse tab's folder at `path`. - fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { - self.state - .browse - .active_tab() - .and_then(|b| find_node(&b.tree, path)) - .map(files_under) - .unwrap_or_default() - } - /// Queue downloads of `files` (path, size) from the active browse tab's user. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 9b497af..ec97cb4 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1676,6 +1676,77 @@ mod tests { assert!(tui.state.show_browse, "still open"); } + #[test] + fn folder_keys_and_the_filter_move_through_a_browsed_tree() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + tui.state.browse.open("bob"); + let listing = vec![ + soulseek_rs::SharedDirectory { + name: "music\\alpha".to_string(), + files: vec![ + soulseek_rs::SharedFileEntry { + name: "a1.mp3".to_string(), + size: 1, + attributes: Vec::new(), + }, + soulseek_rs::SharedFileEntry { + name: "a2.flac".to_string(), + size: 2, + attributes: Vec::new(), + }, + ], + }, + soulseek_rs::SharedDirectory { + name: "music\\beta".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "b1.mp3".to_string(), + size: 3, + attributes: Vec::new(), + }], + }, + ]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(&mut tui); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + let rows = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").rows().len() + }; + + press(&mut tui, KeyCode::Char('L')); + assert_eq!(rows(&tui), 6, "every folder open"); + press(&mut tui, KeyCode::Char('J')); + press(&mut tui, KeyCode::Char('J')); + assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); + press(&mut tui, KeyCode::Char('K')); + assert_eq!(selected(&tui), 1, "back to alpha"); + press(&mut tui, KeyCode::Char('H')); + assert_eq!(rows(&tui), 1, "just music"); + + press(&mut tui, KeyCode::Char('/')); + for c in "flac".chars() { + press(&mut tui, KeyCode::Char(c)); + } + assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); + let screen = screen_of(&mut tui); + assert!(screen.contains("filter: flac_"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + press(&mut tui, KeyCode::Char('j')); + assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); + press(&mut tui, KeyCode::Esc); + assert_eq!(rows(&tui), 1, "Esc clears the filter first"); + assert!(tui.state.show_browse); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_browse, "then hides the popup"); + } + fn in_a_room(tui: &mut MainTui, messages: usize) { tui.state.rooms.apply_event( soulseek_rs::RoomEvent::Joined { diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 840398e..b6e4a5e 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -98,6 +98,18 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("Tab / Shift-Tab", "next room, chat or user"), ], ), + ( + "Browse popup", + &[ + ("← → / h l", "close / open a folder, or step out / in"), + ("J K", "next / previous folder"), + ("H L", "close / open every folder"), + ("/", "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"), + ], + ), ]; impl MainTui { @@ -551,17 +563,30 @@ impl MainTui { } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { - vec![ - ("↑↓", "move"), - ("PgUp/PgDn", "page"), - ("→←", "expand/collapse"), - ("Enter", "open/download"), - ("d", "download folder"), - ("Tab", "switch user"), - ("r", "retry"), - ("w", "close tab"), - ("Esc", "hide"), - ] + if self.state.browse.active_tab().is_some_and(|b| b.filtering) { + // Typing goes to the filter, so no letter keys are on offer. + vec![ + ("Type", "filter"), + ("↑/↓", "navigate"), + ("PgUp/PgDn", "page"), + ("Enter", "keep filter"), + ("Esc", "clear filter"), + ] + } else { + vec![ + ("↑↓", "move"), + ("J/K", "next/prev folder"), + ("→←", "expand/collapse"), + ("H/L", "collapse/expand all"), + ("/", "filter"), + ("Enter", "open/download"), + ("d", "download folder"), + ("Tab", "switch user"), + ("r", "retry"), + ("w", "close tab"), + ("Esc", "hide"), + ] + } } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 08299ad..6c0d7f3 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -62,8 +62,20 @@ fn render_browse_one( spinner_state: usize, ) { let title = match browse.status { + BrowseStatus::Loaded + if browse.filtering || !browse.filter().is_empty() => + { + format!( + " Browse {} — {} files, {} folders · filter: {}{} (Enter: keep, Esc: clear) ", + browse.username, + browse.file_count, + browse.folder_count, + browse.filter(), + if browse.filtering { "_" } else { "" } + ) + } BrowseStatus::Loaded => format!( - " Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ", + " Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ", browse.username, browse.file_count, browse.folder_count ), _ => format!(" Browse {} ", browse.username), @@ -105,6 +117,15 @@ fn render_browse_one( ]; frame.render_widget(Paragraph::new(text).block(block), area); } + BrowseStatus::Loaded + if browse.rows().is_empty() && !browse.filter().is_empty() => + { + let text = format!("Nothing here matches '{}'.", browse.filter()); + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } BrowseStatus::Loaded => { let all = browse.rows(); let window = visible_range(