resticview/src/app.rs

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);
}
}