Initial resticview implementation
This commit is contained in:
commit
16a2155d68
11 changed files with 4148 additions and 0 deletions
68
.github/workflows/release.yml
vendored
Normal file
68
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. v0.1.6)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo build --release
|
||||
- run: cargo test --release
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: resticview-macos
|
||||
path: target/release/resticview
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install cargo-deb
|
||||
- run: cargo build --release
|
||||
- run: cargo test --release
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- run: cargo deb
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: resticview-linux
|
||||
path: |
|
||||
target/release/resticview
|
||||
target/debian/*.deb
|
||||
|
||||
release:
|
||||
needs: [build-macos, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: resticview-macos
|
||||
path: artifacts/macos
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: resticview-linux
|
||||
path: artifacts/linux
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
artifacts/macos/resticview
|
||||
artifacts/linux/release/resticview
|
||||
artifacts/linux/debian/*.deb
|
||||
generate_release_notes: true
|
||||
overwrite_files: true
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
1882
Cargo.lock
generated
Normal file
1882
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "resticview"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["janvete"]
|
||||
description = "TUI browser for restic backup repositories"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/janvete/resticview"
|
||||
homepage = "https://github.com/janvete/resticview"
|
||||
keywords = ["cli", "tui", "restic", "backup"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
crossterm = "0.29.0"
|
||||
dirs = "6.0.0"
|
||||
ratatui = "0.30.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "janvete <jan.vete@example.com>"
|
||||
copyright = "2026, janvete"
|
||||
extended-description = "TUI browser for restic backup repositories."
|
||||
section = "utils"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/resticview", "usr/bin/resticview", "755"],
|
||||
["README.md", "usr/share/doc/resticview/README.md", "644"],
|
||||
]
|
||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# resticview
|
||||
|
||||
A TUI browser for restic backup repositories. Browse snapshots, explore the file tree of any snapshot, view repository stats, and restore whole snapshots or single paths on demand.
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew tap janvete/tools
|
||||
brew install resticview
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/janvete/resticview
|
||||
```
|
||||
|
||||
`restic` must be installed and on `PATH`.
|
||||
|
||||
## Usage
|
||||
|
||||
resticview reads restic credentials (`RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, …) from env files stored in `~/.restic`. It picks up every hidden file (name starting with `.`, e.g. `.env`, `.hetzner`) directly in that directory as well as inside hidden subdirectories, one level deep (e.g. `~/.restic/.work/.env`). If several env files are found, a picker is shown; with exactly one it is loaded directly.
|
||||
|
||||
```bash
|
||||
resticview # use env files from ~/.restic
|
||||
resticview --env-dir DIR # use a different env directory
|
||||
resticview --select # always show the env file picker
|
||||
```
|
||||
|
||||
The main view has three panels: snapshots (left), the file tree of the selected snapshot (middle), and repository stats (right). The focused panel has a highlighted border.
|
||||
|
||||
Restore (`r`) asks for a target directory (pre-filled with `~/restic-restore-<id>`), asks for confirmation, and then runs `restic restore` in the background. With the focus on the snapshot list the whole snapshot is restored; with the focus on the file tree only the selected path is restored (`--include`).
|
||||
|
||||
## Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `q` / `Ctrl-C` | quit |
|
||||
| `Tab` / `Shift-Tab` | cycle focus between panels |
|
||||
| `j` / `k` or `↓` / `↑` | move selection / scroll |
|
||||
| mouse wheel | scroll the focused panel |
|
||||
| `Enter` | expand/collapse directory (Files panel) |
|
||||
| `h` / `l` or `←` / `→` | collapse / expand directory |
|
||||
| `g` / `G` | jump to top / bottom |
|
||||
| `r` | restore selected snapshot or path |
|
||||
| `R` | refresh snapshots and repository stats |
|
||||
| `Esc` | close prompt / dialog, clear status |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
668
src/app.rs
Normal file
668
src/app.rs
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
//! 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, Stats};
|
||||
use crate::tree::Tree;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Snapshots,
|
||||
Tree,
|
||||
Stats,
|
||||
}
|
||||
|
||||
impl Focus {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Focus::Snapshots => Focus::Tree,
|
||||
Focus::Tree => Focus::Stats,
|
||||
Focus::Stats => Focus::Snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Focus::Snapshots => Focus::Stats,
|
||||
Focus::Tree => Focus::Snapshots,
|
||||
Focus::Stats => 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>,
|
||||
},
|
||||
Stats(Result<Stats, 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 stats: LoadState<Stats>,
|
||||
|
||||
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)),
|
||||
stats: LoadState::Loading,
|
||||
restore_state: RestoreState::Idle,
|
||||
restore_outcome: None,
|
||||
restore_cancel: Arc::new(AtomicBool::new(false)),
|
||||
status: None,
|
||||
tx,
|
||||
rx,
|
||||
};
|
||||
app.spawn_snapshots();
|
||||
app.spawn_stats();
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stats(&mut self) {
|
||||
self.stats = LoadState::Loading;
|
||||
let env = self.env.clone();
|
||||
let tx = self.tx.clone();
|
||||
thread::spawn(move || {
|
||||
let result = restic::stats(&env).map_err(|e| format!("{e:#}"));
|
||||
let _ = tx.send(BgMsg::Stats(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 and stats (`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.spawn_stats();
|
||||
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::Stats(Ok(stats)) => {
|
||||
self.stats = LoadState::Ready(stats);
|
||||
}
|
||||
BgMsg::Stats(Err(e)) => {
|
||||
self.stats = LoadState::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::Stats => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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::Stats => {}
|
||||
}
|
||||
}
|
||||
|
||||
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::Stats => {}
|
||||
}
|
||||
}
|
||||
|
||||
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::Stats => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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::Stats => {
|
||||
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::Stats);
|
||||
assert_eq!(Focus::Stats.next(), Focus::Snapshots);
|
||||
assert_eq!(Focus::Snapshots.prev(), Focus::Stats);
|
||||
}
|
||||
}
|
||||
228
src/envfile.rs
Normal file
228
src/envfile.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! Scanning and parsing of restic credential env files.
|
||||
//!
|
||||
//! Env files are plain `KEY=VALUE` files (optionally with an `export ` prefix,
|
||||
//! quotes and `#` comments). They are parsed literally — no shell evaluation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A single env file discovered in the env directory, with parsed variables.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnvFile {
|
||||
/// Path relative to the env dir, e.g. `.env` or `.work/.env`. Used for display.
|
||||
pub rel_path: String,
|
||||
/// Parsed KEY=VALUE pairs.
|
||||
pub vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl EnvFile {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.vars.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Value of RESTIC_REPOSITORY, for display in the picker and stats panel.
|
||||
pub fn repository(&self) -> &str {
|
||||
self.get("RESTIC_REPOSITORY")
|
||||
.unwrap_or("(no RESTIC_REPOSITORY)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse env file content: `KEY=VALUE` lines with an optional `export ` prefix,
|
||||
/// single/double quotes, `#` comments and empty lines. No shell evaluation.
|
||||
pub fn parse(content: &str) -> HashMap<String, String> {
|
||||
let mut vars = HashMap::new();
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line = line
|
||||
.strip_prefix("export ")
|
||||
.map(str::trim_start)
|
||||
.unwrap_or(line);
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
vars.insert(key.to_string(), parse_value(value.trim()));
|
||||
}
|
||||
vars
|
||||
}
|
||||
|
||||
/// Strip surrounding quotes; for unquoted values strip a trailing ` # comment`.
|
||||
fn parse_value(raw: &str) -> String {
|
||||
let bytes = raw.as_bytes();
|
||||
if raw.len() >= 2 {
|
||||
let (first, last) = (bytes[0], bytes[bytes.len() - 1]);
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return raw[1..raw.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
match raw.find(" #") {
|
||||
Some(i) => raw[..i].trim_end().to_string(),
|
||||
None => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden(name: &str) -> bool {
|
||||
name.starts_with('.')
|
||||
}
|
||||
|
||||
/// Scan `dir` for env files: every hidden file (`.*`) directly in `dir`, plus
|
||||
/// hidden files one level deep inside hidden subdirectories (`.*/`). Results
|
||||
/// are sorted by relative path.
|
||||
pub fn scan(dir: &Path) -> io::Result<Vec<EnvFile>> {
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_file() && is_hidden(name) {
|
||||
paths.push(entry.path());
|
||||
} else if file_type.is_dir() && is_hidden(name) {
|
||||
let Ok(sub_entries) = fs::read_dir(entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
for sub in sub_entries {
|
||||
let Ok(sub) = sub else { continue };
|
||||
let sub_name = sub.file_name();
|
||||
let Some(sub_name) = sub_name.to_str() else { continue };
|
||||
if let Ok(ft) = sub.file_type()
|
||||
&& ft.is_file()
|
||||
&& is_hidden(sub_name)
|
||||
{
|
||||
paths.push(sub.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files: Vec<EnvFile> = paths
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
let rel_path = path
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Some(EnvFile {
|
||||
rel_path,
|
||||
vars: parse(&content),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_pairs() {
|
||||
let vars = parse("RESTIC_REPOSITORY=s3:example/repo\nRESTIC_PASSWORD=secret\n");
|
||||
assert_eq!(vars["RESTIC_REPOSITORY"], "s3:example/repo");
|
||||
assert_eq!(vars["RESTIC_PASSWORD"], "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_export_prefix() {
|
||||
let vars = parse("export RESTIC_PASSWORD=secret\nexport SPACED=value\n");
|
||||
assert_eq!(vars["RESTIC_PASSWORD"], "secret");
|
||||
assert_eq!(vars["SPACED"], "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_quoted_values() {
|
||||
let vars = parse(
|
||||
"DOUBLE=\"with spaces # not a comment\"\nSINGLE='also # kept'\nEMPTY=\"\"\n",
|
||||
);
|
||||
assert_eq!(vars["DOUBLE"], "with spaces # not a comment");
|
||||
assert_eq!(vars["SINGLE"], "also # kept");
|
||||
assert_eq!(vars["EMPTY"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_comments_and_empty_lines() {
|
||||
let vars = parse("# a comment\n\n \nKEY=value # trailing comment\n");
|
||||
assert_eq!(vars.len(), 1);
|
||||
assert_eq!(vars["KEY"], "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hash_inside_unquoted_value_is_kept() {
|
||||
let vars = parse("PASS=abc#def\n");
|
||||
assert_eq!(vars["PASS"], "abc#def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_value_containing_equals() {
|
||||
let vars = parse("AWS_TOKEN=abc=def==\n");
|
||||
assert_eq!(vars["AWS_TOKEN"], "abc=def==");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_garbage_lines() {
|
||||
let vars = parse("no equals sign\n=emptykey\nOK=yes\n");
|
||||
assert_eq!(vars.len(), 1);
|
||||
assert_eq!(vars["OK"], "yes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_hidden_files_flat_and_in_hidden_subdirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".env"), "A=1").unwrap();
|
||||
fs::write(root.join(".ble"), "A=2").unwrap();
|
||||
fs::write(root.join(".hetzner"), "A=3").unwrap();
|
||||
fs::write(root.join("backup.sh"), "A=4").unwrap(); // non-hidden: excluded
|
||||
fs::write(root.join("backup.log"), "A=5").unwrap(); // non-hidden: excluded
|
||||
fs::write(root.join("excludes.txt"), "A=6").unwrap(); // non-hidden: excluded
|
||||
|
||||
// hidden subdir, one level deep: included
|
||||
let work = root.join(".work");
|
||||
fs::create_dir(&work).unwrap();
|
||||
fs::write(work.join(".env"), "B=1").unwrap();
|
||||
fs::write(work.join(".token"), "B=2").unwrap();
|
||||
fs::write(work.join("notes.txt"), "B=3").unwrap(); // non-hidden: excluded
|
||||
|
||||
// visible subdir: excluded
|
||||
let visible = root.join("sub");
|
||||
fs::create_dir(&visible).unwrap();
|
||||
fs::write(visible.join(".env"), "C=1").unwrap();
|
||||
|
||||
// two levels deep: excluded
|
||||
let deep = work.join(".deep");
|
||||
fs::create_dir(&deep).unwrap();
|
||||
fs::write(deep.join(".env"), "D=1").unwrap();
|
||||
|
||||
let files = scan(root).unwrap();
|
||||
let rels: Vec<&str> = files.iter().map(|f| f.rel_path.as_str()).collect();
|
||||
assert_eq!(
|
||||
rels,
|
||||
vec![".ble", ".env", ".hetzner", ".work/.env", ".work/.token"]
|
||||
);
|
||||
assert_eq!(files[1].vars["A"], "1");
|
||||
assert_eq!(files[3].vars["B"], "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_helper() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join(".env"), "RESTIC_REPOSITORY=/backup\n").unwrap();
|
||||
let files = scan(tmp.path()).unwrap();
|
||||
assert_eq!(files[0].repository(), "/backup");
|
||||
|
||||
fs::write(tmp.path().join(".env"), "OTHER=1\n").unwrap();
|
||||
let files = scan(tmp.path()).unwrap();
|
||||
assert_eq!(files[0].repository(), "(no RESTIC_REPOSITORY)");
|
||||
}
|
||||
}
|
||||
211
src/main.rs
Normal file
211
src/main.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! resticview — a TUI browser for restic backup repositories.
|
||||
|
||||
mod app;
|
||||
mod envfile;
|
||||
mod restic;
|
||||
mod tree;
|
||||
mod ui;
|
||||
|
||||
use std::io::{self, stdout};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crossterm::event::{
|
||||
self, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, List, ListItem, ListState};
|
||||
use ratatui::{Terminal, layout::Layout};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::envfile::EnvFile;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "resticview",
|
||||
version,
|
||||
about = "TUI browser for restic backup repositories"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Directory containing restic env files (hidden dotfiles, also in hidden subdirs)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
env_dir: Option<PathBuf>,
|
||||
|
||||
/// Show the env file picker even if only one env file exists
|
||||
#[arg(long)]
|
||||
select: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let env_dir = cli.env_dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.expect("could not determine home directory")
|
||||
.join(".restic")
|
||||
});
|
||||
let files = envfile::scan(&env_dir)
|
||||
.with_context(|| format!("failed to read env dir {}", env_dir.display()))?;
|
||||
if files.is_empty() {
|
||||
eprintln!(
|
||||
"resticview: no env files found in {} (expected hidden files like .env, also inside hidden subdirectories)",
|
||||
env_dir.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let selected = if files.len() == 1 && !cli.select {
|
||||
Some(0)
|
||||
} else {
|
||||
run_picker(&files)?
|
||||
};
|
||||
let Some(idx) = selected else {
|
||||
return Ok(()); // aborted in the picker
|
||||
};
|
||||
let env_file = files.into_iter().nth(idx).expect("index from picker");
|
||||
if env_file.vars.is_empty() {
|
||||
eprintln!(
|
||||
"resticview: {} contains no KEY=VALUE pairs",
|
||||
env_file.rel_path
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
run_tui(env_file)
|
||||
}
|
||||
|
||||
/// Restore the terminal on drop, whatever happens.
|
||||
struct TerminalGuard;
|
||||
|
||||
impl TerminalGuard {
|
||||
fn new() -> io::Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture);
|
||||
}
|
||||
}
|
||||
|
||||
/// Env file picker: shown when multiple env files exist (or with --select).
|
||||
/// Returns the chosen index, or None when aborted.
|
||||
fn run_picker(files: &[EnvFile]) -> Result<Option<usize>> {
|
||||
let _guard = TerminalGuard::new()?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
let mut state = ListState::default().with_selected(Some(0));
|
||||
let len = files.len();
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(1)])
|
||||
.split(frame.area());
|
||||
let items: Vec<ListItem> = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
f.rel_path.clone(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
f.get("RESTIC_REPOSITORY").unwrap_or("").to_string(),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::bordered().title("Select repository"))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("> ");
|
||||
frame.render_stateful_widget(list, chunks[0], &mut state);
|
||||
frame.render_widget(
|
||||
ratatui::widgets::Paragraph::new("Enter select · q/Esc quit")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
chunks[1],
|
||||
);
|
||||
})?;
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
let cur = state.selected().unwrap_or(0);
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
|
||||
KeyCode::Enter => return Ok(state.selected()),
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
state.select(Some((cur + 1).min(len - 1)));
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
state.select(Some(cur.saturating_sub(1)));
|
||||
}
|
||||
KeyCode::Char('g') => state.select(Some(0)),
|
||||
KeyCode::Char('G') => state.select(Some(len - 1)),
|
||||
KeyCode::Char('c')
|
||||
if key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
return Ok(None)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
let cur = state.selected().unwrap_or(0);
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
state.select(Some(cur.saturating_sub(1)));
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
state.select(Some((cur + 1).min(len - 1)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_tui(env_file: EnvFile) -> Result<()> {
|
||||
let _guard = TerminalGuard::new()?;
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
terminal.clear().context("failed to clear terminal")?;
|
||||
let mut app = App::new(env_file);
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| ui::draw(frame, &mut app))
|
||||
.context("failed to draw")?;
|
||||
app.drain_messages();
|
||||
if event::poll(Duration::from_millis(80))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => app.on_key(key),
|
||||
Event::Mouse(mouse) => app.on_mouse(mouse.kind),
|
||||
Event::Resize(_, _) => {} // next draw picks up the new size
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
app.tick = app.tick.wrapping_add(1);
|
||||
if app.should_quit {
|
||||
app.cancel_background();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
272
src/restic.rs
Normal file
272
src/restic.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
//! Blocking wrappers around the restic CLI.
|
||||
//!
|
||||
//! All functions run restic as a child process with the parsed env-file
|
||||
//! variables, capture stderr and surface it in the error message. They are
|
||||
//! meant to be called from background threads.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// One entry of `restic snapshots --json`.
|
||||
///
|
||||
/// `short_id` is unique within a repository and accepted by all restic
|
||||
/// commands, so it is used both for display and as the command argument.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub short_id: String,
|
||||
pub time: String,
|
||||
}
|
||||
|
||||
/// One line of `restic ls <id> --json` (either the snapshot header or a node).
|
||||
/// Node names are derived from the path, so `name` is not modelled.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LsLine {
|
||||
pub struct_type: String,
|
||||
#[serde(rename = "type", default)]
|
||||
pub node_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
impl LsLine {
|
||||
pub fn is_node(&self) -> bool {
|
||||
self.struct_type == "node"
|
||||
}
|
||||
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.node_type.as_deref() == Some("dir")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of `restic stats --json` (default mode).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Stats {
|
||||
pub total_size: u64,
|
||||
pub total_file_count: u64,
|
||||
}
|
||||
|
||||
fn base_command(env: &HashMap<String, String>) -> Command {
|
||||
let mut cmd = Command::new("restic");
|
||||
cmd.envs(env);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn stderr_error(stderr: &[u8]) -> anyhow::Error {
|
||||
let text = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if text.is_empty() {
|
||||
anyhow!("restic failed without an error message")
|
||||
} else {
|
||||
anyhow!("{text}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `restic snapshots --json`, sorted newest first.
|
||||
pub fn snapshots(env: &HashMap<String, String>) -> Result<Vec<Snapshot>> {
|
||||
let out = base_command(env)
|
||||
.args(["snapshots", "--json"])
|
||||
.output()
|
||||
.context("failed to run restic (is it installed and on PATH?)")?;
|
||||
if !out.status.success() {
|
||||
return Err(stderr_error(&out.stderr));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut parsed: Option<Vec<Snapshot>> =
|
||||
serde_json::from_str(text).context("failed to parse restic snapshots output")?;
|
||||
let mut snaps = parsed.take().unwrap_or_default();
|
||||
snaps.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
Ok(snaps)
|
||||
}
|
||||
|
||||
/// `restic ls <snapshot> --json`, streamed line by line. When `cancel` is set,
|
||||
/// the child process is killed and an error is returned.
|
||||
pub fn ls(env: &HashMap<String, String>, snapshot: &str, cancel: &AtomicBool) -> Result<Vec<LsLine>> {
|
||||
let mut child = base_command(env)
|
||||
.args(["ls", snapshot, "--json"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to run restic (is it installed and on PATH?)")?;
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(anyhow!("cancelled"));
|
||||
}
|
||||
match lines.next() {
|
||||
Some(Ok(line)) => {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: LsLine = serde_json::from_str(&line)
|
||||
.context("failed to parse restic ls output")?;
|
||||
out.push(parsed);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e).context("failed to read restic ls output");
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let status = child.wait().context("failed to wait for restic")?;
|
||||
if !status.success() {
|
||||
let mut err = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut err);
|
||||
}
|
||||
return Err(stderr_error(err.as_bytes()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `restic stats --json` (default mode).
|
||||
pub fn stats(env: &HashMap<String, String>) -> Result<Stats> {
|
||||
let out = base_command(env)
|
||||
.args(["stats", "--json"])
|
||||
.output()
|
||||
.context("failed to run restic (is it installed and on PATH?)")?;
|
||||
if !out.status.success() {
|
||||
return Err(stderr_error(&out.stderr));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
serde_json::from_str(text.trim()).context("failed to parse restic stats output")
|
||||
}
|
||||
|
||||
/// `restic restore <snapshot> --target <dir> [--include <path>]`.
|
||||
///
|
||||
/// Polls the child so a running restore can be cancelled via `cancel`
|
||||
/// (the child is killed). Returns restic's stdout on success.
|
||||
pub fn restore(
|
||||
env: &HashMap<String, String>,
|
||||
snapshot: &str,
|
||||
target: &Path,
|
||||
include: Option<&str>,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<String> {
|
||||
let mut cmd = base_command(env);
|
||||
cmd.arg("restore")
|
||||
.arg(snapshot)
|
||||
.arg("--target")
|
||||
.arg(target);
|
||||
if let Some(include) = include {
|
||||
cmd.arg("--include").arg(include);
|
||||
}
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to run restic (is it installed and on PATH?)")?;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().context("failed to wait for restic")? {
|
||||
break status;
|
||||
}
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(anyhow!("restore cancelled"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
let _ = out.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let _ = err.read_to_string(&mut stderr);
|
||||
}
|
||||
if !status.success() {
|
||||
return Err(stderr_error(stderr.as_bytes()));
|
||||
}
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
/// Expand a leading `~/` (or bare `~`) to the user's home directory.
|
||||
pub fn expand_tilde(path: &str) -> std::path::PathBuf {
|
||||
let home = dirs::home_dir();
|
||||
match (home, path.strip_prefix("~/")) {
|
||||
(Some(home), Some(rest)) => home.join(rest),
|
||||
(Some(home), None) if path == "~" => home,
|
||||
_ => std::path::PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_snapshot_json() {
|
||||
// Real restic output carries many more fields; serde ignores them.
|
||||
let json = r#"[{"time":"2026-08-15T21:02:18.148309+02:00","paths":["/Users/seth"],"hostname":"mac.local","username":"seth","id":"46dd2015bcdb","short_id":"46dd2015"}]"#;
|
||||
let snaps: Vec<Snapshot> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(snaps[0].short_id, "46dd2015");
|
||||
assert_eq!(snaps[0].time, "2026-08-15T21:02:18.148309+02:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_lines() {
|
||||
let header: LsLine = serde_json::from_str(
|
||||
r#"{"time":"2026-08-15T21:02:18.148309+02:00","id":"x","short_id":"y","struct_type":"snapshot"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!header.is_node());
|
||||
|
||||
let dir: LsLine = serde_json::from_str(
|
||||
r#"{"name":"Users","type":"dir","path":"/Users","uid":0,"struct_type":"node"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(dir.is_node());
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(dir.path.as_deref(), Some("/Users"));
|
||||
assert_eq!(dir.size, None);
|
||||
|
||||
let file: LsLine = serde_json::from_str(
|
||||
r#"{"name":".zshrc","type":"file","path":"/Users/seth/.zshrc","size":1234,"struct_type":"node"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(file.is_node());
|
||||
assert!(!file.is_dir());
|
||||
assert_eq!(file.size, Some(1234));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stats_json() {
|
||||
// `snapshots_count` (present since restic 0.19) is ignored.
|
||||
let stats: Stats = serde_json::from_str(
|
||||
r#"{"total_size":16942067259,"total_file_count":294549,"snapshots_count":6}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stats.total_size, 16942067259);
|
||||
assert_eq!(stats.total_file_count, 294549);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expansion() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
|
||||
assert_eq!(expand_tilde("~"), home);
|
||||
assert_eq!(expand_tilde("/abs/path"), std::path::PathBuf::from("/abs/path"));
|
||||
assert_eq!(
|
||||
expand_tilde("relative/path"),
|
||||
std::path::PathBuf::from("relative/path")
|
||||
);
|
||||
}
|
||||
}
|
||||
333
src/tree.rs
Normal file
333
src/tree.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
//! Folder tree built from `restic ls <id> --json` node lines.
|
||||
//!
|
||||
//! Nodes live in a flat arena (`Vec<Node>`, index 0 is the virtual root) and
|
||||
//! reference each other by index, which keeps expand/collapse and navigation
|
||||
//! free of borrow checker gymnastics. `visible` is the flattened list of rows
|
||||
//! (node index + depth) currently shown; it is rebuilt whenever the expansion
|
||||
//! state changes, never per frame.
|
||||
|
||||
use crate::restic::LsLine;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Node {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: Option<u64>,
|
||||
pub expanded: bool,
|
||||
pub parent: Option<usize>,
|
||||
pub children: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Tree {
|
||||
pub nodes: Vec<Node>,
|
||||
/// Flattened visible rows: (node index, depth). Root itself is not shown.
|
||||
pub visible: Vec<(usize, usize)>,
|
||||
/// Index into `visible`.
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
pub fn build(lines: &[LsLine]) -> Tree {
|
||||
let mut tree = Tree {
|
||||
nodes: vec![Node {
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
is_dir: true,
|
||||
size: None,
|
||||
expanded: true,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
}],
|
||||
visible: Vec::new(),
|
||||
selected: 0,
|
||||
};
|
||||
for line in lines {
|
||||
if !line.is_node() {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = &line.path else { continue };
|
||||
tree.insert(path, line.is_dir(), line.size);
|
||||
}
|
||||
sort_recursive(&mut tree.nodes, 0);
|
||||
tree.rebuild_visible();
|
||||
tree
|
||||
}
|
||||
|
||||
fn insert(&mut self, path: &str, is_dir: bool, size: Option<u64>) {
|
||||
let comps: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
|
||||
if comps.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut current = 0usize;
|
||||
let mut current_path = String::new();
|
||||
for (i, comp) in comps.iter().enumerate() {
|
||||
current_path.push('/');
|
||||
current_path.push_str(comp);
|
||||
let last = i + 1 == comps.len();
|
||||
let next = match find_child(&self.nodes, current, comp) {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
let idx = self.nodes.len();
|
||||
self.nodes.push(Node {
|
||||
name: comp.to_string(),
|
||||
path: current_path.clone(),
|
||||
is_dir: if last { is_dir } else { true },
|
||||
size: None,
|
||||
expanded: false,
|
||||
parent: Some(current),
|
||||
children: Vec::new(),
|
||||
});
|
||||
self.nodes[current].children.push(idx);
|
||||
idx
|
||||
}
|
||||
};
|
||||
if last {
|
||||
self.nodes[next].is_dir = is_dir;
|
||||
self.nodes[next].size = size;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rebuild_visible(&mut self) {
|
||||
self.visible.clear();
|
||||
let children = self.nodes[0].children.clone();
|
||||
for child in children {
|
||||
self.push_visible(child, 0);
|
||||
}
|
||||
if self.visible.is_empty() {
|
||||
self.selected = 0;
|
||||
} else if self.selected >= self.visible.len() {
|
||||
self.selected = self.visible.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn push_visible(&mut self, idx: usize, depth: usize) {
|
||||
self.visible.push((idx, depth));
|
||||
if self.nodes[idx].expanded {
|
||||
let children = self.nodes[idx].children.clone();
|
||||
for child in children {
|
||||
self.push_visible(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_by(&mut self, delta: i64) {
|
||||
if self.visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = (self.selected as i64 + delta).clamp(0, self.visible.len() as i64 - 1);
|
||||
self.selected = next as usize;
|
||||
}
|
||||
|
||||
pub fn first(&mut self) {
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn last(&mut self) {
|
||||
if !self.visible.is_empty() {
|
||||
self.selected = self.visible.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_idx(&self) -> Option<usize> {
|
||||
self.visible.get(self.selected).map(|&(idx, _)| idx)
|
||||
}
|
||||
|
||||
pub fn selected_node(&self) -> Option<&Node> {
|
||||
self.selected_idx().map(|idx| &self.nodes[idx])
|
||||
}
|
||||
|
||||
pub fn toggle_selected(&mut self) {
|
||||
if let Some(idx) = self.selected_idx()
|
||||
&& self.nodes[idx].is_dir
|
||||
{
|
||||
self.nodes[idx].expanded = !self.nodes[idx].expanded;
|
||||
self.rebuild_visible();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_selected(&mut self) {
|
||||
if let Some(idx) = self.selected_idx()
|
||||
&& self.nodes[idx].is_dir
|
||||
&& !self.nodes[idx].expanded
|
||||
{
|
||||
self.nodes[idx].expanded = true;
|
||||
self.rebuild_visible();
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse the selected dir, or jump to the parent node when the
|
||||
/// selection is a file or an already-collapsed dir.
|
||||
pub fn collapse_selected(&mut self) {
|
||||
let Some(idx) = self.selected_idx() else {
|
||||
return;
|
||||
};
|
||||
if self.nodes[idx].is_dir && self.nodes[idx].expanded {
|
||||
self.nodes[idx].expanded = false;
|
||||
self.rebuild_visible();
|
||||
} else if let Some(parent) = self.nodes[idx].parent
|
||||
&& parent != 0
|
||||
&& let Some(pos) = self.visible.iter().position(|&(i, _)| i == parent)
|
||||
{
|
||||
self.selected = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_child(nodes: &[Node], parent: usize, name: &str) -> Option<usize> {
|
||||
nodes[parent]
|
||||
.children
|
||||
.iter()
|
||||
.find(|&&idx| nodes[idx].name == name)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Sort children recursively: directories first, then by name (case-insensitive).
|
||||
fn sort_recursive(nodes: &mut [Node], idx: usize) {
|
||||
let mut children = std::mem::take(&mut nodes[idx].children);
|
||||
children.sort_by(|&a, &b| {
|
||||
(!nodes[a].is_dir)
|
||||
.cmp(&(!nodes[b].is_dir))
|
||||
.then_with(|| nodes[a].name.to_lowercase().cmp(&nodes[b].name.to_lowercase()))
|
||||
});
|
||||
for &child in &children {
|
||||
sort_recursive(nodes, child);
|
||||
}
|
||||
nodes[idx].children = children;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn node(_name: &str, node_type: &str, path: &str, size: Option<u64>) -> LsLine {
|
||||
LsLine {
|
||||
struct_type: "node".to_string(),
|
||||
node_type: Some(node_type.to_string()),
|
||||
path: Some(path.to_string()),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> LsLine {
|
||||
LsLine {
|
||||
struct_type: "snapshot".to_string(),
|
||||
node_type: None,
|
||||
path: None,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_lines() -> Vec<LsLine> {
|
||||
vec![
|
||||
header(),
|
||||
node("Users", "dir", "/Users", None),
|
||||
node("seth", "dir", "/Users/seth", None),
|
||||
node(".zshrc", "file", "/Users/seth/.zshrc", Some(1234)),
|
||||
node("Documents", "dir", "/Users/seth/Documents", None),
|
||||
node("a.txt", "file", "/Users/seth/Documents/a.txt", Some(10)),
|
||||
node("b.txt", "file", "/Users/seth/Documents/b.txt", Some(20)),
|
||||
node("etc", "dir", "/etc", None),
|
||||
node("hosts", "file", "/etc/hosts", Some(100)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_tree_from_ls_lines() {
|
||||
let tree = Tree::build(&sample_lines());
|
||||
// root children: /etc and /Users (dirs first, alphabetical)
|
||||
let root_children = &tree.nodes[0].children;
|
||||
assert_eq!(root_children.len(), 2);
|
||||
assert_eq!(tree.nodes[root_children[0]].name, "etc");
|
||||
assert_eq!(tree.nodes[root_children[1]].name, "Users");
|
||||
|
||||
let users = root_children[1];
|
||||
assert!(tree.nodes[users].is_dir);
|
||||
let seth = tree.nodes[users].children[0];
|
||||
assert_eq!(tree.nodes[seth].name, "seth");
|
||||
// seth children: Documents (dir) before .zshrc (file)
|
||||
let seth_children = &tree.nodes[seth].children;
|
||||
assert_eq!(tree.nodes[seth_children[0]].name, "Documents");
|
||||
assert_eq!(tree.nodes[seth_children[1]].name, ".zshrc");
|
||||
assert_eq!(tree.nodes[seth_children[1]].size, Some(1234));
|
||||
assert!(!tree.nodes[seth_children[1]].is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_implicit_intermediate_dirs() {
|
||||
let lines = vec![node("deep.txt", "file", "/a/b/c/deep.txt", Some(1))];
|
||||
let tree = Tree::build(&lines);
|
||||
let a = tree.nodes[0].children[0];
|
||||
assert!(tree.nodes[a].is_dir);
|
||||
let b = tree.nodes[a].children[0];
|
||||
assert_eq!(tree.nodes[b].path, "/a/b");
|
||||
assert!(tree.nodes[b].is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_rows_follow_expansion() {
|
||||
let mut tree = Tree::build(&sample_lines());
|
||||
// initially only root children are visible
|
||||
let names: Vec<&str> = tree
|
||||
.visible
|
||||
.iter()
|
||||
.map(|&(idx, _)| tree.nodes[idx].name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["etc", "Users"]);
|
||||
|
||||
// expand /Users
|
||||
tree.selected = 1;
|
||||
tree.expand_selected();
|
||||
let names: Vec<&str> = tree
|
||||
.visible
|
||||
.iter()
|
||||
.map(|&(idx, _)| tree.nodes[idx].name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["etc", "Users", "seth"]);
|
||||
let depths: Vec<usize> = tree.visible.iter().map(|&(_, d)| d).collect();
|
||||
assert_eq!(depths, vec![0, 0, 1]);
|
||||
|
||||
// collapse /Users again via toggle
|
||||
tree.toggle_selected();
|
||||
assert_eq!(tree.visible.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_jumps_to_parent() {
|
||||
let mut tree = Tree::build(&sample_lines());
|
||||
tree.selected = 1; // Users
|
||||
tree.expand_selected();
|
||||
tree.selected = 2; // seth (collapsed dir) -> collapse jumps to parent Users
|
||||
tree.collapse_selected();
|
||||
assert_eq!(tree.selected, 1);
|
||||
assert_eq!(tree.selected_node().unwrap().name, "Users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_is_clamped() {
|
||||
let mut tree = Tree::build(&sample_lines());
|
||||
tree.move_by(-5);
|
||||
assert_eq!(tree.selected, 0);
|
||||
tree.move_by(100);
|
||||
assert_eq!(tree.selected, tree.visible.len() - 1);
|
||||
tree.last();
|
||||
assert_eq!(tree.selected, 1);
|
||||
tree.first();
|
||||
assert_eq!(tree.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_snapshot_has_no_rows() {
|
||||
let lines = vec![header()];
|
||||
let mut tree = Tree::build(&lines);
|
||||
assert!(tree.visible.is_empty());
|
||||
assert!(tree.selected_node().is_none());
|
||||
// navigation on empty tree must not panic
|
||||
tree.move_by(1);
|
||||
tree.toggle_selected();
|
||||
tree.collapse_selected();
|
||||
}
|
||||
}
|
||||
398
src/ui.rs
Normal file
398
src/ui.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
//! Ratatui rendering: three side-by-side panels, status line, key hints and
|
||||
//! the restore prompt/confirmation popups.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::app::{App, Focus, LoadState, RestoreState, TreeStatus};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
let area = frame.area();
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(45),
|
||||
Constraint::Percentage(30),
|
||||
])
|
||||
.split(chunks[0]);
|
||||
|
||||
draw_snapshots(frame, app, cols[0]);
|
||||
draw_tree(frame, app, cols[1]);
|
||||
draw_stats(frame, app, cols[2]);
|
||||
draw_status(frame, app, chunks[1]);
|
||||
draw_footer(frame, chunks[2]);
|
||||
|
||||
match &app.restore_state {
|
||||
RestoreState::Input { .. } => draw_input_popup(frame, app),
|
||||
RestoreState::Confirm { .. } => draw_confirm_popup(frame, app),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_block(title: String, focused: bool) -> Block<'static> {
|
||||
let style = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Block::bordered().title(title).border_style(style)
|
||||
}
|
||||
|
||||
fn placeholder(block: Block<'static>, text: String, style: Style) -> Paragraph<'static> {
|
||||
Paragraph::new(text).block(block).style(style)
|
||||
}
|
||||
|
||||
fn draw_snapshots(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == Focus::Snapshots;
|
||||
match &app.snapshots {
|
||||
LoadState::Loading => {
|
||||
let block = panel_block("Snapshots".to_string(), focused);
|
||||
let text = format!("{} loading snapshots…", app.spinner());
|
||||
frame.render_widget(placeholder(block, text, Style::default().fg(Color::Gray)), area);
|
||||
}
|
||||
LoadState::Failed(e) => {
|
||||
let block = panel_block("Snapshots — error".to_string(), focused);
|
||||
let text = format!("{e}\n\npress R to retry");
|
||||
frame.render_widget(
|
||||
placeholder(block, text, Style::default().fg(Color::Red)).wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
LoadState::Ready(snaps) => {
|
||||
let block = panel_block(format!("Snapshots ({})", snaps.len()), focused);
|
||||
if snaps.is_empty() {
|
||||
frame.render_widget(
|
||||
placeholder(block, "no snapshots".to_string(), Style::default().fg(Color::Gray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let items: Vec<ListItem> = snaps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
s.short_id.clone(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format_time(&s.time), Style::default().fg(Color::Gray)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items).block(block).highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
frame.render_stateful_widget(list, area, &mut app.snap_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tree(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
let focused = app.focus == Focus::Tree;
|
||||
let title = match &app.tree {
|
||||
Some((sid, _)) => format!("Files — {sid}"),
|
||||
None => "Files".to_string(),
|
||||
};
|
||||
let block = panel_block(title, focused);
|
||||
match &app.tree_status {
|
||||
TreeStatus::Loading => {
|
||||
let text = format!("{} loading files…", app.spinner());
|
||||
frame.render_widget(placeholder(block, text, Style::default().fg(Color::Gray)), area);
|
||||
}
|
||||
TreeStatus::Failed(e) => {
|
||||
let text = format!("{e}\n\npress R to retry");
|
||||
frame.render_widget(
|
||||
placeholder(block, text, Style::default().fg(Color::Red)).wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
TreeStatus::Idle => match &app.tree {
|
||||
None => {
|
||||
frame.render_widget(
|
||||
placeholder(
|
||||
block,
|
||||
"no snapshot selected".to_string(),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
Some((_, tree)) if tree.visible.is_empty() => {
|
||||
frame.render_widget(
|
||||
placeholder(block, "empty snapshot".to_string(), Style::default().fg(Color::Gray)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
Some((_, tree)) => {
|
||||
let items: Vec<ListItem> = tree
|
||||
.visible
|
||||
.iter()
|
||||
.map(|&(idx, depth)| {
|
||||
let node = &tree.nodes[idx];
|
||||
let indent = " ".repeat(depth);
|
||||
let icon = if node.is_dir {
|
||||
if node.expanded { "▾ " } else { "▸ " }
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let name_style = if node.is_dir {
|
||||
Style::default().fg(Color::Blue)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let name = if node.is_dir {
|
||||
format!("{}/", node.name)
|
||||
} else {
|
||||
node.name.clone()
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::raw(format!("{indent}{icon}")),
|
||||
Span::styled(name, name_style),
|
||||
];
|
||||
if let Some(size) = node.size
|
||||
&& !node.is_dir
|
||||
{
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", human_size(size)),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
let mut state = ListState::default().with_selected(Some(tree.selected));
|
||||
let list = List::new(items).block(block).highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_stats(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let focused = app.focus == Focus::Stats;
|
||||
let block = panel_block("Repository".to_string(), focused);
|
||||
let label = Style::default().fg(Color::Gray);
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Repository: ", label),
|
||||
Span::raw(app.repo.clone()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Env file: ", label),
|
||||
Span::raw(app.env_rel_path.clone()),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
match &app.snapshots {
|
||||
LoadState::Ready(snaps) => lines.push(Line::from(vec![
|
||||
Span::styled("Snapshots: ", label),
|
||||
Span::raw(snaps.len().to_string()),
|
||||
])),
|
||||
LoadState::Loading => lines.push(Line::from(vec![
|
||||
Span::styled("Snapshots: ", label),
|
||||
Span::raw(format!("{} loading…", app.spinner())),
|
||||
])),
|
||||
LoadState::Failed(_) => lines.push(Line::from(vec![
|
||||
Span::styled("Snapshots: ", label),
|
||||
Span::styled("error", Style::default().fg(Color::Red)),
|
||||
])),
|
||||
}
|
||||
match &app.stats {
|
||||
LoadState::Loading => lines.push(Line::from(vec![
|
||||
Span::styled("Stats: ", label),
|
||||
Span::raw(format!("{} loading…", app.spinner())),
|
||||
])),
|
||||
LoadState::Failed(e) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Stats: ", label),
|
||||
Span::styled("error", Style::default().fg(Color::Red)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
e.clone(),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
LoadState::Ready(stats) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Total size: ", label),
|
||||
Span::raw(human_size(stats.total_size)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Files: ", label),
|
||||
Span::raw(format_count(stats.total_file_count)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(block).wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let (text, style) = if matches!(app.restore_state, RestoreState::Running) {
|
||||
(
|
||||
format!("{} restore running…", app.spinner()),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)
|
||||
} else if let Some((ok, msg)) = &app.restore_outcome {
|
||||
let style = if *ok {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
(msg.clone(), style)
|
||||
} else if let Some(status) = &app.status {
|
||||
(status.clone(), Style::default().fg(Color::Gray))
|
||||
} else {
|
||||
(String::new(), Style::default())
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text).style(style), area);
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame, area: Rect) {
|
||||
let hints = "q quit · Tab focus · j/k/↑/↓ scroll · Enter/h/l tree · r restore · R refresh · g/G top/bottom";
|
||||
frame.render_widget(
|
||||
Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
Rect {
|
||||
x: area.x + area.width.saturating_sub(width) / 2,
|
||||
y: area.y + area.height.saturating_sub(height) / 2,
|
||||
width: width.min(area.width),
|
||||
height: height.min(area.height),
|
||||
}
|
||||
}
|
||||
|
||||
fn popup_block(title: &str) -> Block<'static> {
|
||||
Block::bordered()
|
||||
.title(title.to_string())
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
|
||||
fn draw_input_popup(frame: &mut Frame, app: &App) {
|
||||
let RestoreState::Input { input, cursor, .. } = &app.restore_state else {
|
||||
return;
|
||||
};
|
||||
let area = frame.area();
|
||||
let popup = centered_rect(60.min(area.width.saturating_sub(4)), 3, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = popup_block("Restore to directory");
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
frame.render_widget(Paragraph::new(input.as_str()), inner);
|
||||
let cursor_x = inner.x + (*cursor as u16).min(inner.width.saturating_sub(1));
|
||||
frame.set_cursor_position((cursor_x, inner.y));
|
||||
}
|
||||
|
||||
fn draw_confirm_popup(frame: &mut Frame, app: &App) {
|
||||
let RestoreState::Confirm {
|
||||
target,
|
||||
snapshot,
|
||||
include,
|
||||
} = &app.restore_state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let area = frame.area();
|
||||
let popup = centered_rect(70.min(area.width.saturating_sub(4)), 8, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let block = popup_block("Confirm restore");
|
||||
let mut lines = vec![Line::from(format!("Snapshot: {snapshot}"))];
|
||||
if let Some(include) = include {
|
||||
lines.push(Line::from(format!("Path: {include}")));
|
||||
}
|
||||
lines.push(Line::from(format!("Target: {target}")));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("[y]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" restore "),
|
||||
Span::styled("[n]/Esc", Style::default().fg(Color::Red)),
|
||||
Span::raw(" cancel"),
|
||||
]));
|
||||
frame.render_widget(Paragraph::new(lines).block(block), popup);
|
||||
}
|
||||
|
||||
/// "2026-08-15T21:02:18.148309+02:00" -> "2026-08-15 21:02:18"
|
||||
pub fn format_time(raw: &str) -> String {
|
||||
if raw.len() >= 19 {
|
||||
format!("{} {}", &raw[..10], &raw[11..19])
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn human_size(bytes: u64) -> String {
|
||||
const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
|
||||
pub fn format_count(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn time_formatting() {
|
||||
assert_eq!(
|
||||
format_time("2026-08-15T21:02:18.148309+02:00"),
|
||||
"2026-08-15 21:02:18"
|
||||
);
|
||||
assert_eq!(format_time("short"), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_sizes() {
|
||||
assert_eq!(human_size(0), "0 B");
|
||||
assert_eq!(human_size(512), "512 B");
|
||||
assert_eq!(human_size(1024), "1.0 KiB");
|
||||
assert_eq!(human_size(16942067259), "15.8 GiB");
|
||||
assert_eq!(human_size(3158099665), "2.9 GiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_formatting() {
|
||||
assert_eq!(format_count(294549), "294,549");
|
||||
assert_eq!(format_count(6), "6");
|
||||
assert_eq!(format_count(1000), "1,000");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue