Initial resticview implementation

This commit is contained in:
SW 2026-08-16 19:30:55 +02:00
commit 16a2155d68
11 changed files with 4148 additions and 0 deletions

68
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -0,0 +1 @@
/target

1882
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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");
}
}