feat(tui): username is now appended to download_dir before passed as parameter fix(tui): username is sanitized before appended to path
320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
use super::MainTui;
|
|
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
|
|
use crate::models::BrowseStatus;
|
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use std::{thread, time::Duration};
|
|
|
|
/// How long to wait for a browse response before showing a timeout notice.
|
|
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;
|
|
}
|
|
// The tab bar takes a row once more than one user is open.
|
|
let chrome = u16::from(self.state.browse.tabs.len() > 1);
|
|
let jump = list_jump(key, self.popup_page(chrome));
|
|
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
|
|
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
return;
|
|
}
|
|
|
|
// Tab management: switch between browsed users or close the active tab.
|
|
match key.code {
|
|
KeyCode::Tab => {
|
|
self.state.browse.next_tab();
|
|
self.sync_browse_selection();
|
|
return;
|
|
}
|
|
KeyCode::BackTab => {
|
|
self.state.browse.prev_tab();
|
|
self.sync_browse_selection();
|
|
return;
|
|
}
|
|
KeyCode::Char('w') => {
|
|
if !self.state.browse.close_active() {
|
|
self.state.show_browse = false;
|
|
}
|
|
self.sync_browse_selection();
|
|
return;
|
|
}
|
|
KeyCode::Char('B') => {
|
|
self.show_own_shares();
|
|
return;
|
|
}
|
|
KeyCode::Char('r') if self.state.browse.active_is_own() => {
|
|
self.reindex_shares();
|
|
return;
|
|
}
|
|
KeyCode::Char('r') => {
|
|
// Retry a timed-out browse.
|
|
if let Some(username) = self.state.browse.retry_active() {
|
|
let _ = self.client.browse_user(&username);
|
|
}
|
|
return;
|
|
}
|
|
// The shared folders themselves are the settings pane's business,
|
|
// so adding or dropping one is a keystroke away from seeing it.
|
|
KeyCode::Char('o') if self.state.browse.active_is_own() => {
|
|
self.state.show_browse = false;
|
|
self.open_settings();
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// The highlighted row and where it sits, then drop the borrow.
|
|
let (len, sel, row) = {
|
|
let Some(browse) = self.state.browse.active_tab() 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);
|
|
(rows.len(), sel, rows[sel].clone())
|
|
};
|
|
|
|
if let Some(jump) = jump {
|
|
if let Some(browse) = self.state.browse.active_tab_mut() {
|
|
browse.selected_row = jumped(sel, len, jump);
|
|
}
|
|
self.sync_browse_selection();
|
|
return;
|
|
}
|
|
|
|
// 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.state
|
|
.browse
|
|
.active_tab()
|
|
.map(|b| b.folder_files(&row.path))
|
|
.unwrap_or_default()
|
|
} 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.active_tab_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(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);
|
|
} else if row.is_folder {
|
|
browse.selected_row = (sel + 1).min(len - 1);
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
if row.is_folder && row.expanded {
|
|
browse.set_expanded(&row.path, false);
|
|
} else if let Some(parent) = (0..sel)
|
|
.rev()
|
|
.find(|&i| browse.rows()[i].depth < row.depth)
|
|
{
|
|
browse.selected_row = parent;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
// Folder toggle (files handled above).
|
|
browse.set_expanded(&row.path, !row.expanded);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
let mut filter = browse.filter().to_string();
|
|
let Some(edit) = edit_filter(&mut filter, key) else {
|
|
return false;
|
|
};
|
|
browse.filtering = edit == FilterEdit::Changed;
|
|
browse.set_filter(filter);
|
|
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);
|
|
}
|
|
|
|
/// Queue downloads of `files` (path, size) from the active browse tab's user.
|
|
///
|
|
/// Our own shares are already on this disk, so the own tab queues nothing.
|
|
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
|
let Some(username) =
|
|
self.state.browse.download_target().map(str::to_string)
|
|
else {
|
|
return;
|
|
};
|
|
if files.is_empty() {
|
|
return;
|
|
}
|
|
let sender = self.downloads_sender();
|
|
let client = self.client.clone();
|
|
let download_dir = self.download_dir.clone();
|
|
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) => {
|
|
soulseek_rs::warn!(
|
|
"Failed to queue download {path}: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Request a user's shared files and open (or focus) their browse tab.
|
|
pub(super) fn start_browse(&mut self, username: String) {
|
|
let username = username.trim().to_string();
|
|
if username.is_empty() {
|
|
return;
|
|
}
|
|
// Open/focus the tab; only (re)issue the request when it's new or a
|
|
// previous attempt timed out.
|
|
if self.state.browse.open(&username) {
|
|
let _ = self.client.browse_user(&username);
|
|
}
|
|
self.state.show_browse = true;
|
|
self.sync_browse_selection();
|
|
}
|
|
|
|
/// Open (or focus) the view of what this session shares, filled from the
|
|
/// index the network is served from.
|
|
pub(super) fn show_own_shares(&mut self) {
|
|
if self.state.browse.open_own(&self.client.username()) {
|
|
self.load_own_shares();
|
|
}
|
|
self.state.show_browse = true;
|
|
self.sync_browse_selection();
|
|
}
|
|
|
|
/// Put a fresh copy of the share index into the own tab, if it is open.
|
|
/// A re-index or an added folder would otherwise leave it showing what
|
|
/// the network saw a scan ago.
|
|
pub(super) fn load_own_shares(&mut self) {
|
|
// The early return matters: without an own tab there is nothing to
|
|
// fill, and against a daemon asking anyway is a whole index over the
|
|
// socket for nobody.
|
|
let Some(idx) = self.state.browse.own_index() else {
|
|
return;
|
|
};
|
|
let listing = self.client.shared_listing();
|
|
self.state.browse.tabs[idx].load(&listing);
|
|
self.sync_browse_selection();
|
|
}
|
|
|
|
/// The username of the highlighted search result (filter-aware).
|
|
pub(super) fn highlighted_result_owner(&self) -> Option<String> {
|
|
self.highlighted_result().map(|f| f.username.clone())
|
|
}
|
|
|
|
/// Drain browse responses into any loading tabs, or time them out.
|
|
pub(super) fn poll_browse_result(&mut self) {
|
|
// Which loading tabs are waiting, and for whom.
|
|
let loading: Vec<(usize, String, std::time::Instant)> = self
|
|
.state
|
|
.browse
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, b)| b.status == BrowseStatus::Loading)
|
|
.map(|(i, b)| (i, b.username.clone(), b.requested_at))
|
|
.collect();
|
|
|
|
for (idx, username, requested_at) in loading {
|
|
if let Some(directories) = self.client.take_browse_result(&username)
|
|
{
|
|
if let Some(browse) = self.state.browse.tabs.get_mut(idx) {
|
|
browse.load(&directories);
|
|
}
|
|
if idx == self.state.browse.active {
|
|
self.state.browse_table_state.select(Some(0));
|
|
}
|
|
} else if requested_at.elapsed() > BROWSE_TIMEOUT
|
|
&& let Some(browse) = self.state.browse.tabs.get_mut(idx)
|
|
{
|
|
browse.status = BrowseStatus::TimedOut;
|
|
}
|
|
}
|
|
}
|
|
}
|