diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index aecc779..e9794e3 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -243,12 +243,16 @@ fn run_default_tui( persist_credentials(&outcome, config_path, file_config, &secret_store); + let store = persist::paths::state_dir() + .map(persist::state::StateStore::new); + launch_main_tui( terminal, Arc::new(outcome.client), resolved.download_dir.clone(), resolved.max_concurrent_downloads, Duration::from_secs(resolved.search_timeout), + store, ) } diff --git a/soulseek-rs/src/persist/mod.rs b/soulseek-rs/src/persist/mod.rs index 0012e9e..1c92b6d 100644 --- a/soulseek-rs/src/persist/mod.rs +++ b/soulseek-rs/src/persist/mod.rs @@ -7,4 +7,5 @@ pub mod config; pub mod paths; pub mod secret; +pub mod snapshot; pub mod state; diff --git a/soulseek-rs/src/persist/snapshot.rs b/soulseek-rs/src/persist/snapshot.rs new file mode 100644 index 0000000..99b5a46 --- /dev/null +++ b/soulseek-rs/src/persist/snapshot.rs @@ -0,0 +1,187 @@ +//! Capture the persistable slice of [`AppState`] (and restore the pure +//! parts of it). Live handles (channels, cancel flags) never leave the +//! process; only plain data goes to disk. + +use super::state::PersistedDownload; +use crate::models::{AppState, SearchEntry, SearchStatus}; +use soulseek_rs::DownloadStatus; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Snapshot { + pub downloads: Vec, + pub queries: Vec, + pub rooms: Vec, +} + +impl Snapshot { + /// Extract what should survive a restart. Downloads that are not yet + /// `Completed` (including failed/timed-out ones) are marked incomplete + /// so the next start can re-enqueue them. + pub fn capture(state: &AppState) -> Self { + let downloads = state + .downloads + .iter() + .map(|entry| PersistedDownload { + username: entry.download.username.clone(), + filename: entry.download.filename.clone(), + size: entry.download.size, + download_directory: entry + .download + .download_directory + .clone(), + completed: matches!( + entry.download.status, + DownloadStatus::Completed + ), + }) + .collect(); + + let mut queries: Vec = Vec::new(); + for entry in &state.searches { + if !queries.contains(&entry.query) { + queries.push(entry.query.clone()); + } + } + + let rooms = + state.rooms.open.iter().map(|room| room.name.clone()).collect(); + + Self { + downloads, + queries, + rooms, + } + } +} + +/// Rebuild search history entries from persisted query strings. Restored +/// searches are inert (completed, no results) — selecting one shows an +/// empty result set until it is re-run. +pub fn restore_searches(state: &mut AppState, queries: &[String]) { + for query in queries { + state.searches.push(SearchEntry { + query: query.clone(), + status: SearchStatus::Completed, + results: Vec::new(), + start_time: std::time::Instant::now(), + cancel_flag: std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::DownloadEntry; + use soulseek_rs::types::{Download, DownloadMetadata}; + use std::sync::atomic::AtomicBool; + use std::sync::{Arc, mpsc}; + use std::time::Instant; + + fn download( + filename: &str, + status: DownloadStatus, + ) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "peer".into(), + filename: filename.into(), + token: 7, + size: 42, + download_directory: "/music".into(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + fn search(query: &str) -> SearchEntry { + SearchEntry { + query: query.into(), + status: SearchStatus::Active, + results: Vec::new(), + start_time: Instant::now(), + cancel_flag: Arc::new(AtomicBool::new(false)), + } + } + + #[test] + fn capture_maps_download_status_to_completed_flag() { + let mut state = AppState::new(); + state.downloads.push(download("done.mp3", DownloadStatus::Completed)); + state.downloads.push(download("queued.mp3", DownloadStatus::Queued)); + state.downloads.push(download( + "failed.mp3", + DownloadStatus::Failed(Some("nope".into())), + )); + + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot + .downloads + .iter() + .map(|d| (d.filename.as_str(), d.completed)) + .collect::>(), + vec![ + ("done.mp3", true), + ("queued.mp3", false), + ("failed.mp3", false) + ] + ); + assert_eq!(snapshot.downloads[0].username, "peer"); + assert_eq!(snapshot.downloads[0].size, 42); + assert_eq!(snapshot.downloads[0].download_directory, "/music"); + } + + #[test] + fn capture_takes_queries_in_order_without_duplicates() { + let mut state = AppState::new(); + state.searches.push(search("beatles")); + state.searches.push(search("miles davis")); + state.searches.push(search("beatles")); + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot.queries, + vec!["beatles".to_string(), "miles davis".to_string()] + ); + } + + #[test] + fn capture_takes_open_room_names() { + let mut state = AppState::new(); + state.rooms.focus_or_open("indie"); + state.rooms.focus_or_open("jazz"); + let snapshot = Snapshot::capture(&state); + assert_eq!( + snapshot.rooms, + vec!["indie".to_string(), "jazz".to_string()] + ); + } + + #[test] + fn restore_searches_creates_inert_completed_entries() { + let mut state = AppState::new(); + restore_searches( + &mut state, + &["beatles".to_string(), "miles davis".to_string()], + ); + assert_eq!(state.searches.len(), 2); + assert_eq!(state.searches[0].query, "beatles"); + assert_eq!(state.searches[0].status, SearchStatus::Completed); + assert!(state.searches[0].results.is_empty()); + } + + #[test] + fn capture_of_restored_searches_round_trips() { + let mut state = AppState::new(); + restore_searches(&mut state, &["beatles".to_string()]); + let snapshot = Snapshot::capture(&state); + assert_eq!(snapshot.queries, vec!["beatles".to_string()]); + } +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 2dee488..38d2b1b 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -6,6 +6,10 @@ mod rooms; mod search; use crate::models::AppState; +use crate::persist::{ + snapshot::{Snapshot, restore_searches}, + state::StateStore, +}; use color_eyre::Result; use ratatui::{ DefaultTerminal, @@ -22,6 +26,9 @@ pub struct MainTui { max_concurrent_downloads: usize, search_timeout: Duration, spinner_state: usize, + store: Option, + /// Last snapshot written to disk, to skip no-op saves. + saved_snapshot: Snapshot, } impl MainTui { @@ -30,15 +37,107 @@ impl MainTui { download_dir: String, max_concurrent_downloads: usize, search_timeout: Duration, + store: Option, ) -> Self { - Self { + let mut tui = Self { client, state: AppState::new(), download_dir, max_concurrent_downloads, search_timeout, spinner_state: 0, + store, + saved_snapshot: Snapshot::default(), + }; + tui.restore_persisted_state(); + tui + } + + /// Bring back last session's state: search history, chat rooms + /// (rejoined on the server), and downloads — incomplete ones are + /// re-enqueued so they resume automatically. + fn restore_persisted_state(&mut self) { + let Some(store) = &self.store else { return }; + + restore_searches(&mut self.state, &store.load_search_queries()); + + for room in store.load_rooms() { + if self.state.rooms.focus_or_open(&room) + && let Err(e) = self.client.join_room(&room) + { + soulseek_rs::warn!("Could not rejoin {room}: {e}"); + } } + + let downloads = store.load_downloads(); + self.saved_snapshot = Snapshot::capture(&self.state); + // Completed entries are shown as-is; the rest re-enqueue below and + // reappear through the normal downloads channel. + self.saved_snapshot.downloads.clone_from(&downloads); + + let sender = self.downloads_sender(); + for entry in downloads { + if entry.completed { + self.state.downloads.push(crate::models::DownloadEntry { + download: soulseek_rs::types::Download { + username: entry.username, + filename: entry.filename, + token: 0, + size: entry.size, + download_directory: entry.download_directory, + status: soulseek_rs::DownloadStatus::Completed, + sender: std::sync::mpsc::channel().0, + queue_position: None, + metadata: Default::default(), + }, + receiver: None, + }); + } else { + let client = self.client.clone(); + let sender = sender.clone(); + std::thread::spawn(move || { + match client.download( + entry.filename.clone(), + entry.username, + entry.size, + entry.download_directory, + ) { + Ok((download, rx)) => { + let _ = sender.send((download, rx)); + } + Err(e) => soulseek_rs::warn!( + "Could not resume {}: {e}", + entry.filename + ), + } + }); + } + } + } + + /// Write state to disk when it differs from what was last saved. + fn save_persisted_state(&mut self) { + let Some(store) = &self.store else { return }; + let snapshot = Snapshot::capture(&self.state); + if snapshot == self.saved_snapshot { + return; + } + if snapshot.downloads != self.saved_snapshot.downloads + && let Err(e) = store.save_downloads(&snapshot.downloads) + { + soulseek_rs::warn!("Could not save downloads state: {e}"); + } + if snapshot.queries != self.saved_snapshot.queries + && let Err(e) = store.save_search_queries(&snapshot.queries) + { + soulseek_rs::warn!("Could not save search history: {e}"); + } + if snapshot.rooms != self.saved_snapshot.rooms + && let Err(e) = store.save_rooms(&snapshot.rooms) + { + soulseek_rs::warn!("Could not save room state: {e}"); + } + self.saved_snapshot = snapshot; } pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { @@ -49,6 +148,7 @@ impl MainTui { // of raw mode / the alternate screen and mouse capture disabled, or the // user is left with a corrupted terminal. let result = self.run_event_loop(&mut terminal); + self.save_persisted_state(); let _ = execute!(std::io::stdout(), DisableMouseCapture); ratatui::restore(); @@ -78,6 +178,8 @@ impl MainTui { self.spinner_state = (self.spinner_state + 1) % 10; + self.save_persisted_state(); + // Drain every queued input event before the next draw: key // autorepeat outpaces the frame time, and handling one event per // frame makes the backlog keep scrolling for seconds after the @@ -110,12 +212,14 @@ pub fn launch_main_tui( download_dir: String, max_concurrent_downloads: usize, search_timeout: Duration, + store: Option, ) -> Result<()> { let tui = MainTui::new( client, download_dir, max_concurrent_downloads, search_timeout, + store, ); tui.run(terminal) }