The TUI suite had grown its own copy of the soulfind spawner the protocol suite already carried, and its own copy of the CLI suite's in-process client and settle helpers. All four suites now spawn, log in, settle and skip through `tests/common`: the CLI suite's own server goes too, and the fixture checks its child is still alive before calling the port answered, so a lost port cannot pass another server off as ours.
507 lines
19 KiB
Rust
507 lines
19 KiB
Rust
use super::MainTui;
|
|
use crate::models::{
|
|
ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry,
|
|
SearchStatus,
|
|
};
|
|
use chrono::Local;
|
|
use std::{
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
thread,
|
|
time::Instant,
|
|
};
|
|
|
|
/// How long after a search went out a repeat of `S` counts as the same
|
|
/// press: key autorepeat is faster than anyone means to search.
|
|
const RERUN_HOLD: std::time::Duration = std::time::Duration::from_secs(1);
|
|
|
|
impl MainTui {
|
|
pub(super) fn remove_search_at_index(&mut self, index: usize) {
|
|
if index >= self.state.searches.len() {
|
|
return;
|
|
}
|
|
|
|
// Cancel the search if it's active
|
|
if let Some(search) = self.state.searches.get(index) {
|
|
search.cancel_flag.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
// Check if we're removing the currently active search
|
|
let was_active_search = self.state.selected_search_index == Some(index);
|
|
|
|
// Drop it from the session too. The list is a view of what the session
|
|
// holds, so removing it only here would last until the next sync put
|
|
// it straight back — and against a daemon it would come back for every
|
|
// other window as well.
|
|
if let Some(search) = self.state.searches.get(index) {
|
|
self.client.forget_search(&search.query);
|
|
}
|
|
self.state.searches.remove(index);
|
|
|
|
if let Some(current_idx) = self.state.selected_search_index {
|
|
if current_idx == index {
|
|
// Removed the active search - clear it
|
|
self.state.selected_search_index = None;
|
|
} else if current_idx > index {
|
|
// Active search was after removed one - decrement index
|
|
self.state.selected_search_index = Some(current_idx - 1);
|
|
}
|
|
// If current_idx < index, no change needed
|
|
}
|
|
|
|
// If we removed the active search, clear results pane
|
|
if was_active_search {
|
|
self.clear_results_pane();
|
|
}
|
|
|
|
if self.state.searches.is_empty() {
|
|
self.state.searches_table_state.select(None);
|
|
} else {
|
|
let new_selection = if index >= self.state.searches.len() {
|
|
self.state.searches.len() - 1
|
|
} else {
|
|
index
|
|
};
|
|
self.state.searches_table_state.select(Some(new_selection));
|
|
}
|
|
}
|
|
|
|
pub(super) fn clear_all_searches(&mut self) {
|
|
// Cancel all active searches
|
|
for search in &self.state.searches {
|
|
search.cancel_flag.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
// And drop them from the session, or the next sync brings every one
|
|
// of them straight back — the same reason removing a single search
|
|
// has to say so.
|
|
let queries: Vec<String> = self
|
|
.state
|
|
.searches
|
|
.iter()
|
|
.map(|search| search.query.clone())
|
|
.collect();
|
|
for query in queries {
|
|
self.client.forget_search(&query);
|
|
}
|
|
|
|
self.state.searches.clear();
|
|
self.state.searches_table_state.select(None);
|
|
self.state.selected_search_index = None;
|
|
|
|
// Clear results pane
|
|
self.clear_results_pane();
|
|
}
|
|
|
|
fn clear_results_pane(&mut self) {
|
|
self.state.results_items.clear();
|
|
self.state.results_filtered_items.clear();
|
|
self.state.results_filtered_indices.clear();
|
|
self.state.results_selected_indices.clear();
|
|
self.state.results_table_state.select(None);
|
|
self.state.results_filter_query.clear();
|
|
self.state.results_is_filtering = false;
|
|
self.state.results_name_offset = 0;
|
|
}
|
|
|
|
pub(super) fn visible_results(&self) -> &[FileDisplayData] {
|
|
if self.state.results_filter_query.is_empty() {
|
|
&self.state.results_items
|
|
} else {
|
|
&self.state.results_filtered_items
|
|
}
|
|
}
|
|
|
|
pub(super) fn highlighted_result(&self) -> Option<&FileDisplayData> {
|
|
self.visible_results()
|
|
.get(self.state.results_table_state.selected()?)
|
|
}
|
|
|
|
pub(super) fn original_index(&self, row: usize) -> Option<usize> {
|
|
if self.state.results_filter_query.is_empty() {
|
|
Some(row)
|
|
} else {
|
|
self.state.results_filtered_indices.get(row).copied()
|
|
}
|
|
}
|
|
|
|
pub(super) fn apply_filter(&mut self) {
|
|
let (items, indices) = filter_results(
|
|
&self.state.results_items,
|
|
&self.state.results_filter_query,
|
|
);
|
|
self.state.results_filtered_items = items;
|
|
self.state.results_filtered_indices = indices;
|
|
if !self.state.results_filtered_items.is_empty() {
|
|
self.state.results_table_state.select(Some(0));
|
|
}
|
|
}
|
|
|
|
/// Drain any private messages received since the last tick into the inbox.
|
|
pub(super) fn poll_private_messages(&mut self) {
|
|
for msg in self.client.take_private_messages() {
|
|
self.state.messages.push(ChatMessage {
|
|
direction: MessageDirection::Incoming,
|
|
peer: msg.username().to_string(),
|
|
text: msg.message().to_string(),
|
|
at: Local::now(),
|
|
});
|
|
// Badge the inbox when it isn't currently open.
|
|
if !self.state.show_messages {
|
|
self.state.unread_messages += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse a `<recipient> <text>` compose line and send it.
|
|
pub(super) fn send_message_from_input(&mut self, input: &str) {
|
|
let Some((recipient, text)) = input.split_once(char::is_whitespace)
|
|
else {
|
|
return;
|
|
};
|
|
let recipient = recipient.trim();
|
|
let text = text.trim();
|
|
if recipient.is_empty() || text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match self.client.send_private_message(recipient, text) {
|
|
Ok(()) => {
|
|
self.state.messages.push(ChatMessage {
|
|
direction: MessageDirection::Outgoing,
|
|
peer: recipient.to_string(),
|
|
text: text.to_string(),
|
|
at: Local::now(),
|
|
});
|
|
// Land in the chat box on the conversation just started.
|
|
self.state.open_chat(recipient.to_string());
|
|
self.state.chat_composing = false;
|
|
}
|
|
Err(e) => soulseek_rs::warn!("Failed to send message: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Send the chat popup's compose buffer to the active conversation,
|
|
/// staying in compose mode so a reply can follow immediately.
|
|
pub(super) fn send_chat_message(&mut self) {
|
|
let Some(peer) = self.state.active_chat_peer().map(ToString::to_string)
|
|
else {
|
|
return;
|
|
};
|
|
let text = self.state.chat_input.trim().to_string();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match self.client.send_private_message(&peer, &text) {
|
|
Ok(()) => self.state.messages.push(ChatMessage {
|
|
direction: MessageDirection::Outgoing,
|
|
peer,
|
|
text,
|
|
at: Local::now(),
|
|
}),
|
|
Err(e) => soulseek_rs::warn!("Failed to send message: {e}"),
|
|
}
|
|
self.state.chat_input.clear();
|
|
}
|
|
|
|
pub(super) fn start_search(&mut self, query: String) {
|
|
let entry = self.launch_search(query);
|
|
self.state.searches.push(entry);
|
|
self.show_search(self.state.searches.len() - 1);
|
|
}
|
|
|
|
/// Run the search at `index` again, in its row: a restored query comes
|
|
/// back with nothing under it, and this saves typing it out. A run still
|
|
/// collecting is stopped first, or two threads would fill one set.
|
|
pub(super) fn rerun_search_at_index(&mut self, index: usize) {
|
|
let Some(previous) = self.state.searches.get(index) else {
|
|
return;
|
|
};
|
|
// A held key repeats, and every repeat would put another search on
|
|
// the wire: a run that only just went out is left to run.
|
|
if previous.status == SearchStatus::Active
|
|
&& previous.start_time.elapsed() < RERUN_HOLD
|
|
{
|
|
return;
|
|
}
|
|
previous.cancel_flag.store(true, Ordering::Relaxed);
|
|
self.state.searches[index] = self.launch_search(previous.query.clone());
|
|
self.show_search(index);
|
|
}
|
|
|
|
/// Put `query` on the wire and hand back the entry that tracks it. The
|
|
/// session keys results by query, so a repeat starts its set afresh, and
|
|
/// the entry's empty results match that.
|
|
fn launch_search(&self, query: String) -> SearchEntry {
|
|
let cancel_flag = Arc::new(AtomicBool::new(false));
|
|
let search_entry = SearchEntry {
|
|
query: query.clone(),
|
|
status: SearchStatus::Active,
|
|
results: Vec::new(),
|
|
known_files: 0,
|
|
owned: true,
|
|
start_time: Instant::now(),
|
|
cancel_flag: cancel_flag.clone(),
|
|
};
|
|
|
|
let client = self.client.clone();
|
|
let timeout = self.search_timeout;
|
|
|
|
// Results are polled in update_search_results, not returned here.
|
|
thread::spawn(move || {
|
|
if let Err(e) =
|
|
client.search_with_cancel(&query, timeout, Some(cancel_flag))
|
|
{
|
|
soulseek_rs::warn!("Search failed: {e}");
|
|
}
|
|
});
|
|
|
|
search_entry
|
|
}
|
|
|
|
/// Make the search at `index` the one the Results pane shows, starting
|
|
/// from an empty display, filter and all, for what is about to arrive.
|
|
fn show_search(&mut self, index: usize) {
|
|
self.clear_results_pane();
|
|
self.state.searches_table_state.select(Some(index));
|
|
self.state.selected_search_index = Some(index);
|
|
self.state.results_table_state.select(Some(0));
|
|
self.state.focus_pane(FocusedPane::Results);
|
|
}
|
|
|
|
/// Show the searches the session knows about, not just this window's.
|
|
/// Returns whether it synced, which is only the case against a daemon.
|
|
///
|
|
/// Against a daemon the search cache is shared, so a window that opens
|
|
/// later sees what the others have looked for, and a query run in one
|
|
/// appears in the other. A search is still collecting if it went out less
|
|
/// than one search window ago — the window belongs to whichever client
|
|
/// started it, so the age is the only thing another window can judge by.
|
|
fn sync_searches_with_session(&mut self) -> bool {
|
|
// Only worth doing when the session is shared: a window with its own
|
|
// session ran every search in the list and already knows their state.
|
|
if self.client.daemon_endpoint().is_none() {
|
|
return false;
|
|
}
|
|
|
|
let timeout = self.search_timeout.as_secs();
|
|
let known_searches = self.client.all_searches();
|
|
for known in &known_searches {
|
|
let running = known
|
|
.started_secs_ago
|
|
.is_some_and(|elapsed| elapsed < timeout);
|
|
|
|
if let Some(existing) = self
|
|
.state
|
|
.searches
|
|
.iter_mut()
|
|
.find(|search| search.query == known.query)
|
|
{
|
|
// Ours keeps the status its own worker thread sets; the rest
|
|
// follow the session, so a search finishing in one window
|
|
// stops saying "Active" in the others.
|
|
if known.started_secs_ago.is_some() && !existing.owned {
|
|
existing.status = if running {
|
|
SearchStatus::Active
|
|
} else {
|
|
SearchStatus::Completed
|
|
};
|
|
}
|
|
// The count is every entry's change detector: as long as it
|
|
// matches what this window holds, there is nothing to fetch.
|
|
existing.known_files = known.files;
|
|
continue;
|
|
}
|
|
|
|
self.state.searches.push(SearchEntry {
|
|
query: known.query.clone(),
|
|
status: if running {
|
|
SearchStatus::Active
|
|
} else {
|
|
SearchStatus::Completed
|
|
},
|
|
results: Vec::new(),
|
|
known_files: known.files,
|
|
owned: false,
|
|
start_time: std::time::Instant::now(),
|
|
cancel_flag: std::sync::Arc::new(
|
|
std::sync::atomic::AtomicBool::new(false),
|
|
),
|
|
});
|
|
}
|
|
|
|
// Dismissed elsewhere means dismissed here. Ours stay: this window is
|
|
// the authority on a search it started.
|
|
self.state.searches.retain(|search| {
|
|
search.owned
|
|
|| known_searches
|
|
.iter()
|
|
.any(|known| known.query == search.query)
|
|
});
|
|
true
|
|
}
|
|
|
|
pub(super) fn update_search_results(&mut self) {
|
|
// Only a synced session reports counts, so only there can an
|
|
// unchanged count spare the fetch. Locally `known_files` is never
|
|
// maintained and skipping on it would starve every search.
|
|
let counted = self.sync_searches_with_session();
|
|
let timeout = self.search_timeout;
|
|
let selected_search_index = self.state.selected_search_index;
|
|
|
|
// Fetch all results in one go (single lock acquisition per query)
|
|
// Use try_get_search_results to avoid blocking the UI thread
|
|
// Only what the user can see or what is still arriving, and against
|
|
// a daemon only when the session's count says something actually
|
|
// changed: results accumulate, so an unchanged count means a fetch
|
|
// would carry the entire set across the socket to learn nothing.
|
|
// A re-searched query resets the session's set; only a refill that
|
|
// lands back on exactly the held total within one tick could leave
|
|
// stale rows, and that window was already showing them.
|
|
let all_results: Vec<(usize, Vec<_>)> = self
|
|
.state
|
|
.searches
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(idx, search)| {
|
|
(search.status == SearchStatus::Active
|
|
|| selected_search_index == Some(*idx))
|
|
&& (!counted || search.known_files != search.results.len())
|
|
})
|
|
.map(|(idx, s)| (idx, s.query.clone()))
|
|
.filter_map(|(idx, query)| {
|
|
self.client
|
|
.try_get_search_results(&query)
|
|
.map(|results| (idx, results))
|
|
})
|
|
.collect();
|
|
|
|
// Now update state without holding any client locks
|
|
for (idx, search_results) in all_results {
|
|
if let Some(search) = self.state.searches.get_mut(idx) {
|
|
// Results only accumulate, so an unchanged file count means
|
|
// nothing new arrived: skip the rebuild, which clones the
|
|
// full result list several times and dominates frame time.
|
|
let total_files: usize =
|
|
search_results.iter().map(|r| r.files.len()).sum();
|
|
if total_files != search.results.len() {
|
|
search.results.clear();
|
|
for result in search_results {
|
|
for file in result.files {
|
|
search.results.push(FileDisplayData {
|
|
filename: file.name.clone(),
|
|
size: file.size,
|
|
username: result.username.clone(),
|
|
speed: result.speed,
|
|
slots: result.slots,
|
|
bitrate: file.attribs.get(&0).copied(),
|
|
length_seconds: file.attribs.get(&1).copied(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update selected search if this is the active one. Re-derive
|
|
// the filtered view from the current query so an active
|
|
// filter is preserved as new results stream in, rather than
|
|
// being clobbered by the full unfiltered list.
|
|
if let Some(selected_idx) = selected_search_index
|
|
&& selected_idx == idx
|
|
{
|
|
self.state.results_items = search.results.clone();
|
|
let (items, indices) = filter_results(
|
|
&self.state.results_items,
|
|
&self.state.results_filter_query,
|
|
);
|
|
self.state.results_filtered_items = items;
|
|
self.state.results_filtered_indices = indices;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Outside the fetch loop: a tick that had nothing to fetch must
|
|
// still finish a run-out search.
|
|
for search in &mut self.state.searches {
|
|
if search.status == SearchStatus::Active
|
|
&& search.start_time.elapsed() > timeout
|
|
{
|
|
search.status = SearchStatus::Completed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Filter `items` by a case-insensitive substring match on filename or
|
|
/// username. Returns the matching items alongside their indices in the original
|
|
/// list, so callers can translate a filtered display index back to the
|
|
/// unfiltered results. An empty query returns everything (identity mapping).
|
|
fn filter_results(
|
|
items: &[FileDisplayData],
|
|
query: &str,
|
|
) -> (Vec<FileDisplayData>, Vec<usize>) {
|
|
let query = query.to_lowercase();
|
|
if query.is_empty() {
|
|
return (items.to_vec(), (0..items.len()).collect());
|
|
}
|
|
|
|
let mut filtered_items = Vec::new();
|
|
let mut filtered_indices = Vec::new();
|
|
for (idx, item) in items.iter().enumerate() {
|
|
if item.filename.to_lowercase().contains(&query)
|
|
|| item.username.to_lowercase().contains(&query)
|
|
{
|
|
filtered_items.push(item.clone());
|
|
filtered_indices.push(idx);
|
|
}
|
|
}
|
|
(filtered_items, filtered_indices)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::filter_results;
|
|
use crate::models::FileDisplayData;
|
|
|
|
fn file(filename: &str, username: &str) -> FileDisplayData {
|
|
FileDisplayData {
|
|
filename: filename.to_string(),
|
|
username: username.to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_query_returns_identity_mapping() {
|
|
let items = vec![file("a.mp3", "bob"), file("b.flac", "amy")];
|
|
let (filtered, indices) = filter_results(&items, "");
|
|
assert_eq!(filtered.len(), 2);
|
|
assert_eq!(indices, vec![0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn query_matches_filename_and_username_and_maps_indices() {
|
|
let items = vec![
|
|
file("track.mp3", "bob"),
|
|
file("song.flac", "alice"),
|
|
file("alice_demo.mp3", "carol"),
|
|
];
|
|
// "alice" matches item 1 (username) and item 2 (filename).
|
|
let (filtered, indices) = filter_results(&items, "alice");
|
|
assert_eq!(filtered.len(), 2);
|
|
assert_eq!(indices, vec![1, 2]);
|
|
assert_eq!(filtered[0].filename, "song.flac");
|
|
assert_eq!(filtered[1].filename, "alice_demo.mp3");
|
|
}
|
|
|
|
#[test]
|
|
fn query_is_case_insensitive() {
|
|
let items = vec![file("The Weeknd.mp3", "dj")];
|
|
let (filtered, indices) = filter_results(&items, "WEEKND");
|
|
assert_eq!(filtered.len(), 1);
|
|
assert_eq!(indices, vec![0]);
|
|
}
|
|
}
|