feat(tui): run the highlighted search again with S

The search list survives a restart, but a restored query was only ever a
reminder: seeing what the network has now meant typing it out again. In
the Searches pane, `S` now re-runs the highlighted query in its place — no
duplicate row, no reordering — cancelling a run still collecting first and
starting the Results display over for what comes back.

The key is listed in the shortcut bar, the `?` overlay, the README and the
website. `start_search` is split into launching a search and showing it,
so both paths share one implementation.

A new `tui_e2e` suite drives the real window over a real session against
soulfind: keys in, a screen out, a sharing peer on the other end. It
restores a query from a previous run's state directory, presses `S`, and
checks the peer's file lands on screen — then again once the query has
been on the wire, proving a repeat is answered and the display does not
stack. To make that possible `MainTui` is exported with `handle_key_event`,
`render`, `poll_session` and a `state()` accessor, and CI runs the suite
with `SOULSEEK_E2E_REQUIRED=1`.

Closes #85
This commit is contained in:
Michel de Graaf
2026-09-08 13:11:40 +02:00
parent af1f9241e4
commit 76f4854169
10 changed files with 550 additions and 63 deletions
+11 -7
View File
@@ -14,7 +14,8 @@ use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
/// One key press, routed to whatever owns the keyboard right now.
pub fn handle_key_event(&mut self, key: KeyEvent) {
// Command bar takes priority
if self.state.command_bar_active {
return self.handle_command_bar_input(key);
@@ -400,11 +401,10 @@ impl MainTui {
if self.navigate_focused_list(key) || self.scroll_focused_name(key) {
return;
}
let selected = self.state.searches_table_state.selected();
match key.code {
KeyCode::Enter => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
if let Some(selected) = selected {
self.state.selected_search_index = Some(selected);
if let Some(search) = self.state.searches.get(selected) {
self.state.results_items = search.results.clone();
@@ -420,12 +420,16 @@ impl MainTui {
}
}
KeyCode::Char('d') => {
if let Some(selected) =
self.state.searches_table_state.selected()
{
if let Some(selected) = selected {
self.remove_search_at_index(selected);
}
}
// `s` asks for a query; `S` reuses the highlighted one.
KeyCode::Char('S') => {
if let Some(selected) = selected {
self.rerun_search_at_index(selected);
}
}
KeyCode::Char('C') => {
self.clear_all_searches();
}
+143 -23
View File
@@ -196,31 +196,30 @@ impl MainTui {
result
}
/// What the window holds, for a test to read back after driving it.
#[must_use]
pub const fn state(&self) -> &AppState {
&self.state
}
/// One frame's worth of catching up with the session: everything the
/// event loop does between drawing and reading the keyboard.
pub fn poll_session(&mut self) {
self.update_search_results();
self.update_downloads();
self.poll_private_messages();
self.poll_browse_result();
self.poll_room_events();
self.state.uploads = self.client.uploads();
self.spinner_state = (self.spinner_state + 1) % 10;
self.save_persisted_state();
}
fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.state.should_exit {
terminal.draw(|frame| self.render(frame))?;
// Poll for search results updates
self.update_search_results();
// Poll for download updates
self.update_downloads();
// Poll for incoming private messages
self.poll_private_messages();
// Poll for a browse (shared-file listing) response
self.poll_browse_result();
// Poll for chat-room events
self.poll_room_events();
// Refresh the uploads we are serving to peers
self.state.uploads = self.client.uploads();
self.spinner_state = (self.spinner_state + 1) % 10;
self.save_persisted_state();
self.poll_session();
// Drain every queued input event before the next draw: key
// autorepeat outpaces the frame time, and handling one event per
@@ -279,6 +278,8 @@ mod tests {
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
/// What `try_get_search_results` hands back to the window.
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
/// Every query a window put on the wire, in order.
searched: std::sync::Mutex<Vec<String>>,
/// How many times a window asked for the search list.
all_searches_calls: std::sync::atomic::AtomicUsize,
/// How many times a window pulled a full result set across. Against a
@@ -347,10 +348,14 @@ mod tests {
}
fn search_with_cancel(
&self,
_query: &str,
query: &str,
_timeout: Duration,
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
self.searched
.lock()
.expect("not poisoned")
.push(query.to_string());
Ok(Vec::new())
}
fn get_search_results(
@@ -1954,11 +1959,15 @@ mod tests {
}
#[test]
fn the_searches_pane_lists_its_clear_all_key() {
fn the_searches_pane_lists_its_clear_all_and_rerun_keys() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[C → clear all]"), "{screen}");
assert!(screen.contains("[S → search again]"), "{screen}");
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("run the search again"), "{screen}");
}
#[test]
@@ -2051,4 +2060,115 @@ mod tests {
let screen = screen_of(&mut tui);
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
}
/// The queries the window has sent so far, once at least `count` of them
/// are out: a search goes to the session from a worker thread, so a test
/// has to give it a moment.
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let seen = session.searched.lock().expect("not poisoned").clone();
if seen.len() >= count || std::time::Instant::now() > deadline {
return seen;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// A window over last session's search list: the queries came back from
/// disk, with nothing under them.
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
let mut tui = attach(session);
let queries: Vec<String> =
queries.iter().map(ToString::to_string).collect();
restore_searches(&mut tui.state, &queries);
tui.state.focused_pane = FocusedPane::Searches;
tui
}
#[test]
fn shift_s_runs_the_highlighted_search_again_in_its_place() {
// The list survives a restart, but a query on it was only ever a
// reminder: seeing what the network has now meant typing it again.
let session = Arc::new(TalkativeSession::default());
let mut tui =
restored(session.clone(), &["boards of canada", "aphex twin"]);
tui.state.searches_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(
searched(&session, 1),
vec!["aphex twin".to_string()],
"the highlighted query went out again, untyped"
);
let queries: Vec<&str> = tui
.state
.searches
.iter()
.map(|s| s.query.as_str())
.collect();
assert_eq!(
queries,
["boards of canada", "aphex twin"],
"it keeps its row: no duplicate, nothing moved"
);
assert_eq!(tui.state.searches[1].status, SearchStatus::Active);
assert_eq!(tui.state.selected_search_index, Some(1));
assert_eq!(
tui.state.focused_pane,
FocusedPane::Results,
"and the window turns to where the results will land"
);
assert!(tui.state.results_items.is_empty());
}
#[test]
fn shift_s_stops_a_run_still_collecting_before_starting_over() {
let mut tui = furnished_tui();
tui.state.searches[0].status = SearchStatus::Active;
tui.state.searches[0].start_time = std::time::Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("two seconds ago");
let previous = tui.state.searches[0].cancel_flag.clone();
tui.state.focused_pane = FocusedPane::Searches;
tui.state.searches_table_state.select(Some(0));
press(&mut tui, KeyCode::Char('S'));
assert!(
previous.load(std::sync::atomic::Ordering::Relaxed),
"the worker that was collecting is told to stop"
);
let fresh = &tui.state.searches[0];
assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(fresh.results.is_empty(), "stale rows do not linger");
assert_eq!(tui.state.searches.len(), 1);
}
#[test]
fn shift_s_is_only_a_key_over_the_searches_list() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
// Over Results the same key means nothing, so a stray shift does not
// throw a finished search away.
tui.state.focused_pane = FocusedPane::Results;
press(&mut tui, KeyCode::Char('S'));
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
std::thread::sleep(Duration::from_millis(50));
assert!(searched(&session, 0).is_empty(), "nothing went out");
}
#[test]
fn a_held_shift_s_is_one_search_not_one_per_repeat() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
press(&mut tui, KeyCode::Char('S'));
press(&mut tui, KeyCode::Char('1'));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(searched(&session, 1).len(), 1);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored");
}
}
+4 -1
View File
@@ -63,6 +63,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
"Searches",
&[
("Enter", "show its results"),
("S", "run the search again"),
("d", "remove the search"),
("C", "clear every search"),
("c", "chat rooms"),
@@ -126,7 +127,8 @@ fn filter_keys(
}
impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) {
/// Draw the whole window.
pub fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![
Constraint::Length(3), // Status bar
@@ -631,6 +633,7 @@ impl MainTui {
let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![
("s", "search"),
("S", "search again"),
("Enter", "results"),
("d", "remove"),
("C", "clear all"),
+55 -25
View File
@@ -5,11 +5,18 @@ use crate::models::{
};
use chrono::Local;
use std::{
sync::{Arc, atomic::AtomicBool},
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() {
@@ -18,9 +25,7 @@ impl MainTui {
// Cancel the search if it's active
if let Some(search) = self.state.searches.get(index) {
search
.cancel_flag
.store(true, std::sync::atomic::Ordering::Relaxed);
search.cancel_flag.store(true, Ordering::Relaxed);
}
// Check if we're removing the currently active search
@@ -66,9 +71,7 @@ impl MainTui {
pub(super) fn clear_all_searches(&mut self) {
// Cancel all active searches
for search in &self.state.searches {
search
.cancel_flag
.store(true, std::sync::atomic::Ordering::Relaxed);
search.cancel_flag.store(true, Ordering::Relaxed);
}
// And drop them from the session, or the next sync brings every one
@@ -205,6 +208,39 @@ impl MainTui {
}
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 place.
///
/// This is what a restored list is for: last session's queries come back
/// with nothing under them, and typing one out again to see what the
/// network has now is the work the list was meant to save. The entry
/// keeps its row rather than moving to the end — the user is looking at
/// it — and a run still collecting is stopped first, or two threads would
/// be pouring into one result 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(),
@@ -216,24 +252,6 @@ impl MainTui {
cancel_flag: cancel_flag.clone(),
};
self.state.searches.push(search_entry);
let search_index = self.state.searches.len() - 1;
self.state.searches_table_state.select(Some(search_index));
// Make this search the active one
self.state.selected_search_index = Some(search_index);
// Initialize results display (empty at first)
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(Some(0));
self.state.results_name_offset = 0;
// Switch focus to Results pane, bringing it back if it was hidden
self.state.focus_pane(FocusedPane::Results);
let client = self.client.clone();
let timeout = self.search_timeout;
@@ -251,6 +269,18 @@ impl MainTui {
}
}
});
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.
+1 -1
View File
@@ -7,7 +7,7 @@ mod styles;
mod utils;
pub use downloads::render_download_stats;
pub use main_tui::launch_main_tui;
pub use main_tui::{MainTui, launch_main_tui};
pub use paging::*;
pub use styles::*;
pub use utils::*;