Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/search.rs
T
Michel de Graaf 3ef78c6de1 refactor(tests): share one soulfind fixture across the e2e suites
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.
2026-09-08 13:12:12 +02:00

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]);
}
}