From 6b3c825dec034d06b7b9e67e2860f1385cfc9b6e Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Mon, 16 Jun 2025 22:40:25 +0200 Subject: [PATCH 01/19] WIP feat(interactive): Add interactive diff --- src/commands/diff.rs | 79 ++++++- src/commands/tui.rs | 1 + src/commands/tui/diff.rs | 382 ++++++++++++++++++++++++++++++++++ src/commands/tui/snapshots.rs | 34 +++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/commands/tui/diff.rs diff --git a/src/commands/diff.rs b/src/commands/diff.rs index f9521fff3..d018be25e 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -7,7 +7,7 @@ use clap::ValueHint; use log::debug; use std::{ - fmt::Display, + fmt::{Display, Write}, path::{Path, PathBuf}, }; @@ -207,6 +207,83 @@ fn identical_content_local( Ok(true) } +pub enum NodeDiff { + Identical, + Added, + Removed, + TypeChanged, + Modified, + MetaDataChanged, + SymlinkChanged, +} + +impl NodeDiff { + pub fn from( + node1: Option<&Node>, + node2: Option<&Node>, + equal_content: impl Fn(&Node, &Node) -> bool, + ) -> Self { + Self::try_from(node1, node2, |node1, node2| Ok(equal_content(node1, node2))).unwrap() + } + + pub fn try_from( + node1: Option<&Node>, + node2: Option<&Node>, + equal_content: impl Fn(&Node, &Node) -> Result, + ) -> Result { + let result = match (node1, node2) { + (None, Some(_)) => Self::Added, + (Some(_), None) => Self::Removed, + (Some(node1), Some(node2)) => { + let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. }) + && matches!(&node2.node_type, NodeType::Symlink { .. }); + match &node1.node_type { + // if node1.node_type != node2.node_type, they could be different symlinks, + // for this reason we check: + // that their type is different AND that they are not both symlinks + tpe if tpe != &node2.node_type && !are_both_symlink => Self::TypeChanged, + NodeType::File if !equal_content(node1, node2)? => Self::Modified, + NodeType::Dir if node1.subtree != node2.subtree => Self::Modified, + NodeType::Symlink { .. } + if node1.node_type.to_link() != node2.node_type.to_link() => + { + Self::SymlinkChanged + } + _ if node1.meta != node2.meta => Self::MetaDataChanged, + _ => Self::Identical, + } + } + (None, None) => Self::Identical, + }; + Ok(result) + } + + pub fn identical(&self) -> bool { + matches!(self, Self::Identical) + } + + pub fn ignore_metadata(self) -> Self { + match self { + Self::MetaDataChanged => Self::Identical, + d => d, + } + } +} + +impl Display for NodeDiff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let c = match self { + Self::Identical => '=', + Self::Added => '+', + Self::Removed => '-', + Self::TypeChanged => 'T', + Self::Modified => 'M', + Self::MetaDataChanged | Self::SymlinkChanged => 'U', + }; + f.write_char(c) + } +} + /// Statistics about the differences listed with the [`DiffCmd`] command #[derive(Default)] struct DiffStatistics { diff --git a/src/commands/tui.rs b/src/commands/tui.rs index dbc022d40..ba4fc2deb 100644 --- a/src/commands/tui.rs +++ b/src/commands/tui.rs @@ -1,4 +1,5 @@ //! `tui` subcommand +mod diff; mod ls; mod progress; mod restore; diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs new file mode 100644 index 000000000..d908be4be --- /dev/null +++ b/src/commands/tui/diff.rs @@ -0,0 +1,382 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, +}; + +use anyhow::Result; +use crossterm::event::{Event, KeyCode, KeyEventKind}; +use itertools::{EitherOrBoth, Itertools}; +use log::debug; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph}, +}; +use rustic_core::{ + DataId, IndexedFull, ProgressBars, Repository, TreeId, + repofile::{Node, SnapshotFile, Tree}, +}; +use style::palette::tailwind; + +use crate::commands::{ + diff::NodeDiff, + tui::widgets::{ + Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock, + popup_prompt, popup_text, + }, +}; + +// the states this screen can be in +enum CurrentScreen { + Snapshot, + ShowHelp(PopUpText), + PromptExit(PopUpPrompt), +} + +const INFO_TEXT: &str = + "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands"; + +const HELP_TEXT: &str = r" + n : toggle ignoring metadata + +General Commands: + + q,Esc : exit + Enter : enter dir + Backspace : return to parent dir + m : toggle metadata diff + s : compute diff for (sub-)dirs + ? : show this help page + + "; + +#[derive(Default)] +struct DiffTree { + nodes: Vec>, +} + +impl DiffTree { + fn from_trees( + repo: &'_ Repository, + left: Option, + right: Option, + ) -> Result { + let left_tree = if let Some(left) = left { + repo.get_tree(&left)? + } else { + Tree::default() + }; + let right_tree = if let Some(right) = right { + repo.get_tree(&right)? + } else { + Tree::default() + }; + let nodes = left_tree + .nodes + .into_iter() + .merge_join_by(right_tree.nodes, |node_l, node_r| { + node_l.name().cmp(&node_r.name()) + }) + .collect(); + Ok(Self { nodes }) + } + + fn left(&self) -> Tree { + let nodes = self.nodes.iter().filter_map(|n| n.clone().left()).collect(); + Tree { nodes } + } + fn right(&self) -> Tree { + let nodes = self + .nodes + .iter() + .filter_map(|n| n.clone().right()) + .collect(); + Tree { nodes } + } +} + +pub(crate) struct Diff<'a, P, S> { + current_screen: CurrentScreen, + table: WithBlock, + repo: &'a Repository, + snapshot: SnapshotFile, + path: PathBuf, + trees: Vec<(DiffTree, Option, Option, usize)>, // Stack of parent trees with position + tree: DiffTree, + left: Option, + right: Option, + blobs_map: BTreeMap>, + ignore_metadata: bool, +} + +pub enum DiffResult { + Exit, + Return, + None, +} + +impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { + pub fn new(repo: &'a Repository, left: TreeId, right: TreeId) -> Result { + let header = ["Name", "Time", "Size", "- Size", "Time", "Size", "+ Size"] + .into_iter() + .map(Text::from) + .collect(); + + let tree = DiffTree::from_trees(repo, Some(left), Some(right))?; + let mut app = Self { + current_screen: CurrentScreen::Snapshot, + table: WithBlock::new(SelectTable::new(header), Block::new()), + repo, + snapshot: SnapshotFile::default(), + path: PathBuf::new(), + trees: Vec::new(), + tree, + left: Some(left), + right: Some(right), + blobs_map: BTreeMap::new(), + ignore_metadata: true, + }; + app.update_table(); + Ok(app) + } + + fn ls_row(&self, node: &EitherOrBoth) -> Vec> { + let node_info = |node: &Node| { + ( + node.meta.size.to_string(), + node.meta.mtime.map_or_else( + || "?".to_string(), + |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")), + ), + ) + }; + let (left, right) = node.as_ref().left_and_right(); + + let compute_diff = |blobs1: &BTreeSet<&DataId>, blobs2: &BTreeSet<&DataId>| { + if blobs1.is_empty() { + String::new() + } else { + blobs1 + .difference(blobs2) + .map(|id| self.repo.get_index_entry(*id)) + .try_fold(0u64, |sum, b| -> Result<_> { + Ok(sum + u64::from(b?.length)) + }) + .ok() + .map_or("?".to_string(), |s| s.to_string()) + } + }; + + let left_blobs = left.map_or_else(BTreeSet::new, |node| { + if let Some(id) = node.subtree { + if let Some(blobs) = self.blobs_map.get(&id) { + return blobs.iter().collect(); + } + } + node.content.iter().flatten().collect() + }); + let right_blobs = right.map_or_else(BTreeSet::new, |node| { + if let Some(id) = node.subtree { + if let Some(blobs) = self.blobs_map.get(&id) { + return blobs.iter().collect(); + } + } + node.content.iter().flatten().collect() + }); + let left_only = compute_diff(&left_blobs, &right_blobs); + let right_only = compute_diff(&right_blobs, &left_blobs); + + let mut changed = NodeDiff::from(left, right, |n1, n2| n1.content == n2.content); + if self.ignore_metadata { + changed = changed.ignore_metadata(); + } + let name = node.as_ref().reduce(|l, _| l).name(); + let name = format!("{changed} {}", name.to_string_lossy()); + let (left_size, left_mtime) = match node { + EitherOrBoth::Left(node) | EitherOrBoth::Both(node, _) => node_info(node), + _ => (String::new(), String::new()), + }; + let (right_size, right_mtime) = match node { + EitherOrBoth::Right(node) | EitherOrBoth::Both(_, node) => node_info(node), + _ => (String::new(), String::new()), + }; + [ + name, + left_mtime, + left_size, + left_only, + right_mtime, + right_size, + right_only, + ] + .into_iter() + .map(Text::from) + .collect() + } + + pub fn update_table(&mut self) { + let old_selection = if self.tree.nodes.is_empty() { + None + } else { + Some(self.table.widget.selected().unwrap_or_default()) + }; + let mut rows = Vec::new(); + for node in &self.tree.nodes { + let row = self.ls_row(node); + rows.push(row); + } + + self.table.widget.set_content(rows, 1); + + self.table.block = Block::new() + .borders(Borders::BOTTOM | Borders::TOP) + .title(format!("{}:{}", self.snapshot.id, self.path.display())) + .title_alignment(Alignment::Center); + self.table.widget.select(old_selection); + } + + pub fn enter(&mut self) -> Result<()> { + if let Some(idx) = self.table.widget.selected() { + let node = &self.tree.nodes[idx]; + let (new_left, new_right) = match node { + EitherOrBoth::Left(left) => (left.subtree, None), + EitherOrBoth::Right(right) => (None, right.subtree), + EitherOrBoth::Both(left, right) => (left.subtree, right.subtree), + }; + debug!("{new_left:?}, {new_right:?}"); + if (new_left, new_right) != (None, None) { + let tree = std::mem::take(&mut self.tree); + self.trees.push((tree, self.left, self.right, idx)); + self.tree = DiffTree::from_trees(self.repo, new_left, new_right)?; + self.left = new_left; + self.right = new_right; + self.table.widget.set_to(0); + self.update_table(); + } + } + Ok(()) + } + + pub fn goback(&mut self) -> bool { + _ = self.path.pop(); + if let Some((tree, left, right, idx)) = self.trees.pop() { + self.tree = tree; + self.left = left; + self.right = right; + self.table.widget.set_to(idx); + self.update_table(); + false + } else { + true + } + } + + pub fn toggle_ignore_metadata(&mut self) { + self.ignore_metadata = !self.ignore_metadata; + self.update_table(); + } + + pub fn compute_blobs(&mut self) { + if let Some(left) = self.left { + let _ = compute_tree_blobs(self.repo, &self.tree.left(), left, &mut self.blobs_map) + .unwrap(); + } + if let Some(right) = self.right { + let _ = compute_tree_blobs(self.repo, &self.tree.right(), right, &mut self.blobs_map) + .unwrap(); + } + self.update_table(); + } + + pub fn input(&mut self, event: Event) -> Result { + use KeyCode::{Backspace, Char, Enter, Esc, Left, Right}; + match &mut self.current_screen { + CurrentScreen::Snapshot => match event { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + Enter | Right => self.enter()?, + Backspace | Left => { + if self.goback() { + return Ok(DiffResult::Return); + } + } + Esc | Char('q') => { + self.current_screen = CurrentScreen::PromptExit(popup_prompt( + "exit rustic", + "do you want to exit? (y/n)".into(), + )); + } + Char('?') => { + self.current_screen = + CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into())); + } + Char('m') => self.toggle_ignore_metadata(), + Char('s') => self.compute_blobs(), + _ => self.table.input(event), + }, + _ => {} + }, + CurrentScreen::ShowHelp(_) => match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if matches!(key.code, Char('q' | ' ' | '?') | Esc | Enter) { + self.current_screen = CurrentScreen::Snapshot; + } + } + _ => {} + }, + CurrentScreen::PromptExit(prompt) => match prompt.input(event) { + PromptResult::Ok => return Ok(DiffResult::Exit), + PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot, + PromptResult::None => {} + }, + } + Ok(DiffResult::None) + } + + pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { + let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); + + // draw the table + self.table.draw(rects[0], f); + + // draw the footer + let buffer_bg = tailwind::SLATE.c950; + let row_fg = tailwind::SLATE.c200; + let info_footer = Paragraph::new(Line::from(INFO_TEXT)) + .style(Style::new().fg(row_fg).bg(buffer_bg)) + .centered(); + f.render_widget(info_footer, rects[1]); + + // draw popups + match &mut self.current_screen { + CurrentScreen::Snapshot => {} + CurrentScreen::ShowHelp(popup) => popup.draw(area, f), + CurrentScreen::PromptExit(popup) => popup.draw(area, f), + } + } +} + +fn compute_tree_blobs( + repo: &'_ Repository, + tree: &Tree, + id: TreeId, + blobs_map: &mut BTreeMap>, +) -> Result> +where + S: IndexedFull, +{ + if let Some(blobs) = blobs_map.get(&id) { + return Ok(blobs.clone()); + } + let mut blobs = BTreeSet::new(); + for node in &tree.nodes { + for id in node.content.iter().flatten() { + _ = blobs.insert(*id); + } + if node.is_dir() { + let id = node.subtree.unwrap(); + let tree = repo.get_tree(&id)?; + blobs.append(&mut compute_tree_blobs(repo, &tree, id, blobs_map)?); + } + } + _ = blobs_map.insert(id, blobs.clone()); + Ok(blobs) +} diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index 7b2ec47f5..dcd8afa7f 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -18,6 +18,7 @@ use crate::{ commands::{ snapshots::{fill_table, snap_to_table}, tui::{ + diff::{Diff, DiffResult}, ls::{Snapshot, SnapshotResult}, tree::{Tree, TreeIterItem, TreeNode}, widgets::{ @@ -44,6 +45,7 @@ enum CurrentScreen<'a, P, S> { PromptWrite(PopUpPrompt), PromptExit(PopUpPrompt), Dir(Box>), + Diff(Box>), } // status of each snapshot @@ -101,6 +103,7 @@ const HELP_TEXT: &str = r"General Commands: Ctrl-l : remove label for snapshot(s) d : set description for snapshot(s) Ctrl-d : remove description for snapshot(s) + D : diff snapshots if 2 snapshots are selected t : add tag(s) for snapshot(s) Ctrl-t : remove all tags for snapshot(s) s : set tag(s) for snapshot(s) @@ -484,6 +487,22 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { }) } + pub fn diff(&self) -> Result>> { + if self.count_marked_snaps() != 2 { + return Ok(None); + } + + let snaps: Vec<_> = self + .snapshots + .iter() + .zip(self.snaps_status.iter()) + .filter_map(|(snap, status)| status.marked.then_some(snap)) + .collect(); + let left = snaps[0].tree; + let right = snaps[1].tree; + Some(Diff::new(self.repo, left, right)).transpose() + } + pub fn count_marked_snaps(&self) -> usize { self.snaps_status.iter().filter(|s| s.marked).count() } @@ -805,6 +824,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { 5, )); } + Char('D') => { + if let Some(diff) = self.diff()? { + self.current_screen = CurrentScreen::Diff(Box::new(diff)); + } + } Char('t') => { self.current_screen = CurrentScreen::EnterAddTags(popup_input( "add tags", @@ -885,6 +909,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { SnapshotResult::Return => self.current_screen = CurrentScreen::Snapshots, SnapshotResult::None => {} }, + CurrentScreen::Diff(diff) => match diff.input(event)? { + DiffResult::Exit => return Ok(true), + DiffResult::Return => self.current_screen = CurrentScreen::Snapshots, + DiffResult::None => {} + }, } Ok(false) } @@ -895,6 +924,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { return; } + if let CurrentScreen::Diff(diff) = &mut self.current_screen { + diff.draw(area, f); + return; + } + let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); // draw the table From 2c6971f08358bb3db97190d3cc5dcad041ad0b09 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Tue, 17 Jun 2025 21:52:12 +0200 Subject: [PATCH 02/19] add option to only show differences --- src/commands/diff.rs | 2 +- src/commands/tui/diff.rs | 68 +++++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index d018be25e..49611154a 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -258,7 +258,7 @@ impl NodeDiff { Ok(result) } - pub fn identical(&self) -> bool { + pub fn is_identical(&self) -> bool { matches!(self, Self::Identical) } diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index d908be4be..18a7cc0a7 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -36,15 +36,17 @@ const INFO_TEXT: &str = "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands"; const HELP_TEXT: &str = r" - n : toggle ignoring metadata +Diff Commands: + + m : toggle ignoring metadata + d : toggle show only different entries + s : compute diff for (sub-)dirs General Commands: q,Esc : exit Enter : enter dir Backspace : return to parent dir - m : toggle metadata diff - s : compute diff for (sub-)dirs ? : show this help page "; @@ -106,6 +108,7 @@ pub(crate) struct Diff<'a, P, S> { right: Option, blobs_map: BTreeMap>, ignore_metadata: bool, + ignore_identical: bool, } pub enum DiffResult { @@ -116,12 +119,19 @@ pub enum DiffResult { impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn new(repo: &'a Repository, left: TreeId, right: TreeId) -> Result { - let header = ["Name", "Time", "Size", "- Size", "Time", "Size", "+ Size"] - .into_iter() - .map(Text::from) - .collect(); + let header = [ + "Name", + "Time", + "Size", + "- RepoSize", + "Time", + "Size", + "+ RepoSize", + ] + .into_iter() + .map(Text::from) + .collect(); - let tree = DiffTree::from_trees(repo, Some(left), Some(right))?; let mut app = Self { current_screen: CurrentScreen::Snapshot, table: WithBlock::new(SelectTable::new(header), Block::new()), @@ -129,16 +139,33 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { snapshot: SnapshotFile::default(), path: PathBuf::new(), trees: Vec::new(), - tree, + tree: DiffTree::default(), left: Some(left), right: Some(right), blobs_map: BTreeMap::new(), ignore_metadata: true, + ignore_identical: true, }; + let mut tree = DiffTree::from_trees(repo, Some(left), Some(right))?; + tree.nodes.retain(|node| app.show_node(node)); + app.tree = tree; app.update_table(); Ok(app) } + fn node_changed(&self, node: &EitherOrBoth) -> NodeDiff { + let (left, right) = node.as_ref().left_and_right(); + let mut changed = NodeDiff::from(left, right, |n1, n2| n1.content == n2.content); + if self.ignore_metadata { + changed = changed.ignore_metadata(); + } + changed + } + + fn show_node(&self, node: &EitherOrBoth) -> bool { + !self.ignore_identical || !self.node_changed(node).is_identical() + } + fn ls_row(&self, node: &EitherOrBoth) -> Vec> { let node_info = |node: &Node| { ( @@ -149,7 +176,6 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { ), ) }; - let (left, right) = node.as_ref().left_and_right(); let compute_diff = |blobs1: &BTreeSet<&DataId>, blobs2: &BTreeSet<&DataId>| { if blobs1.is_empty() { @@ -166,6 +192,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } }; + let (left, right) = node.as_ref().left_and_right(); let left_blobs = left.map_or_else(BTreeSet::new, |node| { if let Some(id) = node.subtree { if let Some(blobs) = self.blobs_map.get(&id) { @@ -185,10 +212,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { let left_only = compute_diff(&left_blobs, &right_blobs); let right_only = compute_diff(&right_blobs, &left_blobs); - let mut changed = NodeDiff::from(left, right, |n1, n2| n1.content == n2.content); - if self.ignore_metadata { - changed = changed.ignore_metadata(); - } + let changed = self.node_changed(node); let name = node.as_ref().reduce(|l, _| l).name(); let name = format!("{changed} {}", name.to_string_lossy()); let (left_size, left_mtime) = match node { @@ -246,7 +270,9 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { if (new_left, new_right) != (None, None) { let tree = std::mem::take(&mut self.tree); self.trees.push((tree, self.left, self.right, idx)); - self.tree = DiffTree::from_trees(self.repo, new_left, new_right)?; + let mut tree = DiffTree::from_trees(self.repo, new_left, new_right)?; + tree.nodes.retain(|node| self.show_node(node)); + self.tree = tree; self.left = new_left; self.right = new_right; self.table.widget.set_to(0); @@ -275,6 +301,17 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.update_table(); } + pub fn toggle_ignore_identical(&mut self) -> Result<()> { + self.ignore_identical = !self.ignore_identical; + + let mut tree = DiffTree::from_trees(self.repo, self.left, self.right)?; + tree.nodes.retain(|node| self.show_node(node)); + self.tree = tree; + + self.update_table(); + Ok(()) + } + pub fn compute_blobs(&mut self) { if let Some(left) = self.left { let _ = compute_tree_blobs(self.repo, &self.tree.left(), left, &mut self.blobs_map) @@ -309,6 +346,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into())); } Char('m') => self.toggle_ignore_metadata(), + Char('d') => self.toggle_ignore_identical()?, Char('s') => self.compute_blobs(), _ => self.table.input(event), }, From aae3426cb8f8619d4afc5ca09759396cc5b0f82d Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Tue, 17 Jun 2025 22:18:07 +0200 Subject: [PATCH 03/19] also collect sizes in summary --- src/commands/tui/diff.rs | 92 ++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 18a7cc0a7..d94248a79 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -96,6 +96,47 @@ impl DiffTree { } } +#[derive(Default, Clone)] +struct TreeSummary { + blobs: BTreeSet, + size: u64, +} + +impl TreeSummary { + fn update(&mut self, mut other: Self) { + self.blobs.append(&mut other.blobs); + self.size += other.size; + } + + fn from_repo( + repo: &'_ Repository, + tree: &Tree, + id: TreeId, + blobs_map: &mut BTreeMap, + ) -> Result + where + S: IndexedFull, + { + if let Some(summary) = blobs_map.get(&id) { + return Ok(summary.clone()); + } + + let mut summary = Self::default(); + for node in &tree.nodes { + for id in node.content.iter().flatten() { + _ = summary.blobs.insert(*id); + } + summary.size += node.meta.size; + if let Some(id) = node.subtree { + let tree = repo.get_tree(&id)?; + summary.update(Self::from_repo(repo, &tree, id, blobs_map)?); + } + } + _ = blobs_map.insert(id, summary.clone()); + Ok(summary) + } +} + pub(crate) struct Diff<'a, P, S> { current_screen: CurrentScreen, table: WithBlock, @@ -106,7 +147,7 @@ pub(crate) struct Diff<'a, P, S> { tree: DiffTree, left: Option, right: Option, - blobs_map: BTreeMap>, + blobs_map: BTreeMap, ignore_metadata: bool, ignore_identical: bool, } @@ -168,8 +209,13 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { fn ls_row(&self, node: &EitherOrBoth) -> Vec> { let node_info = |node: &Node| { + let size = node.subtree.map_or(node.meta.size, |id| { + self.blobs_map + .get(&id) + .map_or(node.meta.size, |summary| summary.size) + }); ( - node.meta.size.to_string(), + size.to_string(), node.meta.mtime.map_or_else( || "?".to_string(), |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")), @@ -195,16 +241,16 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { let (left, right) = node.as_ref().left_and_right(); let left_blobs = left.map_or_else(BTreeSet::new, |node| { if let Some(id) = node.subtree { - if let Some(blobs) = self.blobs_map.get(&id) { - return blobs.iter().collect(); + if let Some(summary) = self.blobs_map.get(&id) { + return summary.blobs.iter().collect(); } } node.content.iter().flatten().collect() }); let right_blobs = right.map_or_else(BTreeSet::new, |node| { if let Some(id) = node.subtree { - if let Some(blobs) = self.blobs_map.get(&id) { - return blobs.iter().collect(); + if let Some(summary) = self.blobs_map.get(&id) { + return summary.blobs.iter().collect(); } } node.content.iter().flatten().collect() @@ -314,12 +360,13 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn compute_blobs(&mut self) { if let Some(left) = self.left { - let _ = compute_tree_blobs(self.repo, &self.tree.left(), left, &mut self.blobs_map) + let _ = TreeSummary::from_repo(self.repo, &self.tree.left(), left, &mut self.blobs_map) .unwrap(); } if let Some(right) = self.right { - let _ = compute_tree_blobs(self.repo, &self.tree.right(), right, &mut self.blobs_map) - .unwrap(); + let _ = + TreeSummary::from_repo(self.repo, &self.tree.right(), right, &mut self.blobs_map) + .unwrap(); } self.update_table(); } @@ -391,30 +438,3 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } } } - -fn compute_tree_blobs( - repo: &'_ Repository, - tree: &Tree, - id: TreeId, - blobs_map: &mut BTreeMap>, -) -> Result> -where - S: IndexedFull, -{ - if let Some(blobs) = blobs_map.get(&id) { - return Ok(blobs.clone()); - } - let mut blobs = BTreeSet::new(); - for node in &tree.nodes { - for id in node.content.iter().flatten() { - _ = blobs.insert(*id); - } - if node.is_dir() { - let id = node.subtree.unwrap(); - let tree = repo.get_tree(&id)?; - blobs.append(&mut compute_tree_blobs(repo, &tree, id, blobs_map)?); - } - } - _ = blobs_map.insert(id, blobs.clone()); - Ok(blobs) -} From 694cc3136f05192758ce4c3454db61560d559040 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Wed, 18 Jun 2025 13:18:05 +0200 Subject: [PATCH 04/19] refactor --- src/commands/tui/diff.rs | 188 ++++++++++++++++++++++----------------- 1 file changed, 104 insertions(+), 84 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index d94248a79..52e24af1e 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -1,12 +1,12 @@ use std::{ collections::{BTreeMap, BTreeSet}, + ffi::OsString, path::PathBuf, }; use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEventKind}; use itertools::{EitherOrBoth, Itertools}; -use log::debug; use ratatui::{ prelude::*, widgets::{Block, Borders, Paragraph}, @@ -17,12 +17,15 @@ use rustic_core::{ }; use style::palette::tailwind; -use crate::commands::{ - diff::NodeDiff, - tui::widgets::{ - Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock, - popup_prompt, popup_text, +use crate::{ + commands::{ + diff::NodeDiff, + tui::widgets::{ + Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock, + popup_prompt, popup_text, + }, }, + helpers::bytes_size_to_string, }; // the states this screen can be in @@ -40,7 +43,7 @@ Diff Commands: m : toggle ignoring metadata d : toggle show only different entries - s : compute diff for (sub-)dirs + s : compute information for (sub-)dirs General Commands: @@ -51,24 +54,45 @@ General Commands: "; +struct DiffNode(EitherOrBoth); + +impl DiffNode { + fn subtrees(&self) -> Option> { + let (left, right) = self + .0 + .as_ref() + .map_any(|n| n.subtree, |n| n.subtree) + .left_and_right(); + match (left.flatten(), right.flatten()) { + (Some(l), Some(r)) => Some(EitherOrBoth::Both(l, r)), + (Some(l), None) => Some(EitherOrBoth::Left(l)), + (None, Some(r)) => Some(EitherOrBoth::Right(r)), + (None, None) => None, + } + } + + fn name(&self) -> OsString { + self.0.as_ref().reduce(|l, _| l).name() + } +} + #[derive(Default)] struct DiffTree { - nodes: Vec>, + nodes: Vec, } impl DiffTree { fn from_trees( repo: &'_ Repository, - left: Option, - right: Option, + tree_ids: &EitherOrBoth, ) -> Result { - let left_tree = if let Some(left) = left { - repo.get_tree(&left)? + let left_tree = if let Some(left) = tree_ids.as_ref().left() { + repo.get_tree(left)? } else { Tree::default() }; - let right_tree = if let Some(right) = right { - repo.get_tree(&right)? + let right_tree = if let Some(right) = tree_ids.as_ref().right() { + repo.get_tree(right)? } else { Tree::default() }; @@ -78,22 +102,10 @@ impl DiffTree { .merge_join_by(right_tree.nodes, |node_l, node_r| { node_l.name().cmp(&node_r.name()) }) + .map(DiffNode) .collect(); Ok(Self { nodes }) } - - fn left(&self) -> Tree { - let nodes = self.nodes.iter().filter_map(|n| n.clone().left()).collect(); - Tree { nodes } - } - fn right(&self) -> Tree { - let nodes = self - .nodes - .iter() - .filter_map(|n| n.clone().right()) - .collect(); - Tree { nodes } - } } #[derive(Default, Clone)] @@ -108,31 +120,55 @@ impl TreeSummary { self.size += other.size; } + fn update_from_node(&mut self, node: &Node) { + for id in node.content.iter().flatten() { + _ = self.blobs.insert(*id); + } + self.size += node.meta.size; + } + fn from_repo( repo: &'_ Repository, - tree: &Tree, + ids: &EitherOrBoth, + summary_map: &mut BTreeMap, + ) -> Result<()> + where + P: ProgressBars, + S: IndexedFull, + { + let (left, right) = ids.as_ref().left_and_right(); + if let Some(id) = left { + let _ = Self::from_tree(repo, *id, summary_map)?; + } + if let Some(id) = right { + let _ = Self::from_tree(repo, *id, summary_map)?; + } + Ok(()) + } + + fn from_tree( + repo: &'_ Repository, id: TreeId, - blobs_map: &mut BTreeMap, + summary_map: &mut BTreeMap, ) -> Result where + P: ProgressBars, S: IndexedFull, { - if let Some(summary) = blobs_map.get(&id) { + if let Some(summary) = summary_map.get(&id) { return Ok(summary.clone()); } let mut summary = Self::default(); + + let tree = repo.get_tree(&id)?; for node in &tree.nodes { - for id in node.content.iter().flatten() { - _ = summary.blobs.insert(*id); - } - summary.size += node.meta.size; + summary.update_from_node(node); if let Some(id) = node.subtree { - let tree = repo.get_tree(&id)?; - summary.update(Self::from_repo(repo, &tree, id, blobs_map)?); + summary.update(Self::from_tree(repo, id, summary_map)?); } } - _ = blobs_map.insert(id, summary.clone()); + _ = summary_map.insert(id, summary.clone()); Ok(summary) } } @@ -143,11 +179,10 @@ pub(crate) struct Diff<'a, P, S> { repo: &'a Repository, snapshot: SnapshotFile, path: PathBuf, - trees: Vec<(DiffTree, Option, Option, usize)>, // Stack of parent trees with position + trees: Vec<(DiffTree, EitherOrBoth, usize)>, // Stack of parent trees with position tree: DiffTree, - left: Option, - right: Option, - blobs_map: BTreeMap, + tree_ids: EitherOrBoth, + summary_map: BTreeMap, ignore_metadata: bool, ignore_identical: bool, } @@ -173,6 +208,8 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { .map(Text::from) .collect(); + let tree_ids = EitherOrBoth::Both(left, right); + let mut tree = DiffTree::from_trees(repo, &tree_ids)?; let mut app = Self { current_screen: CurrentScreen::Snapshot, table: WithBlock::new(SelectTable::new(header), Block::new()), @@ -181,21 +218,19 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { path: PathBuf::new(), trees: Vec::new(), tree: DiffTree::default(), - left: Some(left), - right: Some(right), - blobs_map: BTreeMap::new(), + tree_ids: EitherOrBoth::Both(left, right), + summary_map: BTreeMap::new(), ignore_metadata: true, ignore_identical: true, }; - let mut tree = DiffTree::from_trees(repo, Some(left), Some(right))?; tree.nodes.retain(|node| app.show_node(node)); app.tree = tree; app.update_table(); Ok(app) } - fn node_changed(&self, node: &EitherOrBoth) -> NodeDiff { - let (left, right) = node.as_ref().left_and_right(); + fn node_changed(&self, node: &DiffNode) -> NodeDiff { + let (left, right) = node.0.as_ref().left_and_right(); let mut changed = NodeDiff::from(left, right, |n1, n2| n1.content == n2.content); if self.ignore_metadata { changed = changed.ignore_metadata(); @@ -203,19 +238,19 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { changed } - fn show_node(&self, node: &EitherOrBoth) -> bool { + fn show_node(&self, node: &DiffNode) -> bool { !self.ignore_identical || !self.node_changed(node).is_identical() } - fn ls_row(&self, node: &EitherOrBoth) -> Vec> { + fn ls_row(&self, node: &DiffNode) -> Vec> { let node_info = |node: &Node| { let size = node.subtree.map_or(node.meta.size, |id| { - self.blobs_map + self.summary_map .get(&id) .map_or(node.meta.size, |summary| summary.size) }); ( - size.to_string(), + bytes_size_to_string(size), node.meta.mtime.map_or_else( || "?".to_string(), |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")), @@ -234,14 +269,14 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { Ok(sum + u64::from(b?.length)) }) .ok() - .map_or("?".to_string(), |s| s.to_string()) + .map_or("?".to_string(), bytes_size_to_string) } }; - let (left, right) = node.as_ref().left_and_right(); + let (left, right) = node.0.as_ref().left_and_right(); let left_blobs = left.map_or_else(BTreeSet::new, |node| { if let Some(id) = node.subtree { - if let Some(summary) = self.blobs_map.get(&id) { + if let Some(summary) = self.summary_map.get(&id) { return summary.blobs.iter().collect(); } } @@ -249,7 +284,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { }); let right_blobs = right.map_or_else(BTreeSet::new, |node| { if let Some(id) = node.subtree { - if let Some(summary) = self.blobs_map.get(&id) { + if let Some(summary) = self.summary_map.get(&id) { return summary.blobs.iter().collect(); } } @@ -259,13 +294,13 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { let right_only = compute_diff(&right_blobs, &left_blobs); let changed = self.node_changed(node); - let name = node.as_ref().reduce(|l, _| l).name(); + let name = node.name(); let name = format!("{changed} {}", name.to_string_lossy()); - let (left_size, left_mtime) = match node { + let (left_size, left_mtime) = match &node.0 { EitherOrBoth::Left(node) | EitherOrBoth::Both(node, _) => node_info(node), _ => (String::new(), String::new()), }; - let (right_size, right_mtime) = match node { + let (right_size, right_mtime) = match &node.0 { EitherOrBoth::Right(node) | EitherOrBoth::Both(_, node) => node_info(node), _ => (String::new(), String::new()), }; @@ -307,20 +342,13 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn enter(&mut self) -> Result<()> { if let Some(idx) = self.table.widget.selected() { let node = &self.tree.nodes[idx]; - let (new_left, new_right) = match node { - EitherOrBoth::Left(left) => (left.subtree, None), - EitherOrBoth::Right(right) => (None, right.subtree), - EitherOrBoth::Both(left, right) => (left.subtree, right.subtree), - }; - debug!("{new_left:?}, {new_right:?}"); - if (new_left, new_right) != (None, None) { + if let Some(tree_ids) = node.subtrees() { let tree = std::mem::take(&mut self.tree); - self.trees.push((tree, self.left, self.right, idx)); - let mut tree = DiffTree::from_trees(self.repo, new_left, new_right)?; + self.trees.push((tree, self.tree_ids.clone(), idx)); + let mut tree = DiffTree::from_trees(self.repo, &tree_ids)?; tree.nodes.retain(|node| self.show_node(node)); self.tree = tree; - self.left = new_left; - self.right = new_right; + self.tree_ids = tree_ids; self.table.widget.set_to(0); self.update_table(); } @@ -330,10 +358,9 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn goback(&mut self) -> bool { _ = self.path.pop(); - if let Some((tree, left, right, idx)) = self.trees.pop() { + if let Some((tree, tree_ids, idx)) = self.trees.pop() { self.tree = tree; - self.left = left; - self.right = right; + self.tree_ids = tree_ids; self.table.widget.set_to(idx); self.update_table(); false @@ -350,7 +377,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn toggle_ignore_identical(&mut self) -> Result<()> { self.ignore_identical = !self.ignore_identical; - let mut tree = DiffTree::from_trees(self.repo, self.left, self.right)?; + let mut tree = DiffTree::from_trees(self.repo, &self.tree_ids)?; tree.nodes.retain(|node| self.show_node(node)); self.tree = tree; @@ -358,17 +385,10 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { Ok(()) } - pub fn compute_blobs(&mut self) { - if let Some(left) = self.left { - let _ = TreeSummary::from_repo(self.repo, &self.tree.left(), left, &mut self.blobs_map) - .unwrap(); - } - if let Some(right) = self.right { - let _ = - TreeSummary::from_repo(self.repo, &self.tree.right(), right, &mut self.blobs_map) - .unwrap(); - } + pub fn compute_summary(&mut self) -> Result<()> { + TreeSummary::from_repo(self.repo, &self.tree_ids, &mut self.summary_map)?; self.update_table(); + Ok(()) } pub fn input(&mut self, event: Event) -> Result { @@ -394,7 +414,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } Char('m') => self.toggle_ignore_metadata(), Char('d') => self.toggle_ignore_identical()?, - Char('s') => self.compute_blobs(), + Char('s') => self.compute_summary()?, _ => self.table.input(event), }, _ => {} From 7a906693730cad4beef04081855a545162760709 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Wed, 18 Jun 2025 13:42:22 +0200 Subject: [PATCH 05/19] add promt when leaving diff view --- src/commands/tui/diff.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 52e24af1e..778675418 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -33,6 +33,7 @@ enum CurrentScreen { Snapshot, ShowHelp(PopUpText), PromptExit(PopUpPrompt), + PromptLeave(PopUpPrompt), } const INFO_TEXT: &str = @@ -356,16 +357,17 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { Ok(()) } - pub fn goback(&mut self) -> bool { + pub fn in_root(&self) -> bool { + self.trees.is_empty() + } + + pub fn goback(&mut self) { _ = self.path.pop(); if let Some((tree, tree_ids, idx)) = self.trees.pop() { self.tree = tree; self.tree_ids = tree_ids; self.table.widget.set_to(idx); self.update_table(); - false - } else { - true } } @@ -398,8 +400,13 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { Enter | Right => self.enter()?, Backspace | Left => { - if self.goback() { - return Ok(DiffResult::Return); + if self.in_root() { + self.current_screen = CurrentScreen::PromptLeave(popup_prompt( + "leave diff", + "do you want to leave the diff view? (y/n)".into(), + )); + } else { + self.goback(); } } Esc | Char('q') => { @@ -432,6 +439,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot, PromptResult::None => {} }, + CurrentScreen::PromptLeave(prompt) => match prompt.input(event) { + PromptResult::Ok => return Ok(DiffResult::Return), + PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot, + PromptResult::None => {} + }, } Ok(DiffResult::None) } @@ -454,7 +466,9 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { match &mut self.current_screen { CurrentScreen::Snapshot => {} CurrentScreen::ShowHelp(popup) => popup.draw(area, f), - CurrentScreen::PromptExit(popup) => popup.draw(area, f), + CurrentScreen::PromptExit(popup) | CurrentScreen::PromptLeave(popup) => { + popup.draw(area, f); + } } } } From 5833fd115d80c53834958e5219aa3bf03fbc30f2 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Wed, 18 Jun 2025 21:57:30 +0200 Subject: [PATCH 06/19] add progress for subdir computation --- Cargo.lock | 10 +++++----- src/commands/tui/diff.rs | 17 +++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 299e99f1e..f1049dbc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,7 +3482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.103", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "rustic_backend" version = "0.5.2" -source = "git+https://github.com/rustic-rs/rustic_core.git#f7cd46825e2a29512d4d7e7b7ce0c3f424ea49e8" +source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" dependencies = [ "aho-corasick", "backon", @@ -4221,7 +4221,7 @@ checksum = "fbcebf2228827bc4b61cb54dfd84cf43aacf06ca2dfe4c014b136a0e32b876e2" [[package]] name = "rustic_core" version = "0.7.3" -source = "git+https://github.com/rustic-rs/rustic_core.git#f7cd46825e2a29512d4d7e7b7ce0c3f424ea49e8" +source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" dependencies = [ "aes256ctr_poly1305aes", "binrw", @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "rustic_testing" version = "0.3.2" -source = "git+https://github.com/rustic-rs/rustic_core.git#f7cd46825e2a29512d4d7e7b7ce0c3f424ea49e8" +source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" dependencies = [ "aho-corasick", "anyhow", @@ -5855,7 +5855,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 778675418..73f660a70 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -12,7 +12,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; use rustic_core::{ - DataId, IndexedFull, ProgressBars, Repository, TreeId, + DataId, IndexedFull, Progress, ProgressBars, Repository, TreeId, repofile::{Node, SnapshotFile, Tree}, }; use style::palette::tailwind; @@ -132,6 +132,7 @@ impl TreeSummary { repo: &'_ Repository, ids: &EitherOrBoth, summary_map: &mut BTreeMap, + p: &impl Progress, ) -> Result<()> where P: ProgressBars, @@ -139,10 +140,10 @@ impl TreeSummary { { let (left, right) = ids.as_ref().left_and_right(); if let Some(id) = left { - let _ = Self::from_tree(repo, *id, summary_map)?; + let _ = Self::from_tree(repo, *id, summary_map, p)?; } if let Some(id) = right { - let _ = Self::from_tree(repo, *id, summary_map)?; + let _ = Self::from_tree(repo, *id, summary_map, p)?; } Ok(()) } @@ -151,9 +152,9 @@ impl TreeSummary { repo: &'_ Repository, id: TreeId, summary_map: &mut BTreeMap, + p: &impl Progress, ) -> Result where - P: ProgressBars, S: IndexedFull, { if let Some(summary) = summary_map.get(&id) { @@ -163,10 +164,11 @@ impl TreeSummary { let mut summary = Self::default(); let tree = repo.get_tree(&id)?; + p.inc(1); for node in &tree.nodes { summary.update_from_node(node); if let Some(id) = node.subtree { - summary.update(Self::from_tree(repo, id, summary_map)?); + summary.update(Self::from_tree(repo, id, summary_map, p)?); } } _ = summary_map.insert(id, summary.clone()); @@ -388,7 +390,10 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } pub fn compute_summary(&mut self) -> Result<()> { - TreeSummary::from_repo(self.repo, &self.tree_ids, &mut self.summary_map)?; + let pb = self.repo.progress_bars(); + let p = pb.progress_counter("computing (sub)-dir information"); + TreeSummary::from_repo(self.repo, &self.tree_ids, &mut self.summary_map, &p)?; + p.finish(); self.update_table(); Ok(()) } From b06e5314ea842d722edb870fea1e9b92929899ea Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Wed, 18 Jun 2025 22:28:33 +0200 Subject: [PATCH 07/19] add current snapshot/path --- src/commands/tui/diff.rs | 56 ++++++++++++++++++++++++++++------- src/commands/tui/ls.rs | 30 +++++++++++-------- src/commands/tui/snapshots.rs | 6 ++-- 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 73f660a70..3cde649ed 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -4,7 +4,7 @@ use std::{ path::PathBuf, }; -use anyhow::Result; +use anyhow::{Result, bail}; use crossterm::event::{Event, KeyCode, KeyEventKind}; use itertools::{EitherOrBoth, Itertools}; use ratatui::{ @@ -180,8 +180,10 @@ pub(crate) struct Diff<'a, P, S> { current_screen: CurrentScreen, table: WithBlock, repo: &'a Repository, - snapshot: SnapshotFile, - path: PathBuf, + snapshot_left: SnapshotFile, + snapshot_right: SnapshotFile, + path_left: PathBuf, + path_right: PathBuf, trees: Vec<(DiffTree, EitherOrBoth, usize)>, // Stack of parent trees with position tree: DiffTree, tree_ids: EitherOrBoth, @@ -197,7 +199,13 @@ pub enum DiffResult { } impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { - pub fn new(repo: &'a Repository, left: TreeId, right: TreeId) -> Result { + pub fn new( + repo: &'a Repository, + snap_left: SnapshotFile, + snap_right: SnapshotFile, + path_left: &str, + path_right: &str, + ) -> Result { let header = [ "Name", "Time", @@ -211,17 +219,30 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { .map(Text::from) .collect(); - let tree_ids = EitherOrBoth::Both(left, right); + let left = repo + .node_from_snapshot_and_path(&snap_left, path_left)? + .subtree; + let right = repo + .node_from_snapshot_and_path(&snap_right, path_right)? + .subtree; + let tree_ids = match (left, right) { + (Some(l), Some(r)) => EitherOrBoth::Both(l, r), + (Some(l), None) => EitherOrBoth::Left(l), + (None, Some(r)) => EitherOrBoth::Right(r), + (None, None) => bail!("no trees given"), + }; let mut tree = DiffTree::from_trees(repo, &tree_ids)?; let mut app = Self { current_screen: CurrentScreen::Snapshot, table: WithBlock::new(SelectTable::new(header), Block::new()), repo, - snapshot: SnapshotFile::default(), - path: PathBuf::new(), + snapshot_left: snap_left, + snapshot_right: snap_right, + path_left: path_left.parse()?, + path_right: path_right.parse()?, trees: Vec::new(), tree: DiffTree::default(), - tree_ids: EitherOrBoth::Both(left, right), + tree_ids, summary_map: BTreeMap::new(), ignore_metadata: true, ignore_identical: true, @@ -337,7 +358,19 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.table.block = Block::new() .borders(Borders::BOTTOM | Borders::TOP) - .title(format!("{}:{}", self.snapshot.id, self.path.display())) + .title(format!( + "{} | {}", + if self.tree_ids.has_left() { + format!("{}:{}", self.snapshot_left.id, self.path_left.display()) + } else { + format!("({})", self.snapshot_left.id) + }, + if self.tree_ids.has_right() { + format!("{}:{}", self.snapshot_right.id, self.path_right.display()) + } else { + format!("({})", self.snapshot_right.id) + }, + )) .title_alignment(Alignment::Center); self.table.widget.select(old_selection); } @@ -346,6 +379,8 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { if let Some(idx) = self.table.widget.selected() { let node = &self.tree.nodes[idx]; if let Some(tree_ids) = node.subtrees() { + self.path_left.push(node.name()); + self.path_right.push(node.name()); let tree = std::mem::take(&mut self.tree); self.trees.push((tree, self.tree_ids.clone(), idx)); let mut tree = DiffTree::from_trees(self.repo, &tree_ids)?; @@ -364,7 +399,8 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } pub fn goback(&mut self) { - _ = self.path.pop(); + _ = self.path_left.pop(); + _ = self.path_right.pop(); if let Some((tree, tree_ids, idx)) = self.trees.pop() { self.tree = tree; self.tree_ids = tree_ids; diff --git a/src/commands/tui/ls.rs b/src/commands/tui/ls.rs index 71b5be775..ea3ecc131 100644 --- a/src/commands/tui/ls.rs +++ b/src/commands/tui/ls.rs @@ -15,15 +15,18 @@ use rustic_core::{ }; use style::palette::tailwind; -use crate::commands::{ - ls::{NodeLs, Summary}, - tui::{ - restore::Restore, - widgets::{ - Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, TextInputResult, - WithBlock, popup_prompt, popup_scrollable_text, popup_text, +use crate::{ + commands::{ + ls::{NodeLs, Summary}, + tui::{ + restore::Restore, + widgets::{ + Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, + TextInputResult, WithBlock, popup_prompt, popup_scrollable_text, popup_text, + }, }, }, + helpers::bytes_size_to_string, }; use super::widgets::PopUpInput; @@ -40,15 +43,18 @@ enum CurrentScreen<'a, P, S> { const INFO_TEXT: &str = "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (v) view | (r) restore | (?) show all commands"; const HELP_TEXT: &str = r" +Diff Commands: + + v : view file contents (text files only, up to 1MiB) + r : restore selected item + n : toggle numeric IDs + s : compute information for (sub)-dirs + General Commands: q,Esc : exit Enter : enter dir Backspace : return to parent dir - v : view file contents (text files only, up to 1MiB) - r : restore selected item - n : toggle numeric IDs - s : compute cumulative summary ? : show this help page "; @@ -114,7 +120,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshot<'a, P, S> { ) }; let name = node.name().to_string_lossy().to_string(); - let size = node.meta.size.to_string(); + let size = bytes_size_to_string(node.meta.size); let mtime = node.meta.mtime.map_or_else( || "?".to_string(), |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")), diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index dcd8afa7f..7b1c1bad8 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -498,9 +498,9 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { .zip(self.snaps_status.iter()) .filter_map(|(snap, status)| status.marked.then_some(snap)) .collect(); - let left = snaps[0].tree; - let right = snaps[1].tree; - Some(Diff::new(self.repo, left, right)).transpose() + let left = snaps[0]; + let right = snaps[1]; + Some(Diff::new(self.repo, left.clone(), right.clone(), "", "")).transpose() } pub fn count_marked_snaps(&self) -> usize { From cd7d0974745f01b3a76dffffb32e8597684d274a Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Thu, 19 Jun 2025 21:19:25 +0200 Subject: [PATCH 08/19] use NodeDiff in traditional diff command --- src/commands/diff.rs | 182 +++++++++++++++++++------------------------ 1 file changed, 82 insertions(+), 100 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 49611154a..faa39c72c 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -4,9 +4,11 @@ use crate::{Application, RUSTIC_APP, repository::CliIndexedRepo, status_err}; use abscissa_core::{Command, Runnable, Shutdown}; use clap::ValueHint; +use itertools::{EitherOrBoth, Itertools}; use log::debug; use std::{ + cmp::Ordering, fmt::{Display, Write}, path::{Path, PathBuf}, }; @@ -209,10 +211,17 @@ fn identical_content_local( pub enum NodeDiff { Identical, - Added, - Removed, + FileAdded, + DirAdded, + SymlinkAdded, + OtherAdded, + FileRemoved, + DirRemoved, + SymlinkRemoved, + OtherRemoved, TypeChanged, - Modified, + FileChanged, + DirChanged, MetaDataChanged, SymlinkChanged, } @@ -232,8 +241,18 @@ impl NodeDiff { equal_content: impl Fn(&Node, &Node) -> Result, ) -> Result { let result = match (node1, node2) { - (None, Some(_)) => Self::Added, - (Some(_), None) => Self::Removed, + (None, Some(node2)) => match node2.node_type { + NodeType::File => Self::FileAdded, + NodeType::Dir => Self::DirAdded, + NodeType::Symlink { .. } => Self::SymlinkAdded, + _ => Self::OtherAdded, + }, + (Some(node1), None) => match node1.node_type { + NodeType::File => Self::FileRemoved, + NodeType::Dir => Self::DirRemoved, + NodeType::Symlink { .. } => Self::SymlinkRemoved, + _ => Self::OtherRemoved, + }, (Some(node1), Some(node2)) => { let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. }) && matches!(&node2.node_type, NodeType::Symlink { .. }); @@ -242,8 +261,8 @@ impl NodeDiff { // for this reason we check: // that their type is different AND that they are not both symlinks tpe if tpe != &node2.node_type && !are_both_symlink => Self::TypeChanged, - NodeType::File if !equal_content(node1, node2)? => Self::Modified, - NodeType::Dir if node1.subtree != node2.subtree => Self::Modified, + NodeType::File if !equal_content(node1, node2)? => Self::FileChanged, + NodeType::Dir if node1.subtree != node2.subtree => Self::DirChanged, NodeType::Symlink { .. } if node1.node_type.to_link() != node2.node_type.to_link() => { @@ -274,10 +293,10 @@ impl Display for NodeDiff { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let c = match self { Self::Identical => '=', - Self::Added => '+', - Self::Removed => '-', + Self::FileAdded | Self::DirAdded | Self::SymlinkAdded | Self::OtherAdded => '+', + Self::FileRemoved | Self::DirRemoved | Self::SymlinkRemoved | Self::OtherRemoved => '-', Self::TypeChanged => 'T', - Self::Modified => 'M', + Self::FileChanged | Self::DirChanged => 'M', Self::MetaDataChanged | Self::SymlinkChanged => 'U', }; f.write_char(c) @@ -287,11 +306,13 @@ impl Display for NodeDiff { /// Statistics about the differences listed with the [`DiffCmd`] command #[derive(Default)] struct DiffStatistics { + identical: usize, files_added: usize, files_removed: usize, files_changed: usize, directories_added: usize, directories_removed: usize, + directories_changed: usize, others_added: usize, others_removed: usize, node_type_changed: usize, @@ -302,38 +323,23 @@ struct DiffStatistics { } impl DiffStatistics { - fn removed_node(&mut self, node_type: &NodeType) { - match node_type { - NodeType::File => self.files_removed += 1, - NodeType::Dir => self.directories_removed += 1, - NodeType::Symlink { .. } => self.symlink_removed += 1, - _ => self.others_removed += 1, - } - } - - fn added_node(&mut self, node_type: &NodeType) { - match node_type { - NodeType::File => self.files_added += 1, - NodeType::Dir => self.directories_added += 1, - NodeType::Symlink { .. } => self.symlink_added += 1, - _ => self.others_added += 1, - } - } - - fn changed_file(&mut self) { - self.files_changed += 1; - } - - fn changed_node_type(&mut self) { - self.node_type_changed += 1; - } - - fn changed_metadata(&mut self) { - self.metadata_changed += 1; - } - - fn changed_symlink(&mut self) { - self.symlink_changed += 1; + fn apply(&mut self, diff: NodeDiff) { + match diff { + NodeDiff::Identical => self.identical += 1, + NodeDiff::FileAdded => self.files_added += 1, + NodeDiff::DirAdded => self.directories_added += 1, + NodeDiff::SymlinkAdded => self.symlink_added += 1, + NodeDiff::OtherAdded => self.others_added += 1, + NodeDiff::FileRemoved => self.files_removed += 1, + NodeDiff::DirRemoved => self.directories_removed += 1, + NodeDiff::SymlinkRemoved => self.symlink_removed += 1, + NodeDiff::OtherRemoved => self.others_removed += 1, + NodeDiff::FileChanged => self.files_changed += 1, + NodeDiff::DirChanged => self.directories_changed += 1, + NodeDiff::TypeChanged => self.node_type_changed += 1, + NodeDiff::MetaDataChanged => self.metadata_changed += 1, + NodeDiff::SymlinkChanged => self.symlink_changed += 1, + }; } } @@ -394,77 +400,53 @@ impl Display for DiffStatistics { /// // TODO!: add errors! fn diff( - mut tree_streamer1: impl Iterator>, - mut tree_streamer2: impl Iterator>, + tree_streamer1: impl Iterator>, + tree_streamer2: impl Iterator>, no_content: bool, file_identical: impl Fn(&Path, &Node, &Node) -> Result, metadata: bool, ) -> Result<()> { - let mut item1 = tree_streamer1.next().transpose()?; - let mut item2 = tree_streamer2.next().transpose()?; + let compare_streamer = tree_streamer1.merge_join_by(tree_streamer2, |left, right| { + let Ok(left) = left else { + return Ordering::Less; + }; + let Ok(right) = right else { + return Ordering::Greater; + }; + left.0.cmp(&right.0) + }); let mut diff_statistics = DiffStatistics::default(); - loop { - match (&item1, &item2) { - (None, None) => break, - (Some(i1), None) => { - println!("- {:?}", i1.0); - diff_statistics.removed_node(&i1.1.node_type); - item1 = tree_streamer1.next().transpose()?; - } - (None, Some(i2)) => { - println!("+ {:?}", i2.0); - diff_statistics.added_node(&i2.1.node_type); - item2 = tree_streamer2.next().transpose()?; + for item in compare_streamer { + let (path, node1, node2) = match item { + EitherOrBoth::Left(l) => { + let l = l?; + (l.0, Some(l.1), None) } - (Some(i1), Some(i2)) if i1.0 < i2.0 => { - println!("- {:?}", i1.0); - diff_statistics.removed_node(&i1.1.node_type); - item1 = tree_streamer1.next().transpose()?; + EitherOrBoth::Right(r) => { + let r = r?; + (r.0, None, Some(r.1)) } - (Some(i1), Some(i2)) if i1.0 > i2.0 => { - println!("+ {:?}", i2.0); - diff_statistics.added_node(&i2.1.node_type); - item2 = tree_streamer2.next().transpose()?; + EitherOrBoth::Both(l, r) => { + let (r, l) = (r?, l?); + (l.0, Some(l.1), Some(r.1)) } - (Some(i1), Some(i2)) => { - let path = &i1.0; - let node1 = &i1.1; - let node2 = &i2.1; + }; - let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. }) - && matches!(&node2.node_type, NodeType::Symlink { .. }); - match &node1.node_type { - // if node1.node_type != node2.node_type, they could be different symlinks, - // for this reason we check: - // that their type is different AND that they are not both symlinks - tpe if tpe != &node2.node_type && !are_both_symlink => { - // type was changed - println!("T {path:?}"); - diff_statistics.changed_node_type(); - } - NodeType::File if !no_content && !file_identical(path, node1, node2)? => { - println!("M {path:?}"); - diff_statistics.changed_file(); - } - NodeType::File if metadata && node1.meta != node2.meta => { - println!("U {path:?}"); - diff_statistics.changed_metadata(); - } - NodeType::Symlink { .. } => { - if node1.node_type.to_link() != node2.node_type.to_link() { - println!("U {path:?}"); - diff_statistics.changed_symlink(); - } - } - _ => {} // no difference to show - } - item1 = tree_streamer1.next().transpose()?; - item2 = tree_streamer2.next().transpose()?; - } + let mut diff = NodeDiff::try_from(node1.as_ref(), node2.as_ref(), |n1, n2| { + Ok(no_content || file_identical(&path, n1, n2)?) + })?; + if !metadata { + diff = diff.ignore_metadata(); + } + + if !matches!(diff, NodeDiff::Identical | NodeDiff::DirChanged) { + println!("{diff} {:?}", path); + diff_statistics.apply(diff); } } + println!("{diff_statistics}"); Ok(()) } From e0c349ef3f675aedb91f47a849e7e04eebb448da Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Fri, 20 Jun 2025 07:09:26 +0200 Subject: [PATCH 09/19] NodeDiff: refactor identical fn --- src/commands/diff.rs | 18 ++++++++++++++---- src/commands/tui/diff.rs | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index faa39c72c..998b33162 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -227,6 +227,12 @@ pub enum NodeDiff { } impl NodeDiff { + pub fn diff(node1: Option<&Node>, node2: Option<&Node>) -> Self { + Self::from(node1, node2, |node1, node2| { + node1.content == node2.content && node1.subtree == node2.subtree + }) + } + pub fn from( node1: Option<&Node>, node2: Option<&Node>, @@ -261,13 +267,13 @@ impl NodeDiff { // for this reason we check: // that their type is different AND that they are not both symlinks tpe if tpe != &node2.node_type && !are_both_symlink => Self::TypeChanged, - NodeType::File if !equal_content(node1, node2)? => Self::FileChanged, - NodeType::Dir if node1.subtree != node2.subtree => Self::DirChanged, NodeType::Symlink { .. } if node1.node_type.to_link() != node2.node_type.to_link() => { Self::SymlinkChanged } + NodeType::File if !equal_content(node1, node2)? => Self::FileChanged, + NodeType::Dir if !equal_content(node1, node2)? => Self::DirChanged, _ if node1.meta != node2.meta => Self::MetaDataChanged, _ => Self::Identical, } @@ -435,13 +441,17 @@ fn diff( }; let mut diff = NodeDiff::try_from(node1.as_ref(), node2.as_ref(), |n1, n2| { - Ok(no_content || file_identical(&path, n1, n2)?) + Ok(match n1.node_type { + NodeType::File => no_content || file_identical(&path, n1, n2)?, + NodeType::Dir => true, + _ => false, + }) })?; if !metadata { diff = diff.ignore_metadata(); } - if !matches!(diff, NodeDiff::Identical | NodeDiff::DirChanged) { + if !diff.is_identical() { println!("{diff} {:?}", path); diff_statistics.apply(diff); } diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 3cde649ed..74628a1e1 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -255,7 +255,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { fn node_changed(&self, node: &DiffNode) -> NodeDiff { let (left, right) = node.0.as_ref().left_and_right(); - let mut changed = NodeDiff::from(left, right, |n1, n2| n1.content == n2.content); + let mut changed = NodeDiff::diff(left, right); if self.ignore_metadata { changed = changed.ignore_metadata(); } From 0ff3e6ce75c942caaf3f33d8a636cb90cb3cba4c Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Sat, 21 Jun 2025 09:45:59 +0200 Subject: [PATCH 10/19] add option --interactive to diff command --- src/commands/diff.rs | 33 ++++++++++++++++++++- src/commands/snapshots.rs | 21 ++++++++++++-- src/commands/tui.rs | 54 ++++++++++++++--------------------- src/commands/tui/diff.rs | 19 ++++++++++-- src/commands/tui/snapshots.rs | 11 +++++-- 5 files changed, 96 insertions(+), 42 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 998b33162..264209683 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -17,10 +17,13 @@ use anyhow::{Context, Result, bail}; use rustic_core::{ IndexedFull, LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions, - LsOptions, ReadSource, ReadSourceEntry, Repository, RusticResult, + LsOptions, Progress, ProgressBars, ReadSource, ReadSourceEntry, Repository, RusticResult, repofile::{Node, NodeType}, }; +#[cfg(feature = "tui")] +use crate::commands::tui; + /// `diff` subcommand #[derive(clap::Parser, Command, Debug)] pub(crate) struct DiffCmd { @@ -47,6 +50,11 @@ pub(crate) struct DiffCmd { /// Ignore options #[clap(flatten)] ignore_opts: LocalSourceFilterOptions, + + #[cfg(feature = "tui")] + /// Run in interactive UI mode + #[clap(long, short)] + pub interactive: bool, } impl Runnable for DiffCmd { @@ -80,6 +88,29 @@ impl DiffCmd { let snap1 = &snaps[0]; let snap2 = &snaps[1]; + #[cfg(feature = "tui")] + if self.interactive { + return tui::run(|progress| { + let config = RUSTIC_APP.config(); + config + .repository + .run_indexed_with_progress(progress.clone(), |repo| { + let p = progress + .progress_spinner("starting rustic in interactive mode..."); + p.finish(); + // create app and run it + let diff = tui::Diff::new( + &repo, + snap1.clone(), + snap2.clone(), + path1, + path2, + )?; + tui::run_app(progress.terminal, diff) + }) + }); + } + let node1 = repo.node_from_snapshot_and_path(snap1, path1)?; let node2 = repo.node_from_snapshot_and_path(snap2, path2)?; diff --git a/src/commands/snapshots.rs b/src/commands/snapshots.rs index 3a0267bc3..2c03fbcbe 100644 --- a/src/commands/snapshots.rs +++ b/src/commands/snapshots.rs @@ -16,12 +16,12 @@ use itertools::Itertools; use serde::Serialize; use rustic_core::{ - SnapshotGroup, SnapshotGroupCriterion, + Progress, ProgressBars, SnapshotGroup, SnapshotGroupCriterion, repofile::{DeleteOption, SnapshotFile}, }; #[cfg(feature = "tui")] -use super::tui; +use crate::commands::tui; /// `snapshot` subcommand #[derive(clap::Parser, Command, Debug)] @@ -74,7 +74,22 @@ impl SnapshotCmd { fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { #[cfg(feature = "tui")] if self.interactive { - return tui::run(self.group_by); + return tui::run(|progress| { + let config = RUSTIC_APP.config(); + config + .repository + .run_indexed_with_progress(progress.clone(), |repo| { + let p = progress.progress_spinner("starting rustic in interactive mode..."); + p.finish(); + // create app and run it + let snapshots = tui::Snapshots::new( + &repo, + config.snapshot_filter.clone(), + self.group_by, + )?; + tui::run_app(progress.terminal, snapshots) + }) + }); } let config = RUSTIC_APP.config(); diff --git a/src/commands/tui.rs b/src/commands/tui.rs index ba4fc2deb..19cf38921 100644 --- a/src/commands/tui.rs +++ b/src/commands/tui.rs @@ -6,33 +6,35 @@ mod restore; mod snapshots; mod tree; mod widgets; - -use crossterm::event::{KeyEvent, KeyModifiers}; -use progress::TuiProgressBars; -use scopeguard::defer; -use snapshots::Snapshots; +pub use diff::Diff; +pub use snapshots::Snapshots; use std::io; use std::sync::{Arc, RwLock}; -use crate::{Application, RUSTIC_APP}; - use anyhow::Result; +use crossterm::event::{KeyEvent, KeyModifiers}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; +use progress::TuiProgressBars; use ratatui::prelude::*; -use rustic_core::{IndexedFull, Progress, ProgressBars, SnapshotGroupCriterion}; +use scopeguard::defer; +use widgets::{Draw, ProcessEvent}; -struct App<'a, P, S> { - snapshots: Snapshots<'a, P, S>, +pub trait TuiResult { + fn exit(&self) -> bool; } -pub fn run(group_by: SnapshotGroupCriterion) -> Result<()> { - let config = RUSTIC_APP.config(); +impl TuiResult for bool { + fn exit(&self) -> bool { + *self + } +} +pub fn run(f: impl FnOnce(TuiProgressBars) -> Result<()>) -> Result<()> { // setup terminal let terminal = init_terminal()?; let terminal = Arc::new(RwLock::new(terminal)); @@ -42,21 +44,9 @@ pub fn run(group_by: SnapshotGroupCriterion) -> Result<()> { reset_terminal().unwrap(); } - let progress = TuiProgressBars { - terminal: terminal.clone(), - }; - let res = config - .repository - .run_indexed_with_progress(progress.clone(), |repo| { - let p = progress.progress_spinner("starting rustic in interactive mode..."); - p.finish(); - // create app and run it - let snapshots = Snapshots::new(&repo, config.snapshot_filter.clone(), group_by)?; - let app = App { snapshots }; - run_app(terminal, app) - }); - - if let Err(err) = res { + let progress = TuiProgressBars { terminal }; + + if let Err(err) = f(progress) { println!("{err:?}"); } @@ -83,9 +73,9 @@ fn reset_terminal() -> Result<()> { Ok(()) } -fn run_app( +pub fn run_app>, B: Backend>( terminal: Arc>>, - mut app: App<'_, P, S>, + mut app: A, ) -> Result<()> { loop { _ = terminal.write().unwrap().draw(|f| ui(f, &mut app))?; @@ -100,13 +90,13 @@ fn run_app( { return Ok(()); } - if app.snapshots.input(event)? { + if app.input(event)?.exit() { return Ok(()); } } } -fn ui(f: &mut Frame<'_>, app: &mut App<'_, P, S>) { +fn ui(f: &mut Frame<'_>, app: &mut A) { let area = f.area(); - app.snapshots.draw(area, f); + app.draw(area, f); } diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 74628a1e1..7885f0da8 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -28,6 +28,8 @@ use crate::{ helpers::bytes_size_to_string, }; +use super::TuiResult; + // the states this screen can be in enum CurrentScreen { Snapshot, @@ -176,7 +178,7 @@ impl TreeSummary { } } -pub(crate) struct Diff<'a, P, S> { +pub struct Diff<'a, P, S> { current_screen: CurrentScreen, table: WithBlock, repo: &'a Repository, @@ -198,6 +200,12 @@ pub enum DiffResult { None, } +impl TuiResult for DiffResult { + fn exit(&self) -> bool { + !matches!(self, Self::None) + } +} + impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn new( repo: &'a Repository, @@ -433,8 +441,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.update_table(); Ok(()) } +} - pub fn input(&mut self, event: Event) -> Result { +impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Diff<'a, P, S> { + type Result = Result; + fn input(&mut self, event: Event) -> Result { use KeyCode::{Backspace, Char, Enter, Esc, Left, Right}; match &mut self.current_screen { CurrentScreen::Snapshot => match event { @@ -488,8 +499,10 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } Ok(DiffResult::None) } +} - pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { +impl<'a, P: ProgressBars, S: IndexedFull> Draw for Diff<'a, P, S> { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area); // draw the table diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index 7b1c1bad8..89bffd5c6 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -112,7 +112,7 @@ const HELP_TEXT: &str = r"General Commands: Ctrl-p : remove delete protection for snapshot(s) "; -pub(crate) struct Snapshots<'a, P, S> { +pub struct Snapshots<'a, P, S> { current_screen: CurrentScreen<'a, P, S>, current_view: View, table: WithBlock, @@ -737,8 +737,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { self.apply_view(); Ok(()) } +} - pub fn input(&mut self, event: Event) -> Result { +impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Snapshots<'a, P, S> { + type Result = Result; + fn input(&mut self, event: Event) -> Result { use KeyCode::{Char, Enter, Esc, F, Left, Right}; match &mut self.current_screen { CurrentScreen::Snapshots => { @@ -917,8 +920,10 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { } Ok(false) } +} - pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { +impl<'a, P: ProgressBars, S: IndexedFull> Draw for Snapshots<'a, P, S> { + fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { if let CurrentScreen::Dir(dir) = &mut self.current_screen { dir.draw(area, f); return; From 5da743fad0638b538428fa84149077ccc1479224 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Sun, 22 Jun 2025 06:07:16 +0200 Subject: [PATCH 11/19] use node instead of TreeId --- src/commands/tui/diff.rs | 107 +++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 7885f0da8..e5af54f86 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -4,7 +4,7 @@ use std::{ path::PathBuf, }; -use anyhow::{Result, bail}; +use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEventKind}; use itertools::{EitherOrBoth, Itertools}; use ratatui::{ @@ -57,19 +57,23 @@ General Commands: "; +#[derive(Clone)] struct DiffNode(EitherOrBoth); impl DiffNode { - fn subtrees(&self) -> Option> { + fn only_subtrees(&self) -> Option { let (left, right) = self .0 - .as_ref() - .map_any(|n| n.subtree, |n| n.subtree) + .clone() + .map_any( + |n| n.subtree.is_some().then_some(n), + |n| n.subtree.is_some().then_some(n), + ) .left_and_right(); match (left.flatten(), right.flatten()) { - (Some(l), Some(r)) => Some(EitherOrBoth::Both(l, r)), - (Some(l), None) => Some(EitherOrBoth::Left(l)), - (None, Some(r)) => Some(EitherOrBoth::Right(r)), + (Some(l), Some(r)) => Some(DiffNode(EitherOrBoth::Both(l, r))), + (Some(l), None) => Some(DiffNode(EitherOrBoth::Left(l))), + (None, Some(r)) => Some(DiffNode(EitherOrBoth::Right(r))), (None, None) => None, } } @@ -85,20 +89,26 @@ struct DiffTree { } impl DiffTree { - fn from_trees( + fn from_node( repo: &'_ Repository, - tree_ids: &EitherOrBoth, + node: &DiffNode, ) -> Result { - let left_tree = if let Some(left) = tree_ids.as_ref().left() { - repo.get_tree(left)? - } else { - Tree::default() - }; - let right_tree = if let Some(right) = tree_ids.as_ref().right() { - repo.get_tree(right)? - } else { - Tree::default() + let tree_from_node = |node: Option<&Node>| { + if let Some(left) = node { + if let Some(id) = left.subtree { + repo.get_tree(&id) + } else { + Ok(Tree { + nodes: vec![left.clone()], + }) + } + } else { + Ok(Tree::default()) + } }; + + let left_tree = tree_from_node(node.0.as_ref().left())?; + let right_tree = tree_from_node(node.0.as_ref().right())?; let nodes = left_tree .nodes .into_iter() @@ -132,7 +142,7 @@ impl TreeSummary { fn from_repo( repo: &'_ Repository, - ids: &EitherOrBoth, + ids: &DiffNode, summary_map: &mut BTreeMap, p: &impl Progress, ) -> Result<()> @@ -140,12 +150,16 @@ impl TreeSummary { P: ProgressBars, S: IndexedFull, { - let (left, right) = ids.as_ref().left_and_right(); - if let Some(id) = left { - let _ = Self::from_tree(repo, *id, summary_map, p)?; + let (left, right) = ids.0.as_ref().left_and_right(); + if let Some(node) = left { + if let Some(id) = node.subtree { + let _ = Self::from_tree(repo, id, summary_map, p)?; + } } - if let Some(id) = right { - let _ = Self::from_tree(repo, *id, summary_map, p)?; + if let Some(node) = right { + if let Some(id) = node.subtree { + let _ = Self::from_tree(repo, id, summary_map, p)?; + } } Ok(()) } @@ -173,6 +187,7 @@ impl TreeSummary { summary.update(Self::from_tree(repo, id, summary_map, p)?); } } + _ = summary_map.insert(id, summary.clone()); Ok(summary) } @@ -186,9 +201,9 @@ pub struct Diff<'a, P, S> { snapshot_right: SnapshotFile, path_left: PathBuf, path_right: PathBuf, - trees: Vec<(DiffTree, EitherOrBoth, usize)>, // Stack of parent trees with position + trees: Vec<(DiffTree, DiffNode, usize)>, // Stack of parent trees with position tree: DiffTree, - tree_ids: EitherOrBoth, + node: DiffNode, summary_map: BTreeMap, ignore_metadata: bool, ignore_identical: bool, @@ -227,19 +242,11 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { .map(Text::from) .collect(); - let left = repo - .node_from_snapshot_and_path(&snap_left, path_left)? - .subtree; - let right = repo - .node_from_snapshot_and_path(&snap_right, path_right)? - .subtree; - let tree_ids = match (left, right) { - (Some(l), Some(r)) => EitherOrBoth::Both(l, r), - (Some(l), None) => EitherOrBoth::Left(l), - (None, Some(r)) => EitherOrBoth::Right(r), - (None, None) => bail!("no trees given"), - }; - let mut tree = DiffTree::from_trees(repo, &tree_ids)?; + let left = repo.node_from_snapshot_and_path(&snap_left, path_left)?; + let right = repo.node_from_snapshot_and_path(&snap_right, path_right)?; + let node = DiffNode(EitherOrBoth::Both(left, right)); + + let mut tree = DiffTree::from_node(repo, &node)?; let mut app = Self { current_screen: CurrentScreen::Snapshot, table: WithBlock::new(SelectTable::new(header), Block::new()), @@ -250,7 +257,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { path_right: path_right.parse()?, trees: Vec::new(), tree: DiffTree::default(), - tree_ids, + node, summary_map: BTreeMap::new(), ignore_metadata: true, ignore_identical: true, @@ -368,12 +375,12 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { .borders(Borders::BOTTOM | Borders::TOP) .title(format!( "{} | {}", - if self.tree_ids.has_left() { + if self.node.0.has_left() { format!("{}:{}", self.snapshot_left.id, self.path_left.display()) } else { format!("({})", self.snapshot_left.id) }, - if self.tree_ids.has_right() { + if self.node.0.has_right() { format!("{}:{}", self.snapshot_right.id, self.path_right.display()) } else { format!("({})", self.snapshot_right.id) @@ -386,15 +393,15 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn enter(&mut self) -> Result<()> { if let Some(idx) = self.table.widget.selected() { let node = &self.tree.nodes[idx]; - if let Some(tree_ids) = node.subtrees() { + if let Some(node) = node.only_subtrees() { self.path_left.push(node.name()); self.path_right.push(node.name()); let tree = std::mem::take(&mut self.tree); - self.trees.push((tree, self.tree_ids.clone(), idx)); - let mut tree = DiffTree::from_trees(self.repo, &tree_ids)?; + self.trees.push((tree, self.node.clone(), idx)); + let mut tree = DiffTree::from_node(self.repo, &node)?; tree.nodes.retain(|node| self.show_node(node)); self.tree = tree; - self.tree_ids = tree_ids; + self.node = node; self.table.widget.set_to(0); self.update_table(); } @@ -409,9 +416,9 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn goback(&mut self) { _ = self.path_left.pop(); _ = self.path_right.pop(); - if let Some((tree, tree_ids, idx)) = self.trees.pop() { + if let Some((tree, node, idx)) = self.trees.pop() { self.tree = tree; - self.tree_ids = tree_ids; + self.node = node; self.table.widget.set_to(idx); self.update_table(); } @@ -425,7 +432,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn toggle_ignore_identical(&mut self) -> Result<()> { self.ignore_identical = !self.ignore_identical; - let mut tree = DiffTree::from_trees(self.repo, &self.tree_ids)?; + let mut tree = DiffTree::from_node(self.repo, &self.node)?; tree.nodes.retain(|node| self.show_node(node)); self.tree = tree; @@ -436,7 +443,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { pub fn compute_summary(&mut self) -> Result<()> { let pb = self.repo.progress_bars(); let p = pb.progress_counter("computing (sub)-dir information"); - TreeSummary::from_repo(self.repo, &self.tree_ids, &mut self.summary_map, &p)?; + TreeSummary::from_repo(self.repo, &self.node, &mut self.summary_map, &p)?; p.finish(); self.update_table(); Ok(()) From 4bd24adabed2806af2a97d6f66975099268a68fd Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Sun, 22 Jun 2025 12:55:51 +0200 Subject: [PATCH 12/19] Add 'I' command --- src/commands/tui/diff.rs | 45 +++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index e5af54f86..945de7a21 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -20,6 +20,7 @@ use style::palette::tailwind; use crate::{ commands::{ diff::NodeDiff, + snapshots::fill_table, tui::widgets::{ Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock, popup_prompt, popup_text, @@ -28,12 +29,16 @@ use crate::{ helpers::bytes_size_to_string, }; -use super::TuiResult; +use super::{ + TuiResult, + widgets::{PopUpTable, popup_table}, +}; // the states this screen can be in enum CurrentScreen { - Snapshot, + Diff, ShowHelp(PopUpText), + SnapshotDetails(PopUpTable), PromptExit(PopUpPrompt), PromptLeave(PopUpPrompt), } @@ -248,7 +253,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { let mut tree = DiffTree::from_node(repo, &node)?; let mut app = Self { - current_screen: CurrentScreen::Snapshot, + current_screen: CurrentScreen::Diff, table: WithBlock::new(SelectTable::new(header), Block::new()), repo, snapshot_left: snap_left, @@ -448,6 +453,21 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.update_table(); Ok(()) } + + pub fn snapshot_details(&self) -> PopUpTable { + let mut rows = Vec::new(); + let mut rows_right = Vec::new(); + fill_table(&self.snapshot_left, |title, value| { + rows.push(vec![Text::from(title.to_string()), Text::from(value)]); + }); + fill_table(&self.snapshot_right, |_, value| { + rows_right.push(Text::from(value)); + }); + for (row, right) in rows.iter_mut().zip(rows_right) { + row.push(right); + } + popup_table("snapshot details", rows) + } } impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Diff<'a, P, S> { @@ -455,7 +475,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Diff<'a, P, S> { fn input(&mut self, event: Event) -> Result { use KeyCode::{Backspace, Char, Enter, Esc, Left, Right}; match &mut self.current_screen { - CurrentScreen::Snapshot => match event { + CurrentScreen::Diff => match event { Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { Enter | Right => self.enter()?, Backspace | Left => { @@ -481,26 +501,30 @@ impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Diff<'a, P, S> { Char('m') => self.toggle_ignore_metadata(), Char('d') => self.toggle_ignore_identical()?, Char('s') => self.compute_summary()?, + Char('I') => { + self.current_screen = + CurrentScreen::SnapshotDetails(self.snapshot_details()); + } _ => self.table.input(event), }, _ => {} }, - CurrentScreen::ShowHelp(_) => match event { + CurrentScreen::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event { Event::Key(key) if key.kind == KeyEventKind::Press => { - if matches!(key.code, Char('q' | ' ' | '?') | Esc | Enter) { - self.current_screen = CurrentScreen::Snapshot; + if matches!(key.code, Char('q' | ' ' | 'I' | '?') | Esc | Enter) { + self.current_screen = CurrentScreen::Diff; } } _ => {} }, CurrentScreen::PromptExit(prompt) => match prompt.input(event) { PromptResult::Ok => return Ok(DiffResult::Exit), - PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot, + PromptResult::Cancel => self.current_screen = CurrentScreen::Diff, PromptResult::None => {} }, CurrentScreen::PromptLeave(prompt) => match prompt.input(event) { PromptResult::Ok => return Ok(DiffResult::Return), - PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot, + PromptResult::Cancel => self.current_screen = CurrentScreen::Diff, PromptResult::None => {} }, } @@ -525,7 +549,8 @@ impl<'a, P: ProgressBars, S: IndexedFull> Draw for Diff<'a, P, S> { // draw popups match &mut self.current_screen { - CurrentScreen::Snapshot => {} + CurrentScreen::Diff => {} + CurrentScreen::SnapshotDetails(popup) => popup.draw(area, f), CurrentScreen::ShowHelp(popup) => popup.draw(area, f), CurrentScreen::PromptExit(popup) | CurrentScreen::PromptLeave(popup) => { popup.draw(area, f); From be3fe532fb4dac9344704d61fd0b1795bf5cfe7e Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Sun, 22 Jun 2025 13:27:12 +0200 Subject: [PATCH 13/19] also ignore metadata for trees (if sub-tree-info is computed) --- Cargo.lock | 10 +++++----- src/commands/tui/diff.rs | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1049dbc6..ba3e75b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,7 +3482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.103", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "rustic_backend" version = "0.5.2" -source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" +source = "git+https://github.com/rustic-rs/rustic_core.git#5cccb63a0a6082a8ed344fcf0869968211fbccb2" dependencies = [ "aho-corasick", "backon", @@ -4221,7 +4221,7 @@ checksum = "fbcebf2228827bc4b61cb54dfd84cf43aacf06ca2dfe4c014b136a0e32b876e2" [[package]] name = "rustic_core" version = "0.7.3" -source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" +source = "git+https://github.com/rustic-rs/rustic_core.git#5cccb63a0a6082a8ed344fcf0869968211fbccb2" dependencies = [ "aes256ctr_poly1305aes", "binrw", @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "rustic_testing" version = "0.3.2" -source = "git+https://github.com/rustic-rs/rustic_core.git#c19c05b4920db0dfa6262fb7052d3bc8a6d1149e" +source = "git+https://github.com/rustic-rs/rustic_core.git#5cccb63a0a6082a8ed344fcf0869968211fbccb2" dependencies = [ "aho-corasick", "anyhow", @@ -5855,7 +5855,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 945de7a21..e122bfd5a 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -13,7 +13,7 @@ use ratatui::{ }; use rustic_core::{ DataId, IndexedFull, Progress, ProgressBars, Repository, TreeId, - repofile::{Node, SnapshotFile, Tree}, + repofile::{Metadata, Node, SnapshotFile, Tree}, }; use style::palette::tailwind; @@ -128,6 +128,7 @@ impl DiffTree { #[derive(Default, Clone)] struct TreeSummary { + id_without_meta: TreeId, blobs: BTreeSet, size: u64, } @@ -185,13 +186,25 @@ impl TreeSummary { let mut summary = Self::default(); let tree = repo.get_tree(&id)?; + let mut tree_without_meta = Tree::default(); p.inc(1); for node in &tree.nodes { + let mut node_without_meta = Node::new_node( + node.name().as_os_str(), + node.node_type.clone(), + Metadata::default(), + ); + node_without_meta.content = node.content.clone(); summary.update_from_node(node); if let Some(id) = node.subtree { - summary.update(Self::from_tree(repo, id, summary_map, p)?); + let subtree_summary = Self::from_tree(repo, id, summary_map, p)?; + node_without_meta.subtree = Some(subtree_summary.id_without_meta); + summary.update(subtree_summary); } + tree_without_meta.nodes.push(node_without_meta); } + let (_, id_without_meta) = tree_without_meta.serialize()?; + summary.id_without_meta = id_without_meta; _ = summary_map.insert(id, summary.clone()); Ok(summary) @@ -275,7 +288,22 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { fn node_changed(&self, node: &DiffNode) -> NodeDiff { let (left, right) = node.0.as_ref().left_and_right(); - let mut changed = NodeDiff::diff(left, right); + let mut changed = NodeDiff::from(left, right, |left, right| { + if left.content != right.content { + return false; + } + if self.ignore_metadata { + if let (Some(id_left), Some(id_right)) = (left.subtree, right.subtree) { + if let (Some(summary_left), Some(summary_right)) = ( + self.summary_map.get(&id_left), + self.summary_map.get(&id_right), + ) { + return summary_left.id_without_meta == summary_right.id_without_meta; + } + } + } + left.subtree == right.subtree + }); if self.ignore_metadata { changed = changed.ignore_metadata(); } From c8a3ed91e5610726a37d6fc53304d44eb123b172 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Mon, 23 Jun 2025 16:19:34 +0200 Subject: [PATCH 14/19] clippy lints, add information --- src/commands/diff.rs | 6 ------ src/commands/tui/diff.rs | 46 +++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 264209683..ff04ec648 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -258,12 +258,6 @@ pub enum NodeDiff { } impl NodeDiff { - pub fn diff(node1: Option<&Node>, node2: Option<&Node>) -> Self { - Self::from(node1, node2, |node1, node2| { - node1.content == node2.content && node1.subtree == node2.subtree - }) - } - pub fn from( node1: Option<&Node>, node2: Option<&Node>, diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index e122bfd5a..0c598effd 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -52,6 +52,7 @@ Diff Commands: m : toggle ignoring metadata d : toggle show only different entries s : compute information for (sub-)dirs + I : show information about snapshots General Commands: @@ -66,7 +67,7 @@ General Commands: struct DiffNode(EitherOrBoth); impl DiffNode { - fn only_subtrees(&self) -> Option { + fn only_subtrees(&self) -> Option { let (left, right) = self .0 .clone() @@ -76,9 +77,9 @@ impl DiffNode { ) .left_and_right(); match (left.flatten(), right.flatten()) { - (Some(l), Some(r)) => Some(DiffNode(EitherOrBoth::Both(l, r))), - (Some(l), None) => Some(DiffNode(EitherOrBoth::Left(l))), - (None, Some(r)) => Some(DiffNode(EitherOrBoth::Right(r))), + (Some(l), Some(r)) => Some(Self(EitherOrBoth::Both(l, r))), + (Some(l), None) => Some(Self(EitherOrBoth::Left(l))), + (None, Some(r)) => Some(Self(EitherOrBoth::Right(r))), (None, None) => None, } } @@ -99,17 +100,19 @@ impl DiffTree { node: &DiffNode, ) -> Result { let tree_from_node = |node: Option<&Node>| { - if let Some(left) = node { - if let Some(id) = left.subtree { - repo.get_tree(&id) - } else { - Ok(Tree { - nodes: vec![left.clone()], - }) - } - } else { - Ok(Tree::default()) - } + node.map_or_else( + || Ok(Tree::default()), + |node| { + node.subtree.map_or_else( + || { + Ok(Tree { + nodes: vec![node.clone()], + }) + }, + |id| repo.get_tree(&id), + ) + }, + ) }; let left_tree = tree_from_node(node.0.as_ref().left())?; @@ -406,6 +409,19 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.table.block = Block::new() .borders(Borders::BOTTOM | Borders::TOP) + .title_bottom(format!( + "{} equal, {} metadata", + if self.ignore_identical { + "hide" + } else { + "show" + }, + if self.ignore_metadata { + "with" + } else { + "without" + } + )) .title(format!( "{} | {}", if self.node.0.has_left() { From 8717b3cea701bb244dc666cd8c1a4d8ef9a8aa50 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Mon, 23 Jun 2025 23:24:24 +0200 Subject: [PATCH 15/19] overwork diff statistics --- src/commands/diff.rs | 211 +++++++++++++++++++++------------------ src/commands/tui/diff.rs | 13 ++- 2 files changed, 124 insertions(+), 100 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index ff04ec648..99b626cc9 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -240,24 +240,35 @@ fn identical_content_local( Ok(true) } -pub enum NodeDiff { +#[derive(Clone, Copy)] +pub enum NodeTypeDiff { Identical, - FileAdded, - DirAdded, - SymlinkAdded, - OtherAdded, - FileRemoved, - DirRemoved, - SymlinkRemoved, - OtherRemoved, - TypeChanged, - FileChanged, - DirChanged, + Added, + Removed, + Changed, MetaDataChanged, - SymlinkChanged, } +#[derive(Clone, Copy)] +pub enum NodeDiff { + File(NodeTypeDiff), + Dir(NodeTypeDiff), + Symlink(NodeTypeDiff), + Other(NodeTypeDiff), + TypeChanged, +} +use NodeTypeDiff::*; + impl NodeDiff { + pub fn from_node_type(t: &NodeType, diff: NodeTypeDiff) -> Self { + match t { + NodeType::File => Self::File(diff), + NodeType::Dir => Self::Dir(diff), + NodeType::Symlink { .. } => Self::Symlink(diff), + _ => Self::Other(diff), + } + } + pub fn from( node1: Option<&Node>, node2: Option<&Node>, @@ -272,18 +283,8 @@ impl NodeDiff { equal_content: impl Fn(&Node, &Node) -> Result, ) -> Result { let result = match (node1, node2) { - (None, Some(node2)) => match node2.node_type { - NodeType::File => Self::FileAdded, - NodeType::Dir => Self::DirAdded, - NodeType::Symlink { .. } => Self::SymlinkAdded, - _ => Self::OtherAdded, - }, - (Some(node1), None) => match node1.node_type { - NodeType::File => Self::FileRemoved, - NodeType::Dir => Self::DirRemoved, - NodeType::Symlink { .. } => Self::SymlinkRemoved, - _ => Self::OtherRemoved, - }, + (None, Some(node2)) => Self::from_node_type(&node2.node_type, Added), + (Some(node1), None) => Self::from_node_type(&node1.node_type, Removed), (Some(node1), Some(node2)) => { let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. }) && matches!(&node2.node_type, NodeType::Symlink { .. }); @@ -295,26 +296,39 @@ impl NodeDiff { NodeType::Symlink { .. } if node1.node_type.to_link() != node2.node_type.to_link() => { - Self::SymlinkChanged + Self::Symlink(Changed) + } + t => { + if !equal_content(node1, node2)? { + Self::from_node_type(t, Changed) + } else if node1.meta != node2.meta { + Self::from_node_type(t, MetaDataChanged) + } else { + Self::from_node_type(t, Identical) + } } - NodeType::File if !equal_content(node1, node2)? => Self::FileChanged, - NodeType::Dir if !equal_content(node1, node2)? => Self::DirChanged, - _ if node1.meta != node2.meta => Self::MetaDataChanged, - _ => Self::Identical, } } - (None, None) => Self::Identical, + (None, None) => bail!("nothing to compare!"), }; Ok(result) } - pub fn is_identical(&self) -> bool { - matches!(self, Self::Identical) + pub fn is_identical(self) -> bool { + match self { + Self::File(diff) | Self::Dir(diff) | Self::Symlink(diff) | Self::Other(diff) => { + matches!(diff, Identical) + } + Self::TypeChanged => false, + } } pub fn ignore_metadata(self) -> Self { match self { - Self::MetaDataChanged => Self::Identical, + Self::File(MetaDataChanged) => Self::File(Identical), + Self::Dir(MetaDataChanged) => Self::Dir(Identical), + Self::Symlink(MetaDataChanged) => Self::Symlink(Identical), + Self::Other(MetaDataChanged) => Self::Other(Identical), d => d, } } @@ -323,82 +337,94 @@ impl NodeDiff { impl Display for NodeDiff { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let c = match self { - Self::Identical => '=', - Self::FileAdded | Self::DirAdded | Self::SymlinkAdded | Self::OtherAdded => '+', - Self::FileRemoved | Self::DirRemoved | Self::SymlinkRemoved | Self::OtherRemoved => '-', + Self::File(diff) | Self::Dir(diff) | Self::Symlink(diff) | Self::Other(diff) => { + match diff { + Identical => '=', + Added => '+', + Removed => '-', + Changed => 'M', + MetaDataChanged => 'U', + } + } Self::TypeChanged => 'T', - Self::FileChanged | Self::DirChanged => 'M', - Self::MetaDataChanged | Self::SymlinkChanged => 'U', }; f.write_char(c) } } +#[derive(Default)] +pub struct DiffTypeStatistic { + pub identical: usize, + pub added: usize, + pub removed: usize, + pub changed: usize, + pub metadata_changed: usize, +} + +impl DiffTypeStatistic { + pub fn apply(&mut self, diff: NodeTypeDiff) { + match diff { + Identical => self.identical += 1, + Added => self.added += 1, + Removed => self.removed += 1, + Changed => self.changed += 1, + MetaDataChanged => self.metadata_changed += 1, + } + } + + pub fn is_empty(&self) -> bool { + self.identical + self.added + self.removed + self.changed + self.metadata_changed == 0 + } +} + +impl Display for DiffTypeStatistic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{} =, {} +, {} -, {} M, {} U", + self.identical, self.added, self.removed, self.changed, self.metadata_changed + ))?; + Ok(()) + } +} + /// Statistics about the differences listed with the [`DiffCmd`] command #[derive(Default)] -struct DiffStatistics { - identical: usize, - files_added: usize, - files_removed: usize, - files_changed: usize, - directories_added: usize, - directories_removed: usize, - directories_changed: usize, - others_added: usize, - others_removed: usize, - node_type_changed: usize, - metadata_changed: usize, - symlink_added: usize, - symlink_removed: usize, - symlink_changed: usize, +pub struct DiffStatistics { + pub files: DiffTypeStatistic, + pub dirs: DiffTypeStatistic, + pub symlinks: DiffTypeStatistic, + pub others: DiffTypeStatistic, + pub node_type_changed: usize, } impl DiffStatistics { - fn apply(&mut self, diff: NodeDiff) { + pub fn apply(&mut self, diff: NodeDiff) { match diff { - NodeDiff::Identical => self.identical += 1, - NodeDiff::FileAdded => self.files_added += 1, - NodeDiff::DirAdded => self.directories_added += 1, - NodeDiff::SymlinkAdded => self.symlink_added += 1, - NodeDiff::OtherAdded => self.others_added += 1, - NodeDiff::FileRemoved => self.files_removed += 1, - NodeDiff::DirRemoved => self.directories_removed += 1, - NodeDiff::SymlinkRemoved => self.symlink_removed += 1, - NodeDiff::OtherRemoved => self.others_removed += 1, - NodeDiff::FileChanged => self.files_changed += 1, - NodeDiff::DirChanged => self.directories_changed += 1, + NodeDiff::File(t) => self.files.apply(t), + NodeDiff::Dir(t) => self.dirs.apply(t), + NodeDiff::Symlink(t) => self.symlinks.apply(t), + NodeDiff::Other(t) => self.others.apply(t), NodeDiff::TypeChanged => self.node_type_changed += 1, - NodeDiff::MetaDataChanged => self.metadata_changed += 1, - NodeDiff::SymlinkChanged => self.symlink_changed += 1, }; } } impl Display for DiffStatistics { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "Files :\t{} new,\t{} removed,\t{} changed\n", - self.files_added, self.files_removed, self.files_changed - ))?; - // symlink - if self.symlink_added != 0 || self.symlink_removed != 0 || self.symlink_changed != 0 { - f.write_fmt(format_args!( - "Symlinks:\t{} new,\t{} removed,\t{} changed\n", - self.symlink_added, self.symlink_removed, self.symlink_changed - ))?; + if !self.files.is_empty() { + f.write_fmt(format_args!("Files : {}\n", self.files))?; } - f.write_fmt(format_args!( - "Dirs :\t{} new,\t{} removed\n", - self.directories_added, self.directories_removed - ))?; - if self.others_added != 0 || self.others_removed != 0 { - f.write_fmt(format_args!( - "Others :\t{} new,\t{} removed\n", - self.others_added, self.others_removed - ))?; + if !self.dirs.is_empty() { + f.write_fmt(format_args!("Dirs : {}\n", self.dirs))?; + } + if !self.symlinks.is_empty() { + f.write_fmt(format_args!("Symlinks: {}\n", self.symlinks))?; + } + if !self.others.is_empty() { + f.write_fmt(format_args!("Others : {}\n", self.others))?; } - // node type + // node type change if self.node_type_changed != 0 { f.write_fmt(format_args!( "NodeType:\t{} changed\n", @@ -406,13 +432,6 @@ impl Display for DiffStatistics { ))?; } - // metadata - if self.metadata_changed != 0 { - f.write_fmt(format_args!( - "Metadata:\t{} changed\n", - self.metadata_changed - ))?; - } Ok(()) } } @@ -478,8 +497,8 @@ fn diff( if !diff.is_identical() { println!("{diff} {:?}", path); - diff_statistics.apply(diff); } + diff_statistics.apply(diff); } println!("{diff_statistics}"); diff --git a/src/commands/tui/diff.rs b/src/commands/tui/diff.rs index 0c598effd..a3da66468 100644 --- a/src/commands/tui/diff.rs +++ b/src/commands/tui/diff.rs @@ -19,7 +19,7 @@ use style::palette::tailwind; use crate::{ commands::{ - diff::NodeDiff, + diff::{DiffStatistics, NodeDiff}, snapshots::fill_table, tui::widgets::{ Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock, @@ -317,7 +317,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { !self.ignore_identical || !self.node_changed(node).is_identical() } - fn ls_row(&self, node: &DiffNode) -> Vec> { + fn ls_row(&self, node: &DiffNode, stat: &mut DiffStatistics) -> Vec> { let node_info = |node: &Node| { let size = node.subtree.map_or(node.meta.size, |id| { self.summary_map @@ -369,6 +369,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { let right_only = compute_diff(&right_blobs, &left_blobs); let changed = self.node_changed(node); + stat.apply(changed); let name = node.name(); let name = format!("{changed} {}", name.to_string_lossy()); let (left_size, left_mtime) = match &node.0 { @@ -394,6 +395,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { } pub fn update_table(&mut self) { + let mut stat = DiffStatistics::default(); let old_selection = if self.tree.nodes.is_empty() { None } else { @@ -401,7 +403,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { }; let mut rows = Vec::new(); for node in &self.tree.nodes { - let row = self.ls_row(node); + let row = self.ls_row(node, &mut stat); rows.push(row); } @@ -410,7 +412,10 @@ impl<'a, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> { self.table.block = Block::new() .borders(Borders::BOTTOM | Borders::TOP) .title_bottom(format!( - "{} equal, {} metadata", + "total: {}, files: {}, dirs: {}; {} equal, {} metadata", + self.tree.nodes.len(), + stat.files, + stat.dirs, if self.ignore_identical { "hide" } else { From f4932b6f71003fd3d6231c1bb63f261841780f8e Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Tue, 24 Jun 2025 20:44:02 +0200 Subject: [PATCH 16/19] tests and corrections --- src/commands/tui/ls.rs | 2 +- src/commands/tui/snapshots.rs | 9 +++++---- tests/repositories.rs | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/commands/tui/ls.rs b/src/commands/tui/ls.rs index ea3ecc131..216e640e9 100644 --- a/src/commands/tui/ls.rs +++ b/src/commands/tui/ls.rs @@ -43,7 +43,7 @@ enum CurrentScreen<'a, P, S> { const INFO_TEXT: &str = "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (v) view | (r) restore | (?) show all commands"; const HELP_TEXT: &str = r" -Diff Commands: +Ls Commands: v : view file contents (text files only, up to 1MiB) r : restore selected item diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index 89bffd5c6..0c097d8fc 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -488,16 +488,17 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { } pub fn diff(&self) -> Result>> { - if self.count_marked_snaps() != 2 { - return Ok(None); - } - let snaps: Vec<_> = self .snapshots .iter() .zip(self.snaps_status.iter()) .filter_map(|(snap, status)| status.marked.then_some(snap)) .collect(); + + if snaps.len() != 2 { + return Ok(None); + } + let left = snaps[0]; let right = snaps[1]; Some(Diff::new(self.repo, left.clone(), right.clone(), "", "")).transpose() diff --git a/tests/repositories.rs b/tests/repositories.rs index 0e6a73a4c..90497b08c 100644 --- a/tests/repositories.rs +++ b/tests/repositories.rs @@ -107,7 +107,7 @@ fn test_rustic_repo_passes(rustic_repo: Result) -> TestResult<()> { .arg("86371783") .assert() .success() - .stdout(predicates::str::contains("1 removed")); + .stdout(predicates::str::contains("1 -")); } Ok(()) @@ -142,7 +142,7 @@ fn test_restic_repo_with_rustic_passes(restic_repo: Result) -> TestR .arg("af05ecb6") .assert() .success() - .stdout(predicates::str::contains("1 removed")); + .stdout(predicates::str::contains("1 -")); } Ok(()) From 991dd5edec9713d0db69ebbbe10753cd779deb90 Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Thu, 26 Jun 2025 15:31:07 +0200 Subject: [PATCH 17/19] allow easy diff with parent --- src/commands/diff.rs | 4 ++++ src/commands/tui/snapshots.rs | 37 ++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 99b626cc9..310b1194c 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -124,6 +124,10 @@ impl DiffCmd { } (Some(id1), None) => { // diff between snapshot and local path + #[cfg(feature = "tui")] + if self.interactive { + bail!("interactive diff with local path is not yet implemented!"); + } let snap1 = repo.get_snapshot_from_str(id1, |sn| config.snapshot_filter.matches(sn))?; diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index 0c097d8fc..caa3de271 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -103,7 +103,7 @@ const HELP_TEXT: &str = r"General Commands: Ctrl-l : remove label for snapshot(s) d : set description for snapshot(s) Ctrl-d : remove description for snapshot(s) - D : diff snapshots if 2 snapshots are selected + D : diff snapshots if 2 snapshots are selected or with parent t : add tag(s) for snapshot(s) Ctrl-t : remove all tags for snapshot(s) s : set tag(s) for snapshot(s) @@ -495,13 +495,36 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { .filter_map(|(snap, status)| status.marked.then_some(snap)) .collect(); - if snaps.len() != 2 { - return Ok(None); - } + let from_parent = |sn: &SnapshotFile| { + if let Some(parent) = sn.parent { + self.repo + // TODO: get snapshot directly from ID, once implemented in Repository + .get_snapshot_from_str(&parent.to_string(), |_| true) + .ok() + .map(|p| (sn.clone(), p)) + } else { + None + } + }; - let left = snaps[0]; - let right = snaps[1]; - Some(Diff::new(self.repo, left.clone(), right.clone(), "", "")).transpose() + let snaps = match snaps.len() { + 2 => Some((snaps[0].clone(), snaps[1].clone())), + 1 => from_parent(snaps[0]), + 0 => { + if let Some(snap) = self.selected_snapshot() { + from_parent(snap) + } else { + None + } + } + _ => None, + }; + + if let Some((left, right)) = snaps { + Some(Diff::new(self.repo, left, right, "", "")).transpose() + } else { + Ok(None) + } } pub fn count_marked_snaps(&self) -> usize { From 596ab26a0d778bd37fa3b918d6087b84b05d9f0e Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Thu, 26 Jun 2025 15:35:57 +0200 Subject: [PATCH 18/19] use parent as first snapshot --- src/commands/tui/snapshots.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index caa3de271..a9c368111 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -501,7 +501,7 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { // TODO: get snapshot directly from ID, once implemented in Repository .get_snapshot_from_str(&parent.to_string(), |_| true) .ok() - .map(|p| (sn.clone(), p)) + .map(|p| (p, sn.clone())) } else { None } From efc099f45e24ffc3b0fdbaf3c3d44cbe803cfb0d Mon Sep 17 00:00:00 2001 From: Alexander Weiss Date: Thu, 26 Jun 2025 15:43:56 +0200 Subject: [PATCH 19/19] clippy --- src/commands/tui/snapshots.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/commands/tui/snapshots.rs b/src/commands/tui/snapshots.rs index a9c368111..2f3c9cba8 100644 --- a/src/commands/tui/snapshots.rs +++ b/src/commands/tui/snapshots.rs @@ -496,27 +496,19 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> { .collect(); let from_parent = |sn: &SnapshotFile| { - if let Some(parent) = sn.parent { + sn.parent.and_then(|parent| { self.repo // TODO: get snapshot directly from ID, once implemented in Repository .get_snapshot_from_str(&parent.to_string(), |_| true) .ok() .map(|p| (p, sn.clone())) - } else { - None - } + }) }; let snaps = match snaps.len() { 2 => Some((snaps[0].clone(), snaps[1].clone())), 1 => from_parent(snaps[0]), - 0 => { - if let Some(snap) = self.selected_snapshot() { - from_parent(snap) - } else { - None - } - } + 0 => self.selected_snapshot().and_then(from_parent), _ => None, };