feat: persist and restore TUI state across restarts
The main TUI now loads state on startup — search history restored as inert entries, saved chat rooms rejoined on the server, and incomplete downloads (including failed/timed-out ones) automatically re-enqueued — and saves a diffed snapshot each loop tick and on exit, so only changed files are rewritten.
This commit is contained in:
@@ -243,12 +243,16 @@ fn run_default_tui(
|
|||||||
|
|
||||||
persist_credentials(&outcome, config_path, file_config, &secret_store);
|
persist_credentials(&outcome, config_path, file_config, &secret_store);
|
||||||
|
|
||||||
|
let store = persist::paths::state_dir()
|
||||||
|
.map(persist::state::StateStore::new);
|
||||||
|
|
||||||
launch_main_tui(
|
launch_main_tui(
|
||||||
terminal,
|
terminal,
|
||||||
Arc::new(outcome.client),
|
Arc::new(outcome.client),
|
||||||
resolved.download_dir.clone(),
|
resolved.download_dir.clone(),
|
||||||
resolved.max_concurrent_downloads,
|
resolved.max_concurrent_downloads,
|
||||||
Duration::from_secs(resolved.search_timeout),
|
Duration::from_secs(resolved.search_timeout),
|
||||||
|
store,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,5 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod secret;
|
pub mod secret;
|
||||||
|
pub mod snapshot;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -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<PersistedDownload>,
|
||||||
|
pub queries: Vec<String>,
|
||||||
|
pub rooms: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> = 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<_>>(),
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ mod rooms;
|
|||||||
mod search;
|
mod search;
|
||||||
|
|
||||||
use crate::models::AppState;
|
use crate::models::AppState;
|
||||||
|
use crate::persist::{
|
||||||
|
snapshot::{Snapshot, restore_searches},
|
||||||
|
state::StateStore,
|
||||||
|
};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal,
|
DefaultTerminal,
|
||||||
@@ -22,6 +26,9 @@ pub struct MainTui {
|
|||||||
max_concurrent_downloads: usize,
|
max_concurrent_downloads: usize,
|
||||||
search_timeout: Duration,
|
search_timeout: Duration,
|
||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
|
store: Option<StateStore>,
|
||||||
|
/// Last snapshot written to disk, to skip no-op saves.
|
||||||
|
saved_snapshot: Snapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
@@ -30,15 +37,107 @@ impl MainTui {
|
|||||||
download_dir: String,
|
download_dir: String,
|
||||||
max_concurrent_downloads: usize,
|
max_concurrent_downloads: usize,
|
||||||
search_timeout: Duration,
|
search_timeout: Duration,
|
||||||
|
store: Option<StateStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
let mut tui = Self {
|
||||||
client,
|
client,
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
download_dir,
|
download_dir,
|
||||||
max_concurrent_downloads,
|
max_concurrent_downloads,
|
||||||
search_timeout,
|
search_timeout,
|
||||||
spinner_state: 0,
|
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<()> {
|
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
|
// of raw mode / the alternate screen and mouse capture disabled, or the
|
||||||
// user is left with a corrupted terminal.
|
// user is left with a corrupted terminal.
|
||||||
let result = self.run_event_loop(&mut terminal);
|
let result = self.run_event_loop(&mut terminal);
|
||||||
|
self.save_persisted_state();
|
||||||
|
|
||||||
let _ = execute!(std::io::stdout(), DisableMouseCapture);
|
let _ = execute!(std::io::stdout(), DisableMouseCapture);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
@@ -78,6 +178,8 @@ impl MainTui {
|
|||||||
|
|
||||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
|
||||||
|
self.save_persisted_state();
|
||||||
|
|
||||||
// Drain every queued input event before the next draw: key
|
// Drain every queued input event before the next draw: key
|
||||||
// autorepeat outpaces the frame time, and handling one event per
|
// autorepeat outpaces the frame time, and handling one event per
|
||||||
// frame makes the backlog keep scrolling for seconds after the
|
// frame makes the backlog keep scrolling for seconds after the
|
||||||
@@ -110,12 +212,14 @@ pub fn launch_main_tui(
|
|||||||
download_dir: String,
|
download_dir: String,
|
||||||
max_concurrent_downloads: usize,
|
max_concurrent_downloads: usize,
|
||||||
search_timeout: Duration,
|
search_timeout: Duration,
|
||||||
|
store: Option<StateStore>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let tui = MainTui::new(
|
let tui = MainTui::new(
|
||||||
client,
|
client,
|
||||||
download_dir,
|
download_dir,
|
||||||
max_concurrent_downloads,
|
max_concurrent_downloads,
|
||||||
search_timeout,
|
search_timeout,
|
||||||
|
store,
|
||||||
);
|
);
|
||||||
tui.run(terminal)
|
tui.run(terminal)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user