Add containing-folder download shortcut to search results
This commit is contained in:
@@ -2,12 +2,96 @@ use super::MainTui;
|
|||||||
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
|
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
|
||||||
use crate::models::BrowseStatus;
|
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::{
|
||||||
|
collections::HashSet,
|
||||||
|
sync::mpsc::{self, Receiver, TryRecvError},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
/// How long to wait for a browse response before showing a timeout notice.
|
/// How long to wait for a browse response before showing a timeout notice.
|
||||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
|
const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// One full peer listing can satisfy several containing-folder requests.
|
||||||
|
pub(super) struct FolderFetch {
|
||||||
|
username: String,
|
||||||
|
folders: Vec<String>,
|
||||||
|
pub(super) requested_at: Instant,
|
||||||
|
request: Option<Receiver<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soulseek paths use remote separators, independently of the local OS.
|
||||||
|
pub(super) fn containing_folder(path: &str) -> Option<&str> {
|
||||||
|
let (parent, name) = path.rsplit_once('\\')?;
|
||||||
|
if parent.trim_matches('\\').is_empty()
|
||||||
|
|| name.is_empty()
|
||||||
|
|| path.contains('/')
|
||||||
|
|| path.chars().any(char::is_control)
|
||||||
|
|| path
|
||||||
|
.trim_start_matches('\\')
|
||||||
|
.split('\\')
|
||||||
|
.any(|part| matches!(part, "" | "." | ".."))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(parent)
|
||||||
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
|
pub(super) fn download_containing_folder(&mut self) {
|
||||||
|
let Some(file) = self.highlighted_result() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(folder) = containing_folder(&file.filename) else {
|
||||||
|
self.folder_download_status =
|
||||||
|
Some("Cannot download folder: invalid remote file path".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = file.username.clone();
|
||||||
|
let folder = folder.to_string();
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
self.folder_download_status =
|
||||||
|
Some("Cannot download folder: missing peer username".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.folder_download_status =
|
||||||
|
Some(format!("Fetching folder {folder} from {username}…"));
|
||||||
|
if let Some(fetch) = self
|
||||||
|
.folder_fetches
|
||||||
|
.iter_mut()
|
||||||
|
.find(|fetch| fetch.username == username)
|
||||||
|
{
|
||||||
|
if !fetch.folders.contains(&folder) {
|
||||||
|
fetch.folders.push(folder);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, request) = mpsc::channel();
|
||||||
|
let client = self.client.clone();
|
||||||
|
let peer = username.clone();
|
||||||
|
// SessionApi's browse works for both local and daemon sessions. The
|
||||||
|
// request itself can involve IPC, so even that runs off the UI thread.
|
||||||
|
if self.state.browse.tabs.iter().any(|tab| {
|
||||||
|
!tab.own
|
||||||
|
&& tab.username == username
|
||||||
|
&& tab.status == BrowseStatus::Loading
|
||||||
|
}) {
|
||||||
|
let _ = sender.send(Ok(()));
|
||||||
|
} else {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = sender
|
||||||
|
.send(client.browse_user(&peer).map_err(|e| e.to_string()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.folder_fetches.push(FolderFetch {
|
||||||
|
username,
|
||||||
|
folders: vec![folder],
|
||||||
|
requested_at: Instant::now(),
|
||||||
|
request: Some(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
// Typing goes to the filter; the other keys still move around.
|
||||||
if self.state.browse.active_tab().is_some_and(|b| b.filtering)
|
if self.state.browse.active_tab().is_some_and(|b| b.filtering)
|
||||||
@@ -218,10 +302,24 @@ impl MainTui {
|
|||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self.queue_peer_files(username, files, self.download_dir.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared browse queue: keep remote paths intact and let the session's
|
||||||
|
/// download machinery choose local names and enforce transfer limits.
|
||||||
|
fn queue_peer_files(
|
||||||
|
&mut self,
|
||||||
|
username: String,
|
||||||
|
files: Vec<(String, u64)>,
|
||||||
|
download_dir: String,
|
||||||
|
) -> Receiver<String> {
|
||||||
let sender = self.downloads_sender();
|
let sender = self.downloads_sender();
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let download_dir = self.download_dir.clone();
|
let (status_sender, status_receiver) = mpsc::channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
let mut queued = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
let mut last_error = String::new();
|
||||||
for (path, size) in files {
|
for (path, size) in files {
|
||||||
match client.download(
|
match client.download(
|
||||||
path.clone(),
|
path.clone(),
|
||||||
@@ -231,15 +329,27 @@ impl MainTui {
|
|||||||
) {
|
) {
|
||||||
Ok((download, rx)) => {
|
Ok((download, rx)) => {
|
||||||
let _ = sender.send((download, rx));
|
let _ = sender.send((download, rx));
|
||||||
|
queued += 1;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
failed += 1;
|
||||||
|
last_error = e.to_string();
|
||||||
soulseek_rs::warn!(
|
soulseek_rs::warn!(
|
||||||
"Failed to queue download {path}: {e}"
|
"Failed to queue download {path}: {e}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let status = if failed == 0 {
|
||||||
|
format!("Queued {queued} files from {username}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Queued {queued} files from {username}; {failed} failed: {last_error}"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _ = status_sender.send(status);
|
||||||
});
|
});
|
||||||
|
status_receiver
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request a user's shared files and open (or focus) their browse tab.
|
/// Request a user's shared files and open (or focus) their browse tab.
|
||||||
@@ -250,7 +360,9 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
// Open/focus the tab; only (re)issue the request when it's new or a
|
// Open/focus the tab; only (re)issue the request when it's new or a
|
||||||
// previous attempt timed out.
|
// previous attempt timed out.
|
||||||
if self.state.browse.open(&username) {
|
if self.state.browse.open(&username)
|
||||||
|
&& !self.folder_fetches.iter().any(|f| f.username == username)
|
||||||
|
{
|
||||||
let _ = self.client.browse_user(&username);
|
let _ = self.client.browse_user(&username);
|
||||||
}
|
}
|
||||||
self.state.show_browse = true;
|
self.state.show_browse = true;
|
||||||
@@ -289,6 +401,7 @@ impl MainTui {
|
|||||||
|
|
||||||
/// Drain browse responses into any loading tabs, or time them out.
|
/// Drain browse responses into any loading tabs, or time them out.
|
||||||
pub(super) fn poll_browse_result(&mut self) {
|
pub(super) fn poll_browse_result(&mut self) {
|
||||||
|
self.poll_folder_requests();
|
||||||
// Which loading tabs are waiting, and for whom.
|
// Which loading tabs are waiting, and for whom.
|
||||||
let loading: Vec<(usize, String, std::time::Instant)> = self
|
let loading: Vec<(usize, String, std::time::Instant)> = self
|
||||||
.state
|
.state
|
||||||
@@ -300,20 +413,162 @@ impl MainTui {
|
|||||||
.map(|(i, b)| (i, b.username.clone(), b.requested_at))
|
.map(|(i, b)| (i, b.username.clone(), b.requested_at))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for (idx, username, requested_at) in loading {
|
// take_browse_result is destructive. Consume once per peer, then
|
||||||
|
// deliver the listing to both browse tabs and folder downloads.
|
||||||
|
let users: HashSet<String> = loading
|
||||||
|
.iter()
|
||||||
|
.map(|(_, user, _)| user.clone())
|
||||||
|
.chain(self.folder_fetches.iter().map(|f| f.username.clone()))
|
||||||
|
.collect();
|
||||||
|
for username in users {
|
||||||
if let Some(directories) = self.client.take_browse_result(&username)
|
if let Some(directories) = self.client.take_browse_result(&username)
|
||||||
{
|
{
|
||||||
if let Some(browse) = self.state.browse.tabs.get_mut(idx) {
|
for (idx, _, _) in
|
||||||
|
loading.iter().filter(|(_, user, _)| *user == username)
|
||||||
|
{
|
||||||
|
let browse = &mut self.state.browse.tabs[*idx];
|
||||||
browse.load(&directories);
|
browse.load(&directories);
|
||||||
|
if *idx == self.state.browse.active {
|
||||||
|
self.state.browse_table_state.select(Some(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if idx == self.state.browse.active {
|
if let Some(index) = self
|
||||||
self.state.browse_table_state.select(Some(0));
|
.folder_fetches
|
||||||
|
.iter()
|
||||||
|
.position(|f| f.username == username)
|
||||||
|
{
|
||||||
|
let fetch = self.folder_fetches.remove(index);
|
||||||
|
for folder in fetch.folders {
|
||||||
|
self.queue_containing_folder(
|
||||||
|
&username,
|
||||||
|
&folder,
|
||||||
|
&directories,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if requested_at.elapsed() > BROWSE_TIMEOUT
|
}
|
||||||
|
}
|
||||||
|
for (idx, _, requested_at) in loading {
|
||||||
|
if requested_at.elapsed() > BROWSE_TIMEOUT
|
||||||
&& let Some(browse) = self.state.browse.tabs.get_mut(idx)
|
&& let Some(browse) = self.state.browse.tabs.get_mut(idx)
|
||||||
|
&& browse.status == BrowseStatus::Loading
|
||||||
{
|
{
|
||||||
browse.status = BrowseStatus::TimedOut;
|
browse.status = BrowseStatus::TimedOut;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn queue_containing_folder(
|
||||||
|
&mut self,
|
||||||
|
username: &str,
|
||||||
|
folder: &str,
|
||||||
|
directories: &[soulseek_rs::SharedDirectory],
|
||||||
|
) {
|
||||||
|
let files = match files_in_folder(directories, folder) {
|
||||||
|
Ok(files) if !files.is_empty() => files,
|
||||||
|
Ok(_) => {
|
||||||
|
self.folder_download_status =
|
||||||
|
Some(format!("Folder {folder} from {username} is empty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.folder_download_status = Some(format!(
|
||||||
|
"Cannot download {folder} from {username}: {error}"
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.folder_download_status = Some(format!(
|
||||||
|
"Queuing {} files from {username}: {folder}…",
|
||||||
|
files.len()
|
||||||
|
));
|
||||||
|
let directory = crate::directories::download_dir_for(
|
||||||
|
&self.download_dir,
|
||||||
|
username,
|
||||||
|
self.use_username_dirs,
|
||||||
|
);
|
||||||
|
let result =
|
||||||
|
self.queue_peer_files(username.to_string(), files, directory);
|
||||||
|
self.folder_queue_results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_folder_requests(&mut self) {
|
||||||
|
self.folder_queue_results
|
||||||
|
.retain(|result| match result.try_recv() {
|
||||||
|
Ok(status) => {
|
||||||
|
self.folder_download_status = Some(status);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => true,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
self.folder_download_status =
|
||||||
|
Some("Folder download worker stopped".into());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.folder_fetches.retain_mut(|fetch| {
|
||||||
|
if let Some(request) = &fetch.request {
|
||||||
|
let error = match request.try_recv() {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
fetch.request = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => Some(error),
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
Some("request worker stopped".into())
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => None,
|
||||||
|
};
|
||||||
|
if let Some(error) = error {
|
||||||
|
self.folder_download_status = Some(format!(
|
||||||
|
"Could not fetch folder from {}: {error}",
|
||||||
|
fetch.username
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetch.requested_at.elapsed() > BROWSE_TIMEOUT {
|
||||||
|
self.folder_download_status = Some(format!(
|
||||||
|
"Folder request to {} timed out; press D to retry",
|
||||||
|
fetch.username
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only direct children of an exact directory match, never descendants or
|
||||||
|
/// prefix matches. Reject malformed entries rather than queue a partial folder.
|
||||||
|
fn files_in_folder(
|
||||||
|
directories: &[soulseek_rs::SharedDirectory],
|
||||||
|
folder: &str,
|
||||||
|
) -> Result<Vec<(String, u64)>, &'static str> {
|
||||||
|
let mut found = false;
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for dir in directories
|
||||||
|
.iter()
|
||||||
|
.filter(|dir| dir.name.trim_end_matches('\\') == folder)
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
for file in &dir.files {
|
||||||
|
if file.name.is_empty()
|
||||||
|
|| file.name.contains(['\\', '/'])
|
||||||
|
|| file.name.chars().any(char::is_control)
|
||||||
|
|| matches!(file.name.as_str(), "." | "..")
|
||||||
|
{
|
||||||
|
return Err("invalid file name in peer listing");
|
||||||
|
}
|
||||||
|
let path = format!("{folder}\\{}", file.name);
|
||||||
|
if seen.insert(path.clone()) {
|
||||||
|
files.push((path, file.size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return Err("folder not found in peer listing");
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ impl MainTui {
|
|||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
self.queue_selected_downloads();
|
self.queue_selected_downloads();
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('D') => self.download_containing_folder(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ pub struct MainTui {
|
|||||||
/// Which divider the left mouse button is holding, if any.
|
/// Which divider the left mouse button is holding, if any.
|
||||||
resize_drag: Option<input::ResizeDrag>,
|
resize_drag: Option<input::ResizeDrag>,
|
||||||
listener_fallback: Option<(u16, u16)>,
|
listener_fallback: Option<(u16, u16)>,
|
||||||
|
folder_fetches: Vec<browse::FolderFetch>,
|
||||||
|
folder_queue_results: Vec<std::sync::mpsc::Receiver<String>>,
|
||||||
|
folder_download_status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
@@ -58,6 +61,9 @@ impl MainTui {
|
|||||||
saved_snapshot: Snapshot::default(),
|
saved_snapshot: Snapshot::default(),
|
||||||
resize_drag: None,
|
resize_drag: None,
|
||||||
listener_fallback: None,
|
listener_fallback: None,
|
||||||
|
folder_fetches: Vec::new(),
|
||||||
|
folder_queue_results: Vec::new(),
|
||||||
|
folder_download_status: None,
|
||||||
};
|
};
|
||||||
tui.restore_persisted_state();
|
tui.restore_persisted_state();
|
||||||
tui
|
tui
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("Space", "select / deselect"),
|
("Space", "select / deselect"),
|
||||||
("a / A", "select all / none"),
|
("a / A", "select all / none"),
|
||||||
("Enter", "download the selection"),
|
("Enter", "download the selection"),
|
||||||
|
("D", "download highlighted file's containing folder"),
|
||||||
("/", "filter, Enter keeps it, Esc clears"),
|
("/", "filter, Enter keeps it, Esc clears"),
|
||||||
("b", "browse the owner"),
|
("b", "browse the owner"),
|
||||||
("c", "chat rooms"),
|
("c", "chat rooms"),
|
||||||
@@ -397,6 +398,7 @@ impl MainTui {
|
|||||||
FocusedPane::Results => vec![
|
FocusedPane::Results => vec![
|
||||||
("Space", "select"),
|
("Space", "select"),
|
||||||
("Enter", "download"),
|
("Enter", "download"),
|
||||||
|
("D", "download containing folder"),
|
||||||
("b", "browse owner"),
|
("b", "browse owner"),
|
||||||
("c", "chat"),
|
("c", "chat"),
|
||||||
("/", "filter"),
|
("/", "filter"),
|
||||||
@@ -426,7 +428,12 @@ impl MainTui {
|
|||||||
// Borders and padding, and the unread badge's column when it shows.
|
// Borders and padding, and the unread badge's column when it shows.
|
||||||
let badge = self.unread_badge().width() as u16;
|
let badge = self.unread_badge().width() as u16;
|
||||||
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
|
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
|
||||||
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved))
|
let mut rows =
|
||||||
|
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved));
|
||||||
|
if let Some(status) = &self.folder_download_status {
|
||||||
|
rows.push(Line::styled(status.clone(), info_style()));
|
||||||
|
}
|
||||||
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prominent unread badge for the shortcuts bar. It rides in the
|
/// A prominent unread badge for the shortcuts bar. It rides in the
|
||||||
|
|||||||
@@ -2,6 +2,366 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn directory(
|
||||||
|
name: &str,
|
||||||
|
files: &[(&str, u64)],
|
||||||
|
) -> soulseek_rs::SharedDirectory {
|
||||||
|
soulseek_rs::SharedDirectory {
|
||||||
|
name: name.to_string(),
|
||||||
|
files: files
|
||||||
|
.iter()
|
||||||
|
.map(|(name, size)| soulseek_rs::SharedFileEntry {
|
||||||
|
name: (*name).to_string(),
|
||||||
|
size: *size,
|
||||||
|
attributes: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_window(session: Arc<TalkativeSession>) -> MainTui {
|
||||||
|
let mut tui = attach(session);
|
||||||
|
tui.state.focused_pane = FocusedPane::Results;
|
||||||
|
tui.state.results_items = results(2);
|
||||||
|
tui.state.results_items[0].filename =
|
||||||
|
"Music\\Artist\\Album\\03 - Track.flac".into();
|
||||||
|
tui.state.results_items[1].filename = "Other\\track.mp3".into();
|
||||||
|
tui.state.results_items[1].username = "alice".into();
|
||||||
|
tui.state.results_table_state.select(Some(0));
|
||||||
|
tui
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for(mut ready: impl FnMut() -> bool) {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while !ready() {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"worker did not finish"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_folder(tui: &mut MainTui) {
|
||||||
|
wait_for(|| {
|
||||||
|
tui.poll_session();
|
||||||
|
tui.folder_fetches.is_empty() && tui.folder_queue_results.is_empty()
|
||||||
|
});
|
||||||
|
tui.update_downloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer(
|
||||||
|
session: &TalkativeSession,
|
||||||
|
directories: Vec<soulseek_rs::SharedDirectory>,
|
||||||
|
) {
|
||||||
|
wait_for(|| !session.browse_requests.lock().unwrap().is_empty());
|
||||||
|
session
|
||||||
|
.browse_results
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert("bob".into(), directories);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn containing_folder_uses_soulseek_paths_and_rejects_malformed_paths() {
|
||||||
|
use super::super::browse::containing_folder;
|
||||||
|
for (path, parent) in [
|
||||||
|
(
|
||||||
|
"Music\\Artist\\Album\\03 - Track.flac",
|
||||||
|
"Music\\Artist\\Album",
|
||||||
|
),
|
||||||
|
("@@share\\song.mp3", "@@share"),
|
||||||
|
("\\Music\\Björk\\曲.flac", "\\Music\\Björk"),
|
||||||
|
("C:\\Music\\song.mp3", "C:\\Music"),
|
||||||
|
] {
|
||||||
|
assert_eq!(containing_folder(path), Some(parent), "{path}");
|
||||||
|
}
|
||||||
|
for path in [
|
||||||
|
"",
|
||||||
|
"track.mp3",
|
||||||
|
"\\track.mp3",
|
||||||
|
"Music\\",
|
||||||
|
"Music\\..\\song.mp3",
|
||||||
|
"Music\\.\\song.mp3",
|
||||||
|
"Music\\\\song.mp3",
|
||||||
|
"Music\\bad\0.mp3",
|
||||||
|
"Music/track.mp3",
|
||||||
|
] {
|
||||||
|
assert_eq!(containing_folder(path), None, "{path:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_d_downloads_only_highlighted_folder_from_complete_peer_listing() {
|
||||||
|
for per_user in [false, true] {
|
||||||
|
let (release, gate) = std::sync::mpsc::channel();
|
||||||
|
let session = Arc::new(TalkativeSession {
|
||||||
|
accept_downloads: true,
|
||||||
|
browse_gate: Some(std::sync::Mutex::new(gate)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
tui.use_username_dirs = per_user;
|
||||||
|
tui.state.results_selected_indices.insert(1);
|
||||||
|
// The kept filter maps the highlighted row to the original result.
|
||||||
|
tui.state.results_items.swap(0, 1);
|
||||||
|
tui.state.results_filter_query = "Track.flac".into();
|
||||||
|
tui.apply_filter();
|
||||||
|
tui.state.results_selected_indices.clear();
|
||||||
|
tui.state.results_selected_indices.insert(0);
|
||||||
|
tui.handle_key_event(KeyEvent::new(
|
||||||
|
KeyCode::Char('D'),
|
||||||
|
KeyModifiers::SHIFT,
|
||||||
|
));
|
||||||
|
// The worker is held at the request: navigation and rendering still run.
|
||||||
|
press(&mut tui, KeyCode::Tab);
|
||||||
|
assert_ne!(tui.state.focused_pane, FocusedPane::Results);
|
||||||
|
assert!(screen_of(&mut tui).contains("Fetching folder"));
|
||||||
|
assert!(!tui.state.show_browse);
|
||||||
|
assert!(tui.state.browse.tabs.is_empty());
|
||||||
|
assert!(session.download_requests.lock().unwrap().is_empty());
|
||||||
|
release.send(()).unwrap();
|
||||||
|
answer(
|
||||||
|
&session,
|
||||||
|
vec![
|
||||||
|
directory("Music\\Artist", &[("parent.mp3", 1)]),
|
||||||
|
directory(
|
||||||
|
"Music\\Artist\\Album\\",
|
||||||
|
&[
|
||||||
|
("03 - Track.flac", 30),
|
||||||
|
("01 - Missing.flac", 10),
|
||||||
|
("cover.jpg", 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
directory("Music\\Artist\\Album\\Disc 2", &[("child.flac", 2)]),
|
||||||
|
directory(
|
||||||
|
"Music\\Artist\\Album extras",
|
||||||
|
&[("sibling.flac", 3)],
|
||||||
|
),
|
||||||
|
directory("Other", &[("track.mp3", 4)]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
let downloads = session.download_requests.lock().unwrap();
|
||||||
|
assert_eq!(downloads.len(), 3);
|
||||||
|
for (download, name, size) in [
|
||||||
|
(&downloads[0], "03 - Track.flac", 30),
|
||||||
|
(&downloads[1], "01 - Missing.flac", 10),
|
||||||
|
(&downloads[2], "cover.jpg", 20),
|
||||||
|
] {
|
||||||
|
assert_eq!(download.username, "bob");
|
||||||
|
assert_eq!(
|
||||||
|
download.filename,
|
||||||
|
format!("Music\\Artist\\Album\\{name}")
|
||||||
|
);
|
||||||
|
assert_eq!(download.size, size);
|
||||||
|
assert_eq!(
|
||||||
|
download.download_directory,
|
||||||
|
if per_user { "/tmp/bob" } else { "/tmp" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(tui.state.downloads.len(), 3);
|
||||||
|
assert_eq!(tui.state.results_selected_indices, [0].into());
|
||||||
|
assert!(screen_of(&mut tui).contains("Queued 3 files from bob"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_still_downloads_selected_files_and_b_still_opens_the_owner() {
|
||||||
|
let session = Arc::new(TalkativeSession {
|
||||||
|
accept_downloads: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
tui.state.results_selected_indices.insert(1);
|
||||||
|
press(&mut tui, KeyCode::Enter);
|
||||||
|
wait_for(|| !session.download_requests.lock().unwrap().is_empty());
|
||||||
|
let requests = session.download_requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].username, "alice");
|
||||||
|
assert_eq!(requests[0].filename, "Other\\track.mp3");
|
||||||
|
assert!(tui.state.results_selected_indices.is_empty());
|
||||||
|
assert!(session.browse_requests.lock().unwrap().is_empty());
|
||||||
|
drop(requests);
|
||||||
|
|
||||||
|
press(&mut tui, KeyCode::Char('b'));
|
||||||
|
assert!(tui.state.show_browse);
|
||||||
|
assert_eq!(tui.state.browse.active_tab().unwrap().username, "bob");
|
||||||
|
assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]);
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn d_without_a_highlight_or_outside_results_does_nothing() {
|
||||||
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
for selection in [None, Some(99)] {
|
||||||
|
tui.state.results_table_state.select(selection);
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
assert!(tui.folder_download_status.is_none());
|
||||||
|
}
|
||||||
|
tui.state.results_items.clear();
|
||||||
|
tui.state.results_table_state.select(Some(0));
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
tui.state.results_items = results(1);
|
||||||
|
for pane in [FocusedPane::Searches, FocusedPane::Downloads] {
|
||||||
|
tui.state.focused_pane = pane;
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
}
|
||||||
|
assert!(session.browse_requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn d_is_text_while_editing_a_filter_and_ctrl_d_still_pages() {
|
||||||
|
let mut tui = results_tui(40);
|
||||||
|
tui.state.results_table_state.select(Some(0));
|
||||||
|
ctrl(&mut tui, 'd');
|
||||||
|
assert!(selected(&tui).unwrap() > 0);
|
||||||
|
press(&mut tui, KeyCode::Char('/'));
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert_eq!(tui.state.results_filter_query, "D");
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_requests_and_timeouts_report_errors_and_allow_retry() {
|
||||||
|
let session = Arc::new(TalkativeSession {
|
||||||
|
browse_fails: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
assert!(screen_of(&mut tui).contains("Could not fetch folder from bob"));
|
||||||
|
assert!(session.download_requests.lock().unwrap().is_empty());
|
||||||
|
|
||||||
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
wait_for(|| !session.browse_requests.lock().unwrap().is_empty());
|
||||||
|
tui.folder_fetches[0].requested_at = std::time::Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(21))
|
||||||
|
.expect("a time before the browse timeout");
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
assert!(screen_of(&mut tui).contains("timed out"));
|
||||||
|
// A late response must not queue anything after the timeout.
|
||||||
|
answer(
|
||||||
|
&session,
|
||||||
|
vec![directory("Music\\Artist\\Album", &[("late.flac", 1)])],
|
||||||
|
);
|
||||||
|
tui.poll_session();
|
||||||
|
assert!(session.download_requests.lock().unwrap().is_empty());
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
wait_for(|| session.browse_requests.lock().unwrap().len() == 2);
|
||||||
|
assert_eq!(tui.folder_fetches.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_missing_and_malformed_folders_do_not_queue_files() {
|
||||||
|
for (listing, message) in [
|
||||||
|
(vec![directory("Music\\Artist\\Album", &[])], "is empty"),
|
||||||
|
(vec![], "folder not found"),
|
||||||
|
(
|
||||||
|
vec![directory(
|
||||||
|
"Music\\Artist\\Album\\Child",
|
||||||
|
&[("child.flac", 1)],
|
||||||
|
)],
|
||||||
|
"folder not found",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
vec![directory(
|
||||||
|
"Music\\Artist\\Album",
|
||||||
|
&[("valid.flac", 1), ("..\\escape.flac", 2)],
|
||||||
|
)],
|
||||||
|
"invalid file name",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let session = Arc::new(TalkativeSession {
|
||||||
|
accept_downloads: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
answer(&session, listing);
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
assert!(
|
||||||
|
tui.folder_download_status
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains(message)
|
||||||
|
);
|
||||||
|
assert!(session.download_requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
tui.state.results_items[0].filename = "track.flac".into();
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert!(screen_of(&mut tui).contains("invalid remote file path"));
|
||||||
|
assert!(tui.folder_fetches.is_empty());
|
||||||
|
assert!(session.browse_requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_queue_failures_are_reported() {
|
||||||
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
answer(
|
||||||
|
&session,
|
||||||
|
vec![directory(
|
||||||
|
"Music\\Artist\\Album",
|
||||||
|
&[("a.flac", 1), ("b.flac", 2)],
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
assert!(screen_of(&mut tui).contains("Queued 0 files from bob; 2 failed"));
|
||||||
|
assert!(tui.state.downloads.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listing_serves_both_browse_and_folder_download_without_duplicate_requests()
|
||||||
|
{
|
||||||
|
for browse_first in [false, true] {
|
||||||
|
let session = Arc::new(TalkativeSession {
|
||||||
|
accept_downloads: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut tui = folder_window(session.clone());
|
||||||
|
if browse_first {
|
||||||
|
press(&mut tui, KeyCode::Char('b'));
|
||||||
|
press(&mut tui, KeyCode::Esc);
|
||||||
|
}
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
press(&mut tui, KeyCode::Char('D'));
|
||||||
|
assert_eq!(tui.folder_fetches.len(), 1);
|
||||||
|
press(&mut tui, KeyCode::Char('b'));
|
||||||
|
answer(
|
||||||
|
&session,
|
||||||
|
vec![directory("Music\\Artist\\Album", &[("a.flac", 1)])],
|
||||||
|
);
|
||||||
|
finish_folder(&mut tui);
|
||||||
|
assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]);
|
||||||
|
assert_eq!(
|
||||||
|
tui.state.browse.active_tab().unwrap().status,
|
||||||
|
crate::models::BrowseStatus::Loaded
|
||||||
|
);
|
||||||
|
assert_eq!(session.download_requests.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_documents_the_containing_folder_key() {
|
||||||
|
let mut tui = results_tui(1);
|
||||||
|
press(&mut tui, KeyCode::Char('?'));
|
||||||
|
let screen = screen_sized(&mut tui, 180, 60);
|
||||||
|
let row =
|
||||||
|
row_with(&screen, "download highlighted file's containing folder");
|
||||||
|
assert!(row.contains('D'));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() {
|
fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() {
|
||||||
let session = Arc::new(TalkativeSession::default());
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ struct TalkativeSession {
|
|||||||
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
|
browse_requests: std::sync::Mutex<Vec<String>>,
|
||||||
|
browse_results: std::sync::Mutex<
|
||||||
|
std::collections::HashMap<String, Vec<soulseek_rs::SharedDirectory>>,
|
||||||
|
>,
|
||||||
|
browse_fails: bool,
|
||||||
|
browse_gate: Option<std::sync::Mutex<std::sync::mpsc::Receiver<()>>>,
|
||||||
|
accept_downloads: bool,
|
||||||
|
download_requests: std::sync::Mutex<Vec<soulseek_rs::types::Download>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
|
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
|
||||||
@@ -156,28 +164,44 @@ impl SessionApi for TalkativeSession {
|
|||||||
}
|
}
|
||||||
fn download(
|
fn download(
|
||||||
&self,
|
&self,
|
||||||
_filename: String,
|
filename: String,
|
||||||
_username: String,
|
username: String,
|
||||||
_size: u64,
|
size: u64,
|
||||||
_directory: String,
|
directory: String,
|
||||||
) -> soulseek_rs::Result<(
|
) -> soulseek_rs::Result<(
|
||||||
soulseek_rs::types::Download,
|
soulseek_rs::types::Download,
|
||||||
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
||||||
)> {
|
)> {
|
||||||
Err(soulseek_rs::SoulseekRs::NotConnected)
|
let mut download = queued(&username, &filename);
|
||||||
|
download.size = size;
|
||||||
|
download.download_directory = directory;
|
||||||
|
self.download_requests
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.push(download.clone());
|
||||||
|
if self.accept_downloads {
|
||||||
|
Ok((download, std::sync::mpsc::channel().1))
|
||||||
|
} else {
|
||||||
|
Err(soulseek_rs::SoulseekRs::NotConnected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn download_with_metadata(
|
fn download_with_metadata(
|
||||||
&self,
|
&self,
|
||||||
_filename: String,
|
filename: String,
|
||||||
_username: String,
|
username: String,
|
||||||
_size: u64,
|
size: u64,
|
||||||
_directory: String,
|
directory: String,
|
||||||
_metadata: soulseek_rs::types::DownloadMetadata,
|
metadata: soulseek_rs::types::DownloadMetadata,
|
||||||
) -> soulseek_rs::Result<(
|
) -> soulseek_rs::Result<(
|
||||||
soulseek_rs::types::Download,
|
soulseek_rs::types::Download,
|
||||||
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
||||||
)> {
|
)> {
|
||||||
Err(soulseek_rs::SoulseekRs::NotConnected)
|
self.download(filename, username, size, directory).map(
|
||||||
|
|(mut download, receiver)| {
|
||||||
|
download.metadata = metadata;
|
||||||
|
(download, receiver)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
|
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
|
||||||
self.downloads.clone()
|
self.downloads.clone()
|
||||||
@@ -263,14 +287,31 @@ impl SessionApi for TalkativeSession {
|
|||||||
fn take_private_messages(&self) -> Vec<soulseek_rs::UserMessage> {
|
fn take_private_messages(&self) -> Vec<soulseek_rs::UserMessage> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> {
|
fn browse_user(&self, username: &str) -> soulseek_rs::Result<()> {
|
||||||
Ok(())
|
self.browse_requests
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.push(username.to_string());
|
||||||
|
if let Some(gate) = &self.browse_gate {
|
||||||
|
gate.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("release browse request");
|
||||||
|
}
|
||||||
|
if self.browse_fails {
|
||||||
|
Err(soulseek_rs::SoulseekRs::NotConnected)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn take_browse_result(
|
fn take_browse_result(
|
||||||
&self,
|
&self,
|
||||||
_username: &str,
|
username: &str,
|
||||||
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
|
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
|
||||||
None
|
self.browse_results
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.remove(username)
|
||||||
}
|
}
|
||||||
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
|
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user