feat(tui): hierarchical browse tree with file/folder downloads
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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::FileDisplayData;
|
use crate::models::{BrowseState, FileDisplayData};
|
||||||
use ratatui::{layout::Rect, widgets::TableState};
|
use ratatui::{layout::Rect, widgets::TableState};
|
||||||
use soulseek_rs::{DownloadStatus, types::Download};
|
use soulseek_rs::{DownloadStatus, types::Download};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -37,6 +37,7 @@ pub enum FocusedPane {
|
|||||||
pub enum CommandBarMode {
|
pub enum CommandBarMode {
|
||||||
Search,
|
Search,
|
||||||
Message,
|
Message,
|
||||||
|
Browse,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direction of a private message relative to the local user.
|
/// Direction of a private message relative to the local user.
|
||||||
@@ -91,6 +92,11 @@ pub struct AppState {
|
|||||||
pub messages: Vec<ChatMessage>,
|
pub messages: Vec<ChatMessage>,
|
||||||
pub show_messages: bool,
|
pub show_messages: bool,
|
||||||
|
|
||||||
|
// Browse a user's shared files
|
||||||
|
pub browse: Option<BrowseState>,
|
||||||
|
pub show_browse: bool,
|
||||||
|
pub browse_table_state: TableState,
|
||||||
|
|
||||||
// Pane areas for mouse interaction
|
// Pane areas for mouse interaction
|
||||||
pub searches_pane_area: Option<Rect>,
|
pub searches_pane_area: Option<Rect>,
|
||||||
pub results_pane_area: Option<Rect>,
|
pub results_pane_area: Option<Rect>,
|
||||||
@@ -137,6 +143,10 @@ impl AppState {
|
|||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
show_messages: false,
|
show_messages: false,
|
||||||
|
|
||||||
|
browse: None,
|
||||||
|
show_browse: false,
|
||||||
|
browse_table_state: TableState::default(),
|
||||||
|
|
||||||
searches_pane_area: None,
|
searches_pane_area: None,
|
||||||
results_pane_area: None,
|
results_pane_area: None,
|
||||||
downloads_pane_area: None,
|
downloads_pane_area: None,
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
//! 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 (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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten the tree into visible rows, descending only into expanded folders.
|
||||||
|
#[must_use]
|
||||||
|
pub fn flatten_browse(
|
||||||
|
nodes: &[BrowseNode],
|
||||||
|
expanded: &HashSet<String>,
|
||||||
|
) -> Vec<BrowseRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
flatten_into(nodes, 0, expanded, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_into(
|
||||||
|
nodes: &[BrowseNode],
|
||||||
|
depth: usize,
|
||||||
|
expanded: &HashSet<String>,
|
||||||
|
rows: &mut Vec<BrowseRow>,
|
||||||
|
) {
|
||||||
|
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<BrowseNode>,
|
||||||
|
pub expanded: HashSet<String>,
|
||||||
|
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<BrowseRow> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
mod app_state;
|
mod app_state;
|
||||||
|
mod browse;
|
||||||
mod file_display_data;
|
mod file_display_data;
|
||||||
|
|
||||||
pub use app_state::{
|
pub use app_state::{
|
||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||||
MessageDirection, SearchEntry, SearchStatus,
|
MessageDirection, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
|
pub use browse::{BrowseState, BrowseStatus, files_under, find_node};
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData,
|
AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode,
|
||||||
FocusedPane, MessageDirection, SearchEntry, SearchStatus,
|
DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, SearchEntry,
|
||||||
|
SearchStatus, files_under, find_node,
|
||||||
};
|
};
|
||||||
use crate::ui::panes::{
|
use crate::ui::panes::{
|
||||||
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
|
ResultsPaneParams, render_browse_pane, render_download_info_pane,
|
||||||
render_results_pane, render_searches_pane,
|
render_downloads_pane, render_results_pane, render_searches_pane,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
||||||
@@ -32,11 +33,16 @@ use std::{
|
|||||||
|
|
||||||
const COMMAND_BAR_PREFIX: &str = "search: ";
|
const COMMAND_BAR_PREFIX: &str = "search: ";
|
||||||
const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): ";
|
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 {
|
const fn command_bar_prefix(mode: CommandBarMode) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
CommandBarMode::Search => COMMAND_BAR_PREFIX,
|
CommandBarMode::Search => COMMAND_BAR_PREFIX,
|
||||||
CommandBarMode::Message => MESSAGE_BAR_PREFIX,
|
CommandBarMode::Message => MESSAGE_BAR_PREFIX,
|
||||||
|
CommandBarMode::Browse => BROWSE_BAR_PREFIX,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +103,9 @@ impl MainTui {
|
|||||||
// Poll for incoming private messages
|
// Poll for incoming private messages
|
||||||
self.poll_private_messages();
|
self.poll_private_messages();
|
||||||
|
|
||||||
|
// Poll for a browse (shared-file listing) response
|
||||||
|
self.poll_browse_result();
|
||||||
|
|
||||||
// Update spinner
|
// Update spinner
|
||||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
|
||||||
@@ -249,6 +258,21 @@ impl MainTui {
|
|||||||
if self.state.show_messages {
|
if self.state.show_messages {
|
||||||
self.render_messages_popup(frame);
|
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) {
|
fn render_messages_popup(&self, frame: &mut Frame) {
|
||||||
@@ -289,7 +313,15 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
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 {
|
match self.state.command_bar_mode {
|
||||||
CommandBarMode::Search => vec![
|
CommandBarMode::Search => vec![
|
||||||
("Type", "search term"),
|
("Type", "search term"),
|
||||||
@@ -303,6 +335,11 @@ impl MainTui {
|
|||||||
("Enter", "send"),
|
("Enter", "send"),
|
||||||
("Esc", "cancel"),
|
("Esc", "cancel"),
|
||||||
],
|
],
|
||||||
|
CommandBarMode::Browse => vec![
|
||||||
|
("Type", "username"),
|
||||||
|
("Enter", "browse"),
|
||||||
|
("Esc", "cancel"),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match self.state.focused_pane {
|
match self.state.focused_pane {
|
||||||
@@ -310,10 +347,10 @@ impl MainTui {
|
|||||||
("s", "search"),
|
("s", "search"),
|
||||||
("m", "message"),
|
("m", "message"),
|
||||||
("i", "inbox"),
|
("i", "inbox"),
|
||||||
|
("b", "browse user"),
|
||||||
("1-3", "focus pane"),
|
("1-3", "focus pane"),
|
||||||
("↑↓", "navigate"),
|
("↑↓", "navigate"),
|
||||||
("Enter", "view results"),
|
("Enter", "view results"),
|
||||||
("d", "remove search"),
|
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
],
|
],
|
||||||
FocusedPane::Results if self.state.results_is_filtering => {
|
FocusedPane::Results if self.state.results_is_filtering => {
|
||||||
@@ -326,15 +363,16 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
FocusedPane::Results => vec![
|
FocusedPane::Results => vec![
|
||||||
("Space", "select"),
|
("Space", "select"),
|
||||||
|
("Enter", "download"),
|
||||||
|
("b", "browse owner"),
|
||||||
("/", "filter"),
|
("/", "filter"),
|
||||||
("a/A", "select all/none"),
|
("a/A", "select all/none"),
|
||||||
("Enter", "download"),
|
|
||||||
("1-3", "focus pane"),
|
("1-3", "focus pane"),
|
||||||
("↑↓", "navigate"),
|
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
],
|
],
|
||||||
FocusedPane::Downloads => {
|
FocusedPane::Downloads => {
|
||||||
vec![
|
vec![
|
||||||
|
("b", "browse user"),
|
||||||
("1-3", "focus pane"),
|
("1-3", "focus pane"),
|
||||||
("↑↓", "navigate"),
|
("↑↓", "navigate"),
|
||||||
("p", "pause/resume"),
|
("p", "pause/resume"),
|
||||||
@@ -346,11 +384,16 @@ impl MainTui {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let shortcuts_line = format_shortcuts_styled(&shortcuts);
|
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(
|
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(border_type(false))
|
.border_type(border_type(false))
|
||||||
.title("Shortcuts"),
|
.title(title),
|
||||||
);
|
);
|
||||||
|
|
||||||
frame.render_widget(shortcuts_widget, area);
|
frame.render_widget(shortcuts_widget, area);
|
||||||
@@ -402,6 +445,11 @@ impl MainTui {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browse popup takes over navigation while open.
|
||||||
|
if self.state.show_browse {
|
||||||
|
return self.handle_browse_input(key);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter mode in Results pane
|
// Filter mode in Results pane
|
||||||
if self.state.results_is_filtering
|
if self.state.results_is_filtering
|
||||||
&& self.state.focused_pane == FocusedPane::Results
|
&& self.state.focused_pane == FocusedPane::Results
|
||||||
@@ -445,6 +493,21 @@ impl MainTui {
|
|||||||
self.state.show_messages = true;
|
self.state.show_messages = true;
|
||||||
return;
|
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) {
|
fn handle_command_bar_input(&mut self, key: KeyEvent) {
|
||||||
self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary(
|
self.state.command_bar_cursor_position = clamp_cursor_to_char_boundary(
|
||||||
&self.state.command_bar_input,
|
&self.state.command_bar_input,
|
||||||
@@ -471,6 +671,7 @@ impl MainTui {
|
|||||||
CommandBarMode::Message => {
|
CommandBarMode::Message => {
|
||||||
self.send_message_from_input(&input);
|
self.send_message_from_input(&input);
|
||||||
}
|
}
|
||||||
|
CommandBarMode::Browse => self.start_browse(input),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.state.command_bar_active = false;
|
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<String> {
|
||||||
|
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 `<recipient> <text>` compose line and send it.
|
/// Parse a `<recipient> <text>` compose line and send it.
|
||||||
fn send_message_from_input(&mut self, input: &str) {
|
fn send_message_from_input(&mut self, input: &str) {
|
||||||
let Some((recipient, text)) = input.split_once(char::is_whitespace)
|
let Some((recipient, text)) = input.split_once(char::is_whitespace)
|
||||||
|
|||||||
@@ -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<Row> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
mod browse_pane;
|
||||||
mod download_info_pane;
|
mod download_info_pane;
|
||||||
mod downloads_pane;
|
mod downloads_pane;
|
||||||
mod results_pane;
|
mod results_pane;
|
||||||
mod searches_pane;
|
mod searches_pane;
|
||||||
|
|
||||||
|
pub use browse_pane::render_browse_pane;
|
||||||
pub use download_info_pane::render_download_info_pane;
|
pub use download_info_pane::render_download_info_pane;
|
||||||
pub use downloads_pane::render_downloads_pane;
|
pub use downloads_pane::render_downloads_pane;
|
||||||
pub use results_pane::{ResultsPaneParams, render_results_pane};
|
pub use results_pane::{ResultsPaneParams, render_results_pane};
|
||||||
|
|||||||
Reference in New Issue
Block a user