feat(daemon): make every window a view of one shared session

Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.

The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.

Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.

Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.

A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
This commit is contained in:
Michel de Graaf
2026-07-27 20:58:49 +02:00
parent 220beb4e41
commit 011eafaa7f
22 changed files with 900 additions and 33 deletions
+12 -1
View File
@@ -42,11 +42,18 @@ fn active_progress(downloads: &[DownloadEntry]) -> Option<(u64, u64, f64)> {
}
/// Renders download statistics in a reusable way
/// The status bar.
///
/// `daemon` names the session's owner when this window is attached to one. It
/// belongs in the title because a shared session behaves differently from a
/// private one: another window can add transfers, and closing this one leaves
/// them running.
pub fn render_download_stats(
frame: &mut Frame,
area: ratatui::layout::Rect,
downloads: &[DownloadEntry],
active_count: usize,
daemon: Option<&str>,
) {
let completed = downloads
.iter()
@@ -72,7 +79,11 @@ pub fn render_download_stats(
let active = active_progress(downloads);
let block = pane_block(false).title(plain_title("Status", false));
let title = match daemon {
Some(endpoint) => format!("Status · daemon {endpoint}"),
None => "Status".to_string(),
};
let block = pane_block(false).title(plain_title(&title, false));
let inner_area = block.inner(area);
frame.render_widget(block, area);
+81 -3
View File
@@ -59,6 +59,22 @@ impl MainTui {
/// Remove all completed / failed / timed-out downloads from the list.
pub(super) fn clear_finished_downloads(&mut self) {
// Forget them in the session too. The list is a view of what the
// session holds, so dropping them only here would last until the next
// poll put them straight back.
for entry in &self.state.downloads {
if matches!(
entry.download.status,
DownloadStatus::Completed
| DownloadStatus::Failed(_)
| DownloadStatus::TimedOut
) {
self.client.remove_download(
&entry.download.username,
&entry.download.filename,
);
}
}
self.state.downloads.retain(|entry| {
!matches!(
entry.download.status,
@@ -218,13 +234,30 @@ impl MainTui {
pub(super) fn update_downloads(&mut self) {
if let Some(ref receiver) = self.state.downloads_receiver_channel {
while let Ok((download, download_receiver)) = receiver.try_recv() {
self.state.downloads.push(DownloadEntry {
download,
receiver: Some(download_receiver),
// A transfer of ours can already be listed: the session
// registers it the moment it is queued, so a frame between
// that and the worker handing it over here will have picked it
// up from the session. Take over that row rather than adding a
// second one for the same file.
let existing = self.state.downloads.iter_mut().find(|entry| {
entry.download.username == download.username
&& entry.download.filename == download.filename
});
match existing {
Some(entry) => {
entry.download = download;
entry.receiver = Some(download_receiver);
}
None => self.state.downloads.push(DownloadEntry {
download,
receiver: Some(download_receiver),
}),
}
}
}
self.sync_downloads_with_session();
self.state.active_downloads_count = 0;
for download_entry in &mut self.state.downloads {
if let Some(ref receiver) = download_entry.receiver {
@@ -241,4 +274,49 @@ impl MainTui {
}
}
}
/// Make the list show the session's transfers, not just this window's.
///
/// The queue belongs to the session, so against a daemon it includes what
/// another client queued and what was still running before this window
/// opened. Transfers this window started keep their own status channel,
/// which reports sooner than a poll; everything else is refreshed from the
/// session each tick.
fn sync_downloads_with_session(&mut self) {
// Only worth doing when the session is shared. A window with a
// session of its own started every transfer in it, so there is
// nothing to learn — and the list also holds completed transfers
// restored from disk that the session has never heard of, which this
// reconciliation would take to mean "cancelled elsewhere" and delete.
if self.client.daemon_endpoint().is_none() {
return;
}
let known = self.client.get_all_downloads();
for download in &known {
let existing = self.state.downloads.iter_mut().find(|entry| {
entry.download.username == download.username
&& entry.download.filename == download.filename
});
match existing {
// Ours: the channel is more current than this snapshot.
Some(entry) if entry.receiver.is_some() => {}
Some(entry) => entry.download.status = download.status.clone(),
None => self.state.downloads.push(DownloadEntry {
download: download.clone(),
receiver: None,
}),
}
}
// Anything the session has forgotten is gone for every window.
self.state.downloads.retain(|entry| {
entry.receiver.is_some()
|| known.iter().any(|download| {
download.username == entry.download.username
&& download.filename == entry.download.filename
})
});
}
}
+230 -7
View File
@@ -269,15 +269,40 @@ pub fn launch_main_tui(
mod tests {
use super::*;
use crate::daemon::proto::ChatMessageDto;
use crate::models::MessageDirection;
use crate::models::{MessageDirection, SearchStatus};
/// A session that knows a conversation and nothing else — what attaching
/// to a daemon looks like from the TUI's side.
struct TalkativeSession(Vec<ChatMessageDto>);
/// A stand-in for a daemon: it already holds a conversation, a transfer
/// queue, and a search cache, none of which this window created.
#[derive(Default)]
struct TalkativeSession {
history: Vec<ChatMessageDto>,
downloads: Vec<soulseek_rs::types::Download>,
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
/// Whether this session is a daemon's. Local sessions do not sync.
shared: bool,
}
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
let (sender, _) = std::sync::mpsc::channel();
soulseek_rs::types::Download {
username: username.to_string(),
filename: filename.to_string(),
token: 1,
size: 4096,
download_directory: "/tmp".to_string(),
status: soulseek_rs::DownloadStatus::Queued,
sender,
queue_position: None,
metadata: soulseek_rs::types::DownloadMetadata::default(),
}
}
impl SessionApi for TalkativeSession {
fn message_history(&self) -> Vec<ChatMessageDto> {
self.0.clone()
self.history.clone()
}
fn daemon_endpoint(&self) -> Option<String> {
self.shared.then(|| "test-daemon".to_string())
}
fn username(&self) -> String {
"tester".to_string()
@@ -302,6 +327,15 @@ mod tests {
) -> Vec<soulseek_rs::SearchResult> {
Vec::new()
}
fn all_searches(&self) -> Vec<crate::api::SessionSearch> {
self.searches.lock().expect("not poisoned").clone()
}
fn forget_search(&self, query: &str) -> bool {
let mut searches = self.searches.lock().expect("not poisoned");
let before = searches.len();
searches.retain(|search| search.query != query);
searches.len() != before
}
fn get_search_results_count(&self, _key: &str) -> usize {
0
}
@@ -346,7 +380,7 @@ mod tests {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
Vec::new()
self.downloads.clone()
}
fn pause_download(&self, _u: &str, _f: &str) -> bool {
false
@@ -441,8 +475,36 @@ mod tests {
}
fn tui(history: Vec<ChatMessageDto>) -> MainTui {
with_session(TalkativeSession {
history,
shared: true,
..TalkativeSession::default()
})
}
fn shared(searches: Vec<crate::api::SessionSearch>) -> TalkativeSession {
TalkativeSession {
searches: std::sync::Mutex::new(searches),
shared: true,
..TalkativeSession::default()
}
}
fn search_of(
query: &str,
files: usize,
age: u64,
) -> crate::api::SessionSearch {
crate::api::SessionSearch {
query: query.to_string(),
files,
started_secs_ago: Some(age),
}
}
fn with_session(session: TalkativeSession) -> MainTui {
MainTui::new(
Arc::new(TalkativeSession(history)),
Arc::new(session),
"/tmp".to_string(),
1,
Duration::from_secs(1),
@@ -481,6 +543,167 @@ mod tests {
assert_eq!(tui.state.chat_peers(), ["bob"]);
}
#[test]
fn a_window_shows_transfers_it_did_not_start() {
// Two windows on one daemon are two views of one queue. A transfer
// queued in the other one has to appear here, or closing a window
// would look like losing its downloads.
let mut tui = with_session(TalkativeSession {
downloads: vec![queued("bob", "@@x\\theirs.mp3")],
shared: true,
..TalkativeSession::default()
});
assert!(
tui.state.downloads.is_empty(),
"nothing until the first poll"
);
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3");
assert!(
tui.state.downloads[0].receiver.is_none(),
"someone else's transfer has no channel of ours"
);
// Polling again must not double it up.
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
}
#[test]
fn a_transfer_the_session_forgets_leaves_every_window() {
let mut tui = with_session(TalkativeSession {
downloads: vec![queued("bob", "gone.mp3")],
shared: true,
..TalkativeSession::default()
});
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
// The session no longer has it — cancelled from the other window.
tui.client = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
tui.update_downloads();
assert!(
tui.state.downloads.is_empty(),
"a queue is shared in both directions"
);
}
#[test]
fn a_window_shows_searches_run_in_another() {
let mut tui =
with_session(shared(vec![search_of("their query", 7, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
assert_eq!(tui.state.searches[0].query, "their query");
// And it is not added a second time on the next frame.
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
}
#[test]
fn a_search_another_window_is_still_running_shows_as_active() {
// It had said "Done" the moment it appeared, because an imported
// search carried no notion of still collecting — so the other window
// looked finished while results were still arriving.
let mut tui =
with_session(shared(vec![search_of("still going", 3, 0)]));
tui.update_search_results();
assert_eq!(tui.state.searches[0].status, SearchStatus::Active);
assert_eq!(
tui.state.searches[0].known_files, 3,
"and its count comes across before the results do"
);
}
#[test]
fn a_search_that_has_run_its_window_shows_as_done() {
let mut tui =
with_session(shared(vec![search_of("finished", 12, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
}
#[test]
fn removing_a_search_removes_it_from_the_session() {
// Removing it only from this window would last until the next sync,
// which would put it straight back — and in a shared session it would
// come back for every other window too.
let mut tui =
with_session(shared(vec![search_of("unwanted", 1, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
tui.remove_search_at_index(0);
tui.update_search_results();
assert!(tui.state.searches.is_empty(), "and it stays gone");
}
#[test]
fn clearing_every_search_clears_them_in_the_session_too() {
let mut tui = with_session(shared(vec![
search_of("one", 1, 9_999),
search_of("two", 2, 9_999),
]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 2);
tui.clear_all_searches();
tui.update_search_results();
assert!(
tui.state.searches.is_empty(),
"clearing has to reach the session, or every row comes back"
);
}
#[test]
fn a_search_dismissed_in_another_window_leaves_this_one() {
let session = shared(vec![search_of("theirs", 4, 9_999)]);
let mut tui = with_session(session);
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
// The other window dismissed it.
tui.client.forget_search("theirs");
tui.update_search_results();
assert!(tui.state.searches.is_empty());
}
#[test]
fn a_window_with_its_own_session_is_left_alone() {
// Nothing to share, so nothing is synced — and in particular the
// completed transfers restored from disk, which the session has never
// heard of, are not mistaken for cancelled and deleted.
let mut tui = with_session(TalkativeSession {
downloads: Vec::new(),
shared: false,
..TalkativeSession::default()
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "from-disk.mp3"),
receiver: None,
});
tui.update_downloads();
assert_eq!(
tui.state.downloads.len(),
1,
"a local window's history must survive the tick"
);
}
#[test]
fn a_local_session_starts_from_its_own_snapshot_instead() {
// Locally the session knows no history and the state file is the whole
+2
View File
@@ -41,11 +41,13 @@ impl MainTui {
constraints.push(Constraint::Length(3)); // Shortcuts
let main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint();
render_download_stats(
frame,
main_chunks[0],
&self.state.downloads,
self.state.active_downloads_count,
daemon.as_deref(),
);
self.render_content(frame, main_chunks[1]);
+103 -1
View File
@@ -26,6 +26,13 @@ impl MainTui {
// 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 {
@@ -64,7 +71,19 @@ impl MainTui {
.store(true, std::sync::atomic::Ordering::Relaxed);
}
// Clear searches
// 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;
@@ -173,6 +192,8 @@ impl MainTui {
query: query.clone(),
status: SearchStatus::Active,
results: Vec::new(),
known_files: 0,
owned: true,
start_time: Instant::now(),
cancel_flag: cancel_flag.clone(),
};
@@ -213,17 +234,98 @@ impl MainTui {
});
}
/// Show the searches the session knows about, not just this window's.
///
/// 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) {
// 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,
// so asking would cost a full copy of every result set per frame and
// tell us nothing.
if self.client.daemon_endpoint().is_none() {
return;
}
let timeout = self.search_timeout.as_secs();
for known in self.client.all_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
};
existing.known_files = known.files;
}
continue;
}
self.state.searches.push(SearchEntry {
query: known.query,
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.
let live: Vec<String> = self
.client
.all_searches()
.into_iter()
.map(|search| search.query)
.collect();
self.state
.searches
.retain(|search| search.owned || live.contains(&search.query));
}
pub(super) fn update_search_results(&mut self) {
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. A shared
// cache can hold every search every window has ever run, and fetching
// all of them each frame is a round trip apiece against a daemon.
// The rest show the count the session reported.
let all_results: Vec<(usize, Vec<_>)> = self
.state
.searches
.iter()
.enumerate()
.filter(|(idx, search)| {
search.status == SearchStatus::Active
|| selected_search_index == Some(*idx)
})
.map(|(idx, s)| (idx, s.query.clone()))
.filter_map(|(idx, query)| {
self.client
+8 -1
View File
@@ -35,7 +35,14 @@ pub fn render_searches_pane(
}
};
let results_count = search.results.len();
// What this window has fetched, or what the session says it will
// find when it does. A search another window ran has a count long
// before this one has pulled the results across.
let results_count = if search.results.is_empty() {
search.known_files
} else {
search.results.len()
};
let results_text = if results_count == 0
&& search.status == SearchStatus::Active
{