//! Application state machine and background-thread plumbing. //! //! All restic calls run on plain `std::thread`s and report back through a //! single `mpsc` channel (`BgMsg`). The tree loader uses a generation counter //! plus a cancel flag: when the user switches snapshots while a load is in //! flight, the stale thread is signalled to kill its restic child and its //! result is discarded on arrival. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, mpsc}; use std::thread; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use ratatui::widgets::ListState; use crate::envfile::EnvFile; use crate::restic::{self, LsLine, Snapshot}; use crate::tree::Tree; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Snapshots, Tree, Repo, } impl Focus { pub fn next(self) -> Self { match self { Focus::Snapshots => Focus::Tree, Focus::Tree => Focus::Repo, Focus::Repo => Focus::Snapshots, } } pub fn prev(self) -> Self { match self { Focus::Snapshots => Focus::Repo, Focus::Tree => Focus::Snapshots, Focus::Repo => Focus::Tree, } } } /// State of a background-loaded value. pub enum LoadState { Loading, Ready(T), Failed(String), } /// State of the file-tree panel (the tree itself lives in `App::tree`). #[derive(Debug, Default, PartialEq, Eq)] pub enum TreeStatus { #[default] Idle, Loading, Failed(String), } /// Restore flow: target input -> confirmation -> background run. pub enum RestoreState { Idle, Input { input: String, cursor: usize, snapshot: String, include: Option, }, Confirm { target: String, snapshot: String, include: Option, }, Running, } /// Messages sent from background threads to the UI loop. pub enum BgMsg { Snapshots(Result, String>), Tree { generation: u64, snapshot: String, result: Result, String>, }, Restore(Result), } const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; pub struct App { env: Arc>, /// Display name of the selected env file (relative path). pub env_rel_path: String, /// RESTIC_REPOSITORY value, for display only. pub repo: String, pub focus: Focus, pub should_quit: bool, pub tick: u64, pub snapshots: LoadState>, pub snap_state: ListState, /// Currently displayed tree together with its snapshot short_id. pub tree: Option<(String, Tree)>, pub tree_status: TreeStatus, tree_cache: HashMap, tree_generation: u64, tree_cancel: Arc, pub restore_state: RestoreState, /// Last finished restore: (success, message). pub restore_outcome: Option<(bool, String)>, restore_cancel: Arc, /// Transient status-line message. pub status: Option, tx: Sender, rx: Receiver, } impl App { pub fn new(env_file: EnvFile) -> App { let (tx, rx) = mpsc::channel(); let repo = env_file.repository().to_string(); let mut app = App { env: Arc::new(env_file.vars), env_rel_path: env_file.rel_path, repo, focus: Focus::Snapshots, should_quit: false, tick: 0, snapshots: LoadState::Loading, snap_state: ListState::default(), tree: None, tree_status: TreeStatus::Idle, tree_cache: HashMap::new(), tree_generation: 0, tree_cancel: Arc::new(AtomicBool::new(false)), restore_state: RestoreState::Idle, restore_outcome: None, restore_cancel: Arc::new(AtomicBool::new(false)), status: None, tx, rx, }; app.spawn_snapshots(); app } pub fn spinner(&self) -> char { SPINNER[(self.tick as usize) % SPINNER.len()] } pub fn selected_snapshot(&self) -> Option<&Snapshot> { let LoadState::Ready(snaps) = &self.snapshots else { return None; }; self.snap_state.selected().and_then(|i| snaps.get(i)) } // ---- background loads ------------------------------------------------- fn spawn_snapshots(&mut self) { self.snapshots = LoadState::Loading; let env = self.env.clone(); let tx = self.tx.clone(); thread::spawn(move || { let result = restic::snapshots(&env).map_err(|e| format!("{e:#}")); let _ = tx.send(BgMsg::Snapshots(result)); }); } /// Make sure the tree for the currently selected snapshot is loaded: /// serve it from the cache when possible, otherwise start a background /// load (cancelling any stale one). fn ensure_tree(&mut self) { let Some(snap) = self.selected_snapshot() else { self.tree = None; self.tree_status = TreeStatus::Idle; return; }; let sid = snap.short_id.clone(); if matches!(&self.tree, Some((cur, _)) if *cur == sid) { return; } // Stash the current tree back into the cache. if let Some((cur, tree)) = self.tree.take() { self.tree_cache.insert(cur, tree); } if let Some(tree) = self.tree_cache.remove(&sid) { self.tree = Some((sid, tree)); self.tree_status = TreeStatus::Idle; return; } self.tree_generation += 1; self.tree_cancel.store(true, Ordering::Relaxed); let cancel = Arc::new(AtomicBool::new(false)); self.tree_cancel = cancel.clone(); self.tree_status = TreeStatus::Loading; let generation = self.tree_generation; let env = self.env.clone(); let tx = self.tx.clone(); thread::spawn(move || { let result = restic::ls(&env, &sid, &cancel).map_err(|e| format!("{e:#}")); let _ = tx.send(BgMsg::Tree { generation, snapshot: sid, result, }); }); } /// Refresh snapshots (`R`); drops the tree cache. pub fn refresh(&mut self) { self.tree_cancel.store(true, Ordering::Relaxed); self.tree_generation += 1; self.tree_cache.clear(); self.tree = None; self.tree_status = TreeStatus::Idle; self.spawn_snapshots(); self.status = Some("refreshing…".to_string()); } /// Signal all background children (restic ls / restore) to stop. Called on quit. pub fn cancel_background(&self) { self.tree_cancel.store(true, Ordering::Relaxed); self.restore_cancel.store(true, Ordering::Relaxed); } pub fn drain_messages(&mut self) { while let Ok(msg) = self.rx.try_recv() { self.handle_msg(msg); } } fn handle_msg(&mut self, msg: BgMsg) { match msg { BgMsg::Snapshots(Ok(mut snaps)) => { snaps.sort_by(|a, b| b.time.cmp(&a.time)); let n = snaps.len(); self.snapshots = LoadState::Ready(snaps); self.snap_state.select(if n > 0 { Some(0) } else { None }); self.status = Some(format!("loaded {n} snapshot(s)")); self.ensure_tree(); } BgMsg::Snapshots(Err(e)) => { self.snapshots = LoadState::Failed(e); } BgMsg::Tree { generation, snapshot, result, } => { let current = self.selected_snapshot().map(|s| s.short_id.clone()); if generation != self.tree_generation || current.as_deref() != Some(snapshot.as_str()) { return; // stale result: discard } match result { Ok(lines) => { self.tree = Some((snapshot, Tree::build(&lines))); self.tree_status = TreeStatus::Idle; } Err(e) => { self.tree_status = TreeStatus::Failed(e); } } } BgMsg::Restore(result) => { self.restore_state = RestoreState::Idle; match result { Ok(msg) => self.restore_outcome = Some((true, msg)), Err(e) => self.restore_outcome = Some((false, truncate(&e, 300))), } } } } // ---- input handling --------------------------------------------------- pub fn on_key(&mut self, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.should_quit = true; return; } match self.restore_state { RestoreState::Input { .. } => { self.on_input_key(key); return; } RestoreState::Confirm { .. } => { self.on_confirm_key(key); return; } _ => {} } match key.code { KeyCode::Char('q') => self.should_quit = true, KeyCode::Tab => self.focus = self.focus.next(), KeyCode::BackTab => self.focus = self.focus.prev(), KeyCode::Esc => { self.status = None; self.restore_outcome = None; } KeyCode::Char('R') => self.refresh(), KeyCode::Char('r') => self.start_restore_prompt(), KeyCode::Char('g') => self.go_first(), KeyCode::Char('G') => self.go_last(), KeyCode::Char('j') | KeyCode::Down => self.move_selection(1), KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1), KeyCode::Char('h') | KeyCode::Left => { if let Some((_, tree)) = &mut self.tree && self.focus == Focus::Tree { tree.collapse_selected(); } } KeyCode::Char('l') | KeyCode::Right => { if let Some((_, tree)) = &mut self.tree && self.focus == Focus::Tree { tree.expand_selected(); } } KeyCode::Enter => match self.focus { Focus::Tree => { if let Some((_, tree)) = &mut self.tree { tree.toggle_selected(); } } Focus::Snapshots => self.focus = Focus::Tree, Focus::Repo => {} }, _ => {} } } pub fn on_mouse(&mut self, kind: MouseEventKind) { let delta = match kind { MouseEventKind::ScrollUp => -1, MouseEventKind::ScrollDown => 1, _ => return, }; self.move_selection(delta); } fn move_selection(&mut self, delta: i64) { match self.focus { Focus::Snapshots => { let LoadState::Ready(snaps) = &self.snapshots else { return; }; if snaps.is_empty() { return; } let cur = self.snap_state.selected().unwrap_or(0) as i64; let next = (cur + delta).clamp(0, snaps.len() as i64 - 1) as usize; if Some(next) != self.snap_state.selected() { self.snap_state.select(Some(next)); self.ensure_tree(); } } Focus::Tree => { if let Some((_, tree)) = &mut self.tree { tree.move_by(delta); } } Focus::Repo => {} } } fn go_first(&mut self) { match self.focus { Focus::Snapshots => { if self.snap_state.selected() != Some(0) { self.snap_state.select(Some(0)); self.ensure_tree(); } } Focus::Tree => { if let Some((_, tree)) = &mut self.tree { tree.first(); } } Focus::Repo => {} } } fn go_last(&mut self) { match self.focus { Focus::Snapshots => { if let LoadState::Ready(snaps) = &self.snapshots && !snaps.is_empty() { let last = snaps.len() - 1; if self.snap_state.selected() != Some(last) { self.snap_state.select(Some(last)); self.ensure_tree(); } } } Focus::Tree => { if let Some((_, tree)) = &mut self.tree { tree.last(); } } Focus::Repo => {} } } // ---- restore flow ----------------------------------------------------- fn start_restore_prompt(&mut self) { if matches!(self.restore_state, RestoreState::Running) { self.status = Some("a restore is already running".to_string()); return; } let Some(snap) = self.selected_snapshot() else { self.status = Some("no snapshot selected".to_string()); return; }; let sid = snap.short_id.clone(); let include = match self.focus { Focus::Snapshots => None, Focus::Tree => match &self.tree { Some((_, tree)) => match tree.selected_node() { Some(node) => Some(node.path.clone()), None => { self.status = Some("no file selected".to_string()); return; } }, None => { self.status = Some("files not loaded yet".to_string()); return; } }, Focus::Repo => { self.status = Some("focus Snapshots or Files to restore".to_string()); return; } }; let input = format!("~/restic-restore-{sid}"); let cursor = input.len(); self.restore_state = RestoreState::Input { input, cursor, snapshot: sid, include, }; } fn on_input_key(&mut self, key: KeyEvent) { enum Action { None, Cancel, Confirm, EmptyTarget, } let action = { let RestoreState::Input { input, cursor, .. } = &mut self.restore_state else { return; }; match key.code { KeyCode::Esc => Action::Cancel, KeyCode::Enter => { if input.trim().is_empty() { Action::EmptyTarget } else { Action::Confirm } } KeyCode::Backspace => { if *cursor > 0 { let prev = prev_char_boundary(input, *cursor); input.drain(prev..*cursor); *cursor = prev; } Action::None } KeyCode::Delete => { if *cursor < input.len() { let next = next_char_boundary(input, *cursor); input.drain(*cursor..next); } Action::None } KeyCode::Left => { *cursor = prev_char_boundary(input, *cursor); Action::None } KeyCode::Right => { *cursor = next_char_boundary(input, *cursor); Action::None } KeyCode::Home => { *cursor = 0; Action::None } KeyCode::End => { *cursor = input.len(); Action::None } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { input.clear(); *cursor = 0; Action::None } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { input.insert(*cursor, c); *cursor += c.len_utf8(); Action::None } _ => Action::None, } }; match action { Action::None => {} Action::Cancel => self.restore_state = RestoreState::Idle, Action::EmptyTarget => { self.status = Some("target directory must not be empty".to_string()); } Action::Confirm => { let old = std::mem::replace(&mut self.restore_state, RestoreState::Idle); if let RestoreState::Input { input, snapshot, include, .. } = old { self.restore_state = RestoreState::Confirm { target: input.trim().to_string(), snapshot, include, }; } } } } fn on_confirm_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { let old = std::mem::replace(&mut self.restore_state, RestoreState::Idle); if let RestoreState::Confirm { target, snapshot, include, } = old { self.spawn_restore(snapshot, target, include); } } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.restore_state = RestoreState::Idle; } _ => {} } } fn spawn_restore(&mut self, snapshot: String, target: String, include: Option) { let path = restic::expand_tilde(&target); let env = self.env.clone(); let cancel = self.restore_cancel.clone(); cancel.store(false, Ordering::Relaxed); let tx = self.tx.clone(); thread::spawn(move || { let result = restic::restore(&env, &snapshot, &path, include.as_deref(), &cancel) .map(|out| { out.lines() .find(|l| l.starts_with("Summary:")) .map(str::to_string) .unwrap_or_else(|| format!("restore finished: {}", path.display())) }) .map_err(|e| format!("{e:#}")); let _ = tx.send(BgMsg::Restore(result)); }); self.restore_state = RestoreState::Running; self.restore_outcome = None; } } fn prev_char_boundary(s: &str, cursor: usize) -> usize { s[..cursor] .char_indices() .next_back() .map(|(i, _)| i) .unwrap_or(0) } fn next_char_boundary(s: &str, cursor: usize) -> usize { s[cursor..] .char_indices() .nth(1) .map(|(i, _)| cursor + i) .unwrap_or(s.len()) } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { let mut end = max; while !s.is_char_boundary(end) { end -= 1; } format!("{}…", &s[..end]) } } #[cfg(test)] mod tests { use super::*; #[test] fn char_boundaries() { let s = "aáb"; assert_eq!(prev_char_boundary(s, 3), 1); // before 'á' (2 bytes) assert_eq!(next_char_boundary(s, 1), 3); assert_eq!(next_char_boundary(s, 3), 4); assert_eq!(prev_char_boundary(s, 0), 0); } #[test] fn truncate_respects_boundaries() { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello…"); assert_eq!(truncate("aáb", 2), "a…"); } #[test] fn focus_cycles() { assert_eq!(Focus::Snapshots.next(), Focus::Tree); assert_eq!(Focus::Tree.next(), Focus::Repo); assert_eq!(Focus::Repo.next(), Focus::Snapshots); assert_eq!(Focus::Snapshots.prev(), Focus::Repo); } }