diff --git a/README.md b/README.md index 198c565..5fa0560 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. +A TUI browser for restic backup repositories. Browse snapshots, explore the file tree of any snapshot, and restore whole snapshots or single paths on demand. ## Installation @@ -48,7 +48,7 @@ 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. +The main view has three panels: snapshots (left), the file tree of the selected snapshot (middle), and repository / snapshot info (right). The right panel shows the repository name, env file, total snapshot count, and details of the selected snapshot (time, host, user, paths, tags). The focused panel has a highlighted border. Restore (`r`) asks for a target directory (pre-filled with `~/restic-restore-`), 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`). @@ -64,7 +64,7 @@ Restore (`r`) asks for a target directory (pre-filled with `~/restic-restore- Self { match self { Focus::Snapshots => Focus::Tree, - Focus::Tree => Focus::Stats, - Focus::Stats => Focus::Snapshots, + Focus::Tree => Focus::Repo, + Focus::Repo => Focus::Snapshots, } } pub fn prev(self) -> Self { match self { - Focus::Snapshots => Focus::Stats, + Focus::Snapshots => Focus::Repo, Focus::Tree => Focus::Snapshots, - Focus::Stats => Focus::Tree, + Focus::Repo => Focus::Tree, } } } @@ -85,7 +85,6 @@ pub enum BgMsg { snapshot: String, result: Result, String>, }, - Stats(Result), Restore(Result), } @@ -112,8 +111,6 @@ pub struct App { tree_generation: u64, tree_cancel: Arc, - pub stats: LoadState, - pub restore_state: RestoreState, /// Last finished restore: (success, message). pub restore_outcome: Option<(bool, String)>, @@ -144,7 +141,6 @@ impl App { 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)), @@ -153,7 +149,6 @@ impl App { rx, }; app.spawn_snapshots(); - app.spawn_stats(); app } @@ -180,16 +175,6 @@ impl App { }); } - 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). @@ -230,7 +215,7 @@ impl App { }); } - /// Refresh snapshots and stats (`R`); drops the tree cache. + /// Refresh snapshots (`R`); drops the tree cache. pub fn refresh(&mut self) { self.tree_cancel.store(true, Ordering::Relaxed); self.tree_generation += 1; @@ -238,7 +223,6 @@ impl App { self.tree = None; self.tree_status = TreeStatus::Idle; self.spawn_snapshots(); - self.spawn_stats(); self.status = Some("refreshing…".to_string()); } @@ -286,12 +270,6 @@ impl App { } } } - 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 { @@ -355,7 +333,7 @@ impl App { } } Focus::Snapshots => self.focus = Focus::Tree, - Focus::Stats => {} + Focus::Repo => {} }, _ => {} } @@ -391,7 +369,7 @@ impl App { tree.move_by(delta); } } - Focus::Stats => {} + Focus::Repo => {} } } @@ -408,7 +386,7 @@ impl App { tree.first(); } } - Focus::Stats => {} + Focus::Repo => {} } } @@ -430,7 +408,7 @@ impl App { tree.last(); } } - Focus::Stats => {} + Focus::Repo => {} } } @@ -461,7 +439,7 @@ impl App { return; } }, - Focus::Stats => { + Focus::Repo => { self.status = Some("focus Snapshots or Files to restore".to_string()); return; } @@ -661,8 +639,8 @@ mod tests { #[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); + assert_eq!(Focus::Tree.next(), Focus::Repo); + assert_eq!(Focus::Repo.next(), Focus::Snapshots); + assert_eq!(Focus::Snapshots.prev(), Focus::Repo); } } diff --git a/src/envfile.rs b/src/envfile.rs index 183e2cf..f89cf97 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -22,7 +22,7 @@ impl EnvFile { self.vars.get(key).map(String::as_str) } - /// Value of RESTIC_REPOSITORY, for display in the picker and stats panel. + /// Value of RESTIC_REPOSITORY, for display in the picker and repo panel. pub fn repository(&self) -> &str { self.get("RESTIC_REPOSITORY") .unwrap_or("(no RESTIC_REPOSITORY)") diff --git a/src/restic.rs b/src/restic.rs index 4b42857..d472af3 100644 --- a/src/restic.rs +++ b/src/restic.rs @@ -22,6 +22,11 @@ use serde::Deserialize; pub struct Snapshot { pub short_id: String, pub time: String, + pub hostname: String, + pub username: String, + pub paths: Vec, + #[serde(default)] + pub tags: Vec, } /// One line of `restic ls --json` (either the snapshot header or a node). @@ -47,13 +52,6 @@ impl LsLine { } } -/// 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) -> Command { let mut cmd = Command::new("restic"); cmd.envs(env); @@ -136,19 +134,6 @@ pub fn ls(env: &HashMap, snapshot: &str, cancel: &AtomicBool) -> Ok(out) } -/// `restic stats --json` (default mode). -pub fn stats(env: &HashMap) -> Result { - 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 --target [--include ]`. /// /// Polls the child so a running restore can be cancelled via `cancel` @@ -214,11 +199,21 @@ mod tests { #[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 json = r#"[{"time":"2026-08-15T21:02:18.148309+02:00","paths":["/Users/seth"],"hostname":"mac.local","username":"seth","id":"46dd2015bcdb","short_id":"46dd2015","tags":["daily","auto"]}]"#; let snaps: Vec = 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"); + assert_eq!(snaps[0].hostname, "mac.local"); + assert_eq!(snaps[0].username, "seth"); + assert_eq!(snaps[0].paths, vec!["/Users/seth"]); + assert_eq!(snaps[0].tags, vec!["daily", "auto"]); + } + + #[test] + fn parse_snapshot_json_without_tags() { + 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 = serde_json::from_str(json).unwrap(); + assert!(snaps[0].tags.is_empty()); } #[test] @@ -247,17 +242,6 @@ mod tests { 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(); diff --git a/src/ui.rs b/src/ui.rs index e9804ce..c48edb0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -26,7 +26,7 @@ pub fn draw(frame: &mut Frame, app: &mut App) { draw_snapshots(frame, app, cols[0]); draw_tree(frame, app, cols[1]); - draw_stats(frame, app, cols[2]); + draw_repo(frame, app, cols[2]); draw_status(frame, app, chunks[1]); draw_footer(frame, chunks[2]); @@ -183,25 +183,26 @@ fn draw_tree(frame: &mut Frame, app: &mut App, area: Rect) { } } -fn draw_stats(frame: &mut Frame, app: &App, area: Rect) { - let focused = app.focus == Focus::Stats; +fn draw_repo(frame: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Repo; let block = panel_block("Repository".to_string(), focused); let label = Style::default().fg(Color::Gray); + let value = Style::default(); let mut lines = vec![ Line::from(vec![ Span::styled("Repository: ", label), - Span::raw(app.repo.clone()), + Span::styled(app.repo.clone(), value), ]), Line::from(vec![ Span::styled("Env file: ", label), - Span::raw(app.env_rel_path.clone()), + Span::styled(app.env_rel_path.clone(), value), ]), Line::from(""), ]; match &app.snapshots { LoadState::Ready(snaps) => lines.push(Line::from(vec![ Span::styled("Snapshots: ", label), - Span::raw(snaps.len().to_string()), + Span::styled(snaps.len().to_string(), value), ])), LoadState::Loading => lines.push(Line::from(vec![ Span::styled("Snapshots: ", label), @@ -212,33 +213,45 @@ fn draw_stats(frame: &mut Frame, app: &App, area: Rect) { 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("")); + match app.selected_snapshot() { + Some(snap) => { lines.push(Line::from(vec![ - Span::styled("Stats: ", label), - Span::styled("error", Style::default().fg(Color::Red)), + Span::styled("Snapshot: ", label), + Span::styled(snap.short_id.clone(), value), ])); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - e.clone(), - Style::default().fg(Color::Red), - ))); + lines.push(Line::from(vec![ + Span::styled("Time: ", label), + Span::styled(format_time(&snap.time), value), + ])); + lines.push(Line::from(vec![ + Span::styled("Host: ", label), + Span::styled(snap.hostname.clone(), value), + ])); + lines.push(Line::from(vec![ + Span::styled("User: ", label), + Span::styled(snap.username.clone(), value), + ])); + lines.push(Line::from(vec![ + Span::styled("Paths: ", label), + Span::styled(snap.paths.join(", "), value), + ])); + if !snap.tags.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Tags: ", label), + Span::styled(snap.tags.join(", "), value), + ])); + } } - 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)), - ])); + None => { + lines.push(Line::from(vec![Span::styled( + "no snapshot selected", + Style::default().fg(Color::Gray), + )])); } } + frame.render_widget( Paragraph::new(lines).block(block).wrap(Wrap { trim: false }), area, @@ -355,18 +368,6 @@ pub fn human_size(bytes: u64) -> String { 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::*; @@ -389,10 +390,4 @@ mod tests { 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"); - } }