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:
michel
2026-07-20 20:17:44 +02:00
parent d410f9e834
commit c945bd5d5e
4 changed files with 297 additions and 1 deletions
+105 -1
View File
@@ -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<StateStore>,
/// 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<StateStore>,
) -> 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<StateStore>,
) -> Result<()> {
let tui = MainTui::new(
client,
download_dir,
max_concurrent_downloads,
search_timeout,
store,
);
tui.run(terminal)
}