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:
@@ -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.
|
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.
|
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
|
### Connectivity
|
||||||
|
|
||||||
Browsing and downloading are peer-to-peer, so one side has to accept an incoming
|
Browsing and downloading are peer-to-peer, so one side has to accept an incoming
|
||||||
|
|||||||
@@ -154,23 +154,20 @@ pub struct BrowseRow {
|
|||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flatten the tree into visible rows, descending only into expanded folders.
|
/// Emit `nodes` as rows. Without a `needle`, every folder and file, a
|
||||||
#[must_use]
|
/// folder's contents only while it is open. With one, only the paths that
|
||||||
pub fn flatten_browse(
|
/// contain it and the folders on the way there, open ones still deciding
|
||||||
nodes: &[BrowseNode],
|
/// whether their contents show. `emit` false only counts. Says whether
|
||||||
expanded: &HashSet<String>,
|
/// anything under `nodes` was kept.
|
||||||
) -> Vec<BrowseRow> {
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
flatten_into(nodes, 0, expanded, &mut rows);
|
|
||||||
rows
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flatten_into(
|
fn flatten_into(
|
||||||
nodes: &[BrowseNode],
|
nodes: &[BrowseNode],
|
||||||
depth: usize,
|
depth: usize,
|
||||||
expanded: &HashSet<String>,
|
expanded: &HashSet<String>,
|
||||||
|
needle: Option<&str>,
|
||||||
rows: &mut Vec<BrowseRow>,
|
rows: &mut Vec<BrowseRow>,
|
||||||
) {
|
emit: bool,
|
||||||
|
) -> bool {
|
||||||
|
let mut kept = false;
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
match node {
|
match node {
|
||||||
BrowseNode::Folder {
|
BrowseNode::Folder {
|
||||||
@@ -178,20 +175,53 @@ fn flatten_into(
|
|||||||
path,
|
path,
|
||||||
children,
|
children,
|
||||||
} => {
|
} => {
|
||||||
let is_expanded = expanded.contains(path);
|
let open = expanded.contains(path);
|
||||||
|
let mark = rows.len();
|
||||||
|
if emit {
|
||||||
rows.push(BrowseRow {
|
rows.push(BrowseRow {
|
||||||
depth,
|
depth,
|
||||||
is_folder: true,
|
is_folder: true,
|
||||||
expanded: is_expanded,
|
expanded: open,
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
size: None,
|
size: None,
|
||||||
});
|
});
|
||||||
if is_expanded {
|
}
|
||||||
flatten_into(children, depth + 1, expanded, rows);
|
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 } => {
|
BrowseNode::File { name, path, size } => {
|
||||||
|
if needle.is_none_or(|needle| path_has(path, needle)) {
|
||||||
|
kept = true;
|
||||||
|
if emit {
|
||||||
rows.push(BrowseRow {
|
rows.push(BrowseRow {
|
||||||
depth,
|
depth,
|
||||||
is_folder: false,
|
is_folder: false,
|
||||||
@@ -203,11 +233,49 @@ fn flatten_into(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
/// Find a node by its full path.
|
||||||
#[must_use]
|
fn find_node<'a>(
|
||||||
pub fn find_node<'a>(
|
|
||||||
nodes: &'a [BrowseNode],
|
nodes: &'a [BrowseNode],
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Option<&'a BrowseNode> {
|
) -> Option<&'a BrowseNode> {
|
||||||
@@ -235,8 +303,7 @@ pub fn find_node<'a>(
|
|||||||
|
|
||||||
/// All file leaves under `node` as `(full_path, size)`, descending the tree
|
/// All file leaves under `node` as `(full_path, size)`, descending the tree
|
||||||
/// structurally (never string-prefix matching).
|
/// structurally (never string-prefix matching).
|
||||||
#[must_use]
|
fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
|
||||||
pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
collect_files(node, &mut out);
|
collect_files(node, &mut out);
|
||||||
out
|
out
|
||||||
@@ -271,6 +338,13 @@ pub struct BrowseState {
|
|||||||
/// The tree flattened for the current expansion, kept between frames:
|
/// The tree flattened for the current expansion, kept between frames:
|
||||||
/// a heavy sharer's tree runs to tens of thousands of rows.
|
/// a heavy sharer's tree runs to tens of thousands of rows.
|
||||||
rows: Vec<BrowseRow>,
|
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 selected_row: usize,
|
||||||
pub file_count: usize,
|
pub file_count: usize,
|
||||||
pub folder_count: usize,
|
pub folder_count: usize,
|
||||||
@@ -286,6 +360,9 @@ impl BrowseState {
|
|||||||
tree: Vec::new(),
|
tree: Vec::new(),
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
|
filter: String::new(),
|
||||||
|
kept_expanded: None,
|
||||||
|
filtering: false,
|
||||||
selected_row: 0,
|
selected_row: 0,
|
||||||
file_count: 0,
|
file_count: 0,
|
||||||
folder_count: 0,
|
folder_count: 0,
|
||||||
@@ -333,14 +410,89 @@ impl BrowseState {
|
|||||||
self.refresh();
|
self.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-flatten after the tree or the expansion changed under `rows`.
|
pub fn expand_all(&mut self) {
|
||||||
pub fn refresh(&mut self) {
|
folder_paths(&self.tree, &mut self.expanded);
|
||||||
self.rows = flatten_browse(&self.tree, &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 =
|
||||||
self.selected_row.min(self.rows.len().saturating_sub(1));
|
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.
|
/// A set of open browse views (tabs), one per user, like the chat-room tabs.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct BrowseTabs {
|
pub struct BrowseTabs {
|
||||||
@@ -435,6 +587,16 @@ impl BrowseTabs {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory {
|
||||||
SharedDirectory {
|
SharedDirectory {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -614,6 +776,35 @@ mod tests {
|
|||||||
assert_eq!(state.selected_row, 1, "pulled back onto the last row");
|
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]
|
#[test]
|
||||||
fn browse_state_load_auto_expands_top_level() {
|
fn browse_state_load_auto_expands_top_level() {
|
||||||
let mut state = BrowseState::loading("bob".to_string());
|
let mut state = BrowseState::loading("bob".to_string());
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ pub use app_state::{
|
|||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
|
||||||
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
|
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
pub use browse::{
|
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
|
||||||
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
|
|
||||||
};
|
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
||||||
pub use settings::{SettingsAction, SettingsMode, SettingsState};
|
pub use settings::{SettingsAction, SettingsMode, SettingsState};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use super::input::{jumped, list_jump};
|
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 ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::{thread, time::Duration};
|
use std::{thread, time::Duration};
|
||||||
|
|
||||||
@@ -9,6 +9,20 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
|
|||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
pub(super) fn handle_browse_input(&mut self, key: KeyEvent) {
|
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')) {
|
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
|
||||||
self.state.show_browse = false;
|
self.state.show_browse = false;
|
||||||
return;
|
return;
|
||||||
@@ -84,7 +98,11 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('d') => {
|
KeyCode::Char('d') => {
|
||||||
let files = if row.is_folder {
|
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 {
|
} else {
|
||||||
vec![(row.path.clone(), row.size.unwrap_or(0))]
|
vec![(row.path.clone(), row.size.unwrap_or(0))]
|
||||||
};
|
};
|
||||||
@@ -103,6 +121,24 @@ impl MainTui {
|
|||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
browse.selected_row = (sel + 1).min(len - 1);
|
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') => {
|
KeyCode::Right | KeyCode::Char('l') => {
|
||||||
if row.is_folder && !row.expanded {
|
if row.is_folder && !row.expanded {
|
||||||
browse.set_expanded(&row.path, true);
|
browse.set_expanded(&row.path, true);
|
||||||
@@ -131,22 +167,40 @@ impl MainTui {
|
|||||||
self.sync_browse_selection();
|
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.
|
/// Point the browse table cursor at the active tab's selected row.
|
||||||
fn sync_browse_selection(&mut self) {
|
fn sync_browse_selection(&mut self) {
|
||||||
let selected = self.state.browse.active_tab().map(|b| b.selected_row);
|
let selected = self.state.browse.active_tab().map(|b| b.selected_row);
|
||||||
self.state.browse_table_state.select(selected);
|
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.
|
/// Queue downloads of `files` (path, size) from the active browse tab's user.
|
||||||
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
||||||
let Some(username) =
|
let Some(username) =
|
||||||
|
|||||||
@@ -1676,6 +1676,77 @@ mod tests {
|
|||||||
assert!(tui.state.show_browse, "still open");
|
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) {
|
fn in_a_room(tui: &mut MainTui, messages: usize) {
|
||||||
tui.state.rooms.apply_event(
|
tui.state.rooms.apply_event(
|
||||||
soulseek_rs::RoomEvent::Joined {
|
soulseek_rs::RoomEvent::Joined {
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("Tab / Shift-Tab", "next room, chat or user"),
|
("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 {
|
impl MainTui {
|
||||||
@@ -551,10 +563,22 @@ impl MainTui {
|
|||||||
} else if self.state.show_rooms {
|
} else if self.state.show_rooms {
|
||||||
self.rooms_shortcuts()
|
self.rooms_shortcuts()
|
||||||
} else if self.state.show_browse {
|
} else if self.state.show_browse {
|
||||||
|
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![
|
vec![
|
||||||
("↑↓", "move"),
|
("↑↓", "move"),
|
||||||
("PgUp/PgDn", "page"),
|
("J/K", "next/prev folder"),
|
||||||
("→←", "expand/collapse"),
|
("→←", "expand/collapse"),
|
||||||
|
("H/L", "collapse/expand all"),
|
||||||
|
("/", "filter"),
|
||||||
("Enter", "open/download"),
|
("Enter", "open/download"),
|
||||||
("d", "download folder"),
|
("d", "download folder"),
|
||||||
("Tab", "switch user"),
|
("Tab", "switch user"),
|
||||||
@@ -562,6 +586,7 @@ impl MainTui {
|
|||||||
("w", "close tab"),
|
("w", "close tab"),
|
||||||
("Esc", "hide"),
|
("Esc", "hide"),
|
||||||
]
|
]
|
||||||
|
}
|
||||||
} else if self.state.command_bar_active {
|
} else if self.state.command_bar_active {
|
||||||
match self.state.command_bar_mode {
|
match self.state.command_bar_mode {
|
||||||
CommandBarMode::Search => vec![
|
CommandBarMode::Search => vec![
|
||||||
|
|||||||
@@ -62,8 +62,20 @@ fn render_browse_one(
|
|||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
) {
|
) {
|
||||||
let title = match browse.status {
|
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!(
|
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
|
browse.username, browse.file_count, browse.folder_count
|
||||||
),
|
),
|
||||||
_ => format!(" Browse {} ", browse.username),
|
_ => format!(" Browse {} ", browse.username),
|
||||||
@@ -105,6 +117,15 @@ fn render_browse_one(
|
|||||||
];
|
];
|
||||||
frame.render_widget(Paragraph::new(text).block(block), area);
|
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 => {
|
BrowseStatus::Loaded => {
|
||||||
let all = browse.rows();
|
let all = browse.rows();
|
||||||
let window = visible_range(
|
let window = visible_range(
|
||||||
|
|||||||
Reference in New Issue
Block a user