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.
This commit is contained in:
@@ -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) =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user