From 1f0b541bdb2cce66988960295e2c7d745cbb1bea Mon Sep 17 00:00:00 2001 From: dumbgoy Date: Tue, 27 Oct 2026 09:55:24 -0400 Subject: [PATCH] Add containing-folder download shortcut to search results --- soulseek-rs/src/ui/main_tui/browse.rs | 271 ++++++++++++- soulseek-rs/src/ui/main_tui/input.rs | 1 + soulseek-rs/src/ui/main_tui/mod.rs | 6 + .../src/ui/main_tui/render/shortcuts.rs | 9 +- soulseek-rs/src/ui/main_tui/tests/browse.rs | 360 ++++++++++++++++++ soulseek-rs/src/ui/main_tui/tests/mod.rs | 71 +++- 6 files changed, 694 insertions(+), 24 deletions(-) diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 2727014..ee0086c 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -2,12 +2,96 @@ 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}; +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. 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, + pub(super) requested_at: Instant, + request: Option>>, +} + +/// 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 { + 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) { // Typing goes to the filter; the other keys still move around. if self.state.browse.active_tab().is_some_and(|b| b.filtering) @@ -218,10 +302,24 @@ impl MainTui { if files.is_empty() { 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 { let sender = self.downloads_sender(); let client = self.client.clone(); - let download_dir = self.download_dir.clone(); + let (status_sender, status_receiver) = mpsc::channel(); thread::spawn(move || { + let mut queued = 0; + let mut failed = 0; + let mut last_error = String::new(); for (path, size) in files { match client.download( path.clone(), @@ -231,15 +329,27 @@ impl MainTui { ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); + queued += 1; } Err(e) => { + failed += 1; + last_error = e.to_string(); soulseek_rs::warn!( "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. @@ -250,7 +360,9 @@ impl MainTui { } // 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) { + if self.state.browse.open(&username) + && !self.folder_fetches.iter().any(|f| f.username == username) + { let _ = self.client.browse_user(&username); } self.state.show_browse = true; @@ -289,6 +401,7 @@ impl MainTui { /// Drain browse responses into any loading tabs, or time them out. pub(super) fn poll_browse_result(&mut self) { + self.poll_folder_requests(); // Which loading tabs are waiting, and for whom. let loading: Vec<(usize, String, std::time::Instant)> = self .state @@ -300,20 +413,162 @@ impl MainTui { .map(|(i, b)| (i, b.username.clone(), b.requested_at)) .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 = 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(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); + if *idx == self.state.browse.active { + self.state.browse_table_state.select(Some(0)); + } } - if idx == self.state.browse.active { - self.state.browse_table_state.select(Some(0)); + if let Some(index) = self + .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) + && browse.status == BrowseStatus::Loading { 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, &'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) } diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 1377491..bcdc69e 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -484,6 +484,7 @@ impl MainTui { KeyCode::Enter => { self.queue_selected_downloads(); } + KeyCode::Char('D') => self.download_containing_folder(), _ => {} } } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 58a1664..52d226b 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -35,6 +35,9 @@ pub struct MainTui { /// Which divider the left mouse button is holding, if any. resize_drag: Option, listener_fallback: Option<(u16, u16)>, + folder_fetches: Vec, + folder_queue_results: Vec>, + folder_download_status: Option, } impl MainTui { @@ -58,6 +61,9 @@ impl MainTui { saved_snapshot: Snapshot::default(), resize_drag: None, listener_fallback: None, + folder_fetches: Vec::new(), + folder_queue_results: Vec::new(), + folder_download_status: None, }; tui.restore_persisted_state(); tui diff --git a/soulseek-rs/src/ui/main_tui/render/shortcuts.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs index f089f62..90ff0bb 100644 --- a/soulseek-rs/src/ui/main_tui/render/shortcuts.rs +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -79,6 +79,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("Space", "select / deselect"), ("a / A", "select all / none"), ("Enter", "download the selection"), + ("D", "download highlighted file's containing folder"), ("/", "filter, Enter keeps it, Esc clears"), ("b", "browse the owner"), ("c", "chat rooms"), @@ -397,6 +398,7 @@ impl MainTui { FocusedPane::Results => vec![ ("Space", "select"), ("Enter", "download"), + ("D", "download containing folder"), ("b", "browse owner"), ("c", "chat"), ("/", "filter"), @@ -426,7 +428,12 @@ impl MainTui { // Borders and padding, and the unread badge's column when it shows. let badge = self.unread_badge().width() as u16; 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 diff --git a/soulseek-rs/src/ui/main_tui/tests/browse.rs b/soulseek-rs/src/ui/main_tui/tests/browse.rs index f50e313..eb8727d 100644 --- a/soulseek-rs/src/ui/main_tui/tests/browse.rs +++ b/soulseek-rs/src/ui/main_tui/tests/browse.rs @@ -2,6 +2,366 @@ 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) -> 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, +) { + 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] fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { let session = Arc::new(TalkativeSession::default()); diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs index 5e74025..272abc9 100644 --- a/soulseek-rs/src/ui/main_tui/tests/mod.rs +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -51,6 +51,14 @@ struct TalkativeSession { download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, + browse_requests: std::sync::Mutex>, + browse_results: std::sync::Mutex< + std::collections::HashMap>, + >, + browse_fails: bool, + browse_gate: Option>>, + accept_downloads: bool, + download_requests: std::sync::Mutex>, } fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { @@ -156,28 +164,44 @@ impl SessionApi for TalkativeSession { } fn download( &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, + filename: String, + username: String, + size: u64, + directory: String, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, )> { - 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( &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, - _metadata: soulseek_rs::types::DownloadMetadata, + filename: String, + username: String, + size: u64, + directory: String, + metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, )> { - 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 { self.downloads.clone() @@ -263,14 +287,31 @@ impl SessionApi for TalkativeSession { fn take_private_messages(&self) -> Vec { Vec::new() } - fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) + fn browse_user(&self, username: &str) -> soulseek_rs::Result<()> { + 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( &self, - _username: &str, + username: &str, ) -> Option> { - None + self.browse_results + .lock() + .expect("not poisoned") + .remove(username) } fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { Ok(())