646 lines
20 KiB
Rust
646 lines
20 KiB
Rust
//! 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<T> {
|
|
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<String>,
|
|
},
|
|
Confirm {
|
|
target: String,
|
|
snapshot: String,
|
|
include: Option<String>,
|
|
},
|
|
Running,
|
|
}
|
|
|
|
/// Messages sent from background threads to the UI loop.
|
|
pub enum BgMsg {
|
|
Snapshots(Result<Vec<Snapshot>, String>),
|
|
Tree {
|
|
generation: u64,
|
|
snapshot: String,
|
|
result: Result<Vec<LsLine>, String>,
|
|
},
|
|
Restore(Result<String, String>),
|
|
}
|
|
|
|
const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
|
|
pub struct App {
|
|
env: Arc<HashMap<String, String>>,
|
|
/// 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<Vec<Snapshot>>,
|
|
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<String, Tree>,
|
|
tree_generation: u64,
|
|
tree_cancel: Arc<AtomicBool>,
|
|
|
|
pub restore_state: RestoreState,
|
|
/// Last finished restore: (success, message).
|
|
pub restore_outcome: Option<(bool, String)>,
|
|
restore_cancel: Arc<AtomicBool>,
|
|
|
|
/// Transient status-line message.
|
|
pub status: Option<String>,
|
|
|
|
tx: Sender<BgMsg>,
|
|
rx: Receiver<BgMsg>,
|
|
}
|
|
|
|
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<String>) {
|
|
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);
|
|
}
|
|
}
|