diff --git a/Cargo.lock b/Cargo.lock
index 299e99f1e..ba3e75b57 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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#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#f7cd46825e2a29512d4d7e7b7ce0c3f424ea49e8"
+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#f7cd46825e2a29512d4d7e7b7ce0c3f424ea49e8"
+source = "git+https://github.com/rustic-rs/rustic_core.git#5cccb63a0a6082a8ed344fcf0869968211fbccb2"
dependencies = [
"aho-corasick",
"anyhow",
diff --git a/src/commands/diff.rs b/src/commands/diff.rs
index f9521fff3..310b1194c 100644
--- a/src/commands/diff.rs
+++ b/src/commands/diff.rs
@@ -4,10 +4,12 @@ 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::{
- fmt::Display,
+ cmp::Ordering,
+ fmt::{Display, Write},
path::{Path, PathBuf},
};
@@ -15,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 {
@@ -45,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 {
@@ -78,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)?;
@@ -91,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))?;
@@ -207,84 +244,191 @@ fn identical_content_local
(
Ok(true)
}
-/// Statistics about the differences listed with the [`DiffCmd`] command
-#[derive(Default)]
-struct DiffStatistics {
- files_added: usize,
- files_removed: usize,
- files_changed: usize,
- directories_added: usize,
- directories_removed: usize,
- others_added: usize,
- others_removed: usize,
- node_type_changed: usize,
- metadata_changed: usize,
- symlink_added: usize,
- symlink_removed: usize,
- symlink_changed: usize,
+#[derive(Clone, Copy)]
+pub enum NodeTypeDiff {
+ Identical,
+ Added,
+ Removed,
+ Changed,
+ MetaDataChanged,
}
-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,
+#[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),
}
}
- 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,
+ 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(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 { .. });
+ 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::Symlink { .. }
+ if node1.node_type.to_link() != node2.node_type.to_link() =>
+ {
+ 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)
+ }
+ }
+ }
+ }
+ (None, None) => bail!("nothing to compare!"),
+ };
+ Ok(result)
+ }
+
+ 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,
}
}
- fn changed_file(&mut self) {
- self.files_changed += 1;
+ pub fn ignore_metadata(self) -> Self {
+ match self {
+ 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,
+ }
}
+}
- fn changed_node_type(&mut self) {
- self.node_type_changed += 1;
+impl Display for NodeDiff {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let c = match self {
+ Self::File(diff) | Self::Dir(diff) | Self::Symlink(diff) | Self::Other(diff) => {
+ match diff {
+ Identical => '=',
+ Added => '+',
+ Removed => '-',
+ Changed => 'M',
+ MetaDataChanged => 'U',
+ }
+ }
+ Self::TypeChanged => 'T',
+ };
+ f.write_char(c)
}
+}
- fn changed_metadata(&mut self) {
- self.metadata_changed += 1;
+#[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,
+ }
}
- fn changed_symlink(&mut self) {
- self.symlink_changed += 1;
+ pub fn is_empty(&self) -> bool {
+ self.identical + self.added + self.removed + self.changed + self.metadata_changed == 0
}
}
-impl Display for DiffStatistics {
+impl Display for DiffTypeStatistic {
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
+ "{} =, {} +, {} -, {} M, {} U",
+ self.identical, self.added, self.removed, self.changed, self.metadata_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
- ))?;
+ Ok(())
+ }
+}
+
+/// Statistics about the differences listed with the [`DiffCmd`] command
+#[derive(Default)]
+pub struct DiffStatistics {
+ pub files: DiffTypeStatistic,
+ pub dirs: DiffTypeStatistic,
+ pub symlinks: DiffTypeStatistic,
+ pub others: DiffTypeStatistic,
+ pub node_type_changed: usize,
+}
+
+impl DiffStatistics {
+ pub fn apply(&mut self, diff: NodeDiff) {
+ match diff {
+ 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,
+ };
+ }
+}
+
+impl Display for DiffStatistics {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ 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",
@@ -292,13 +436,6 @@ impl Display for DiffStatistics {
))?;
}
- // metadata
- if self.metadata_changed != 0 {
- f.write_fmt(format_args!(
- "Metadata:\t{} changed\n",
- self.metadata_changed
- ))?;
- }
Ok(())
}
}
@@ -317,77 +454,57 @@ 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()?;
+ for item in compare_streamer {
+ let (path, node1, node2) = match item {
+ EitherOrBoth::Left(l) => {
+ let l = l?;
+ (l.0, Some(l.1), None)
}
- (None, Some(i2)) => {
- println!("+ {:?}", i2.0);
- diff_statistics.added_node(&i2.1.node_type);
- item2 = tree_streamer2.next().transpose()?;
+ EitherOrBoth::Right(r) => {
+ let r = r?;
+ (r.0, None, Some(r.1))
}
- (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::Both(l, r) => {
+ let (r, l) = (r?, l?);
+ (l.0, Some(l.1), 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()?;
- }
- (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(match n1.node_type {
+ NodeType::File => no_content || file_identical(&path, n1, n2)?,
+ NodeType::Dir => true,
+ _ => false,
+ })
+ })?;
+ if !metadata {
+ diff = diff.ignore_metadata();
}
+
+ if !diff.is_identical() {
+ println!("{diff} {:?}", path);
+ }
+ diff_statistics.apply(diff);
}
+
println!("{diff_statistics}");
Ok(())
}
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 dbc022d40..19cf38921 100644
--- a/src/commands/tui.rs
+++ b/src/commands/tui.rs
@@ -1,37 +1,40 @@
//! `tui` subcommand
+mod diff;
mod ls;
mod progress;
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));
@@ -41,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:?}");
}
@@ -82,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))?;
@@ -99,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
new file mode 100644
index 000000000..a3da66468
--- /dev/null
+++ b/src/commands/tui/diff.rs
@@ -0,0 +1,609 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ ffi::OsString,
+ path::PathBuf,
+};
+
+use anyhow::Result;
+use crossterm::event::{Event, KeyCode, KeyEventKind};
+use itertools::{EitherOrBoth, Itertools};
+use ratatui::{
+ prelude::*,
+ widgets::{Block, Borders, Paragraph},
+};
+use rustic_core::{
+ DataId, IndexedFull, Progress, ProgressBars, Repository, TreeId,
+ repofile::{Metadata, Node, SnapshotFile, Tree},
+};
+use style::palette::tailwind;
+
+use crate::{
+ commands::{
+ diff::{DiffStatistics, NodeDiff},
+ snapshots::fill_table,
+ tui::widgets::{
+ Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock,
+ popup_prompt, popup_text,
+ },
+ },
+ helpers::bytes_size_to_string,
+};
+
+use super::{
+ TuiResult,
+ widgets::{PopUpTable, popup_table},
+};
+
+// the states this screen can be in
+enum CurrentScreen {
+ Diff,
+ ShowHelp(PopUpText),
+ SnapshotDetails(PopUpTable),
+ PromptExit(PopUpPrompt),
+ PromptLeave(PopUpPrompt),
+}
+
+const INFO_TEXT: &str =
+ "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands";
+
+const HELP_TEXT: &str = r"
+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:
+
+ q,Esc : exit
+ Enter : enter dir
+ Backspace : return to parent dir
+ ? : show this help page
+
+ ";
+
+#[derive(Clone)]
+struct DiffNode(EitherOrBoth);
+
+impl DiffNode {
+ fn only_subtrees(&self) -> Option {
+ let (left, right) = self
+ .0
+ .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(Self(EitherOrBoth::Both(l, r))),
+ (Some(l), None) => Some(Self(EitherOrBoth::Left(l))),
+ (None, Some(r)) => Some(Self(EitherOrBoth::Right(r))),
+ (None, None) => None,
+ }
+ }
+
+ fn name(&self) -> OsString {
+ self.0.as_ref().reduce(|l, _| l).name()
+ }
+}
+
+#[derive(Default)]
+struct DiffTree {
+ nodes: Vec,
+}
+
+impl DiffTree {
+ fn from_node(
+ repo: &'_ Repository
,
+ node: &DiffNode,
+ ) -> Result {
+ let tree_from_node = |node: Option<&Node>| {
+ 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())?;
+ let right_tree = tree_from_node(node.0.as_ref().right())?;
+ let nodes = left_tree
+ .nodes
+ .into_iter()
+ .merge_join_by(right_tree.nodes, |node_l, node_r| {
+ node_l.name().cmp(&node_r.name())
+ })
+ .map(DiffNode)
+ .collect();
+ Ok(Self { nodes })
+ }
+}
+
+#[derive(Default, Clone)]
+struct TreeSummary {
+ id_without_meta: TreeId,
+ blobs: BTreeSet,
+ size: u64,
+}
+
+impl TreeSummary {
+ fn update(&mut self, mut other: Self) {
+ self.blobs.append(&mut other.blobs);
+ 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
,
+ ids: &DiffNode,
+ summary_map: &mut BTreeMap,
+ p: &impl Progress,
+ ) -> Result<()>
+ where
+ P: ProgressBars,
+ S: IndexedFull,
+ {
+ 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(node) = right {
+ if let Some(id) = node.subtree {
+ let _ = Self::from_tree(repo, id, summary_map, p)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn from_tree(
+ repo: &'_ Repository
,
+ id: TreeId,
+ summary_map: &mut BTreeMap,
+ p: &impl Progress,
+ ) -> Result
+ where
+ S: IndexedFull,
+ {
+ if let Some(summary) = summary_map.get(&id) {
+ return Ok(summary.clone());
+ }
+
+ 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 {
+ 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)
+ }
+}
+
+pub struct Diff<'a, P, S> {
+ current_screen: CurrentScreen,
+ table: WithBlock,
+ repo: &'a Repository,
+ snapshot_left: SnapshotFile,
+ snapshot_right: SnapshotFile,
+ path_left: PathBuf,
+ path_right: PathBuf,
+ trees: Vec<(DiffTree, DiffNode, usize)>, // Stack of parent trees with position
+ tree: DiffTree,
+ node: DiffNode,
+ summary_map: BTreeMap,
+ ignore_metadata: bool,
+ ignore_identical: bool,
+}
+
+pub enum DiffResult {
+ Exit,
+ Return,
+ 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,
+ snap_left: SnapshotFile,
+ snap_right: SnapshotFile,
+ path_left: &str,
+ path_right: &str,
+ ) -> Result {
+ let header = [
+ "Name",
+ "Time",
+ "Size",
+ "- RepoSize",
+ "Time",
+ "Size",
+ "+ RepoSize",
+ ]
+ .into_iter()
+ .map(Text::from)
+ .collect();
+
+ 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::Diff,
+ table: WithBlock::new(SelectTable::new(header), Block::new()),
+ repo,
+ snapshot_left: snap_left,
+ snapshot_right: snap_right,
+ path_left: path_left.parse()?,
+ path_right: path_right.parse()?,
+ trees: Vec::new(),
+ tree: DiffTree::default(),
+ node,
+ summary_map: BTreeMap::new(),
+ ignore_metadata: true,
+ ignore_identical: true,
+ };
+ tree.nodes.retain(|node| app.show_node(node));
+ app.tree = tree;
+ app.update_table();
+ Ok(app)
+ }
+
+ fn node_changed(&self, node: &DiffNode) -> NodeDiff {
+ let (left, right) = node.0.as_ref().left_and_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();
+ }
+ changed
+ }
+
+ fn show_node(&self, node: &DiffNode) -> bool {
+ !self.ignore_identical || !self.node_changed(node).is_identical()
+ }
+
+ 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
+ .get(&id)
+ .map_or(node.meta.size, |summary| summary.size)
+ });
+ (
+ bytes_size_to_string(size),
+ node.meta.mtime.map_or_else(
+ || "?".to_string(),
+ |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")),
+ ),
+ )
+ };
+
+ 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(), bytes_size_to_string)
+ }
+ };
+
+ 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.summary_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(summary) = self.summary_map.get(&id) {
+ return summary.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 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 {
+ EitherOrBoth::Left(node) | EitherOrBoth::Both(node, _) => node_info(node),
+ _ => (String::new(), String::new()),
+ };
+ let (right_size, right_mtime) = match &node.0 {
+ 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 mut stat = DiffStatistics::default();
+ 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, &mut stat);
+ rows.push(row);
+ }
+
+ self.table.widget.set_content(rows, 1);
+
+ self.table.block = Block::new()
+ .borders(Borders::BOTTOM | Borders::TOP)
+ .title_bottom(format!(
+ "total: {}, files: {}, dirs: {}; {} equal, {} metadata",
+ self.tree.nodes.len(),
+ stat.files,
+ stat.dirs,
+ if self.ignore_identical {
+ "hide"
+ } else {
+ "show"
+ },
+ if self.ignore_metadata {
+ "with"
+ } else {
+ "without"
+ }
+ ))
+ .title(format!(
+ "{} | {}",
+ if self.node.0.has_left() {
+ format!("{}:{}", self.snapshot_left.id, self.path_left.display())
+ } else {
+ format!("({})", self.snapshot_left.id)
+ },
+ if self.node.0.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);
+ }
+
+ pub fn enter(&mut self) -> Result<()> {
+ if let Some(idx) = self.table.widget.selected() {
+ let node = &self.tree.nodes[idx];
+ 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.node.clone(), idx));
+ let mut tree = DiffTree::from_node(self.repo, &node)?;
+ tree.nodes.retain(|node| self.show_node(node));
+ self.tree = tree;
+ self.node = node;
+ self.table.widget.set_to(0);
+ self.update_table();
+ }
+ }
+ Ok(())
+ }
+
+ pub fn in_root(&self) -> bool {
+ self.trees.is_empty()
+ }
+
+ pub fn goback(&mut self) {
+ _ = self.path_left.pop();
+ _ = self.path_right.pop();
+ if let Some((tree, node, idx)) = self.trees.pop() {
+ self.tree = tree;
+ self.node = node;
+ self.table.widget.set_to(idx);
+ self.update_table();
+ }
+ }
+
+ pub fn toggle_ignore_metadata(&mut self) {
+ self.ignore_metadata = !self.ignore_metadata;
+ self.update_table();
+ }
+
+ pub fn toggle_ignore_identical(&mut self) -> Result<()> {
+ self.ignore_identical = !self.ignore_identical;
+
+ let mut tree = DiffTree::from_node(self.repo, &self.node)?;
+ tree.nodes.retain(|node| self.show_node(node));
+ self.tree = tree;
+
+ self.update_table();
+ Ok(())
+ }
+
+ 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.node, &mut self.summary_map, &p)?;
+ p.finish();
+ 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> {
+ type Result = Result;
+ fn input(&mut self, event: Event) -> Result {
+ use KeyCode::{Backspace, Char, Enter, Esc, Left, Right};
+ match &mut self.current_screen {
+ CurrentScreen::Diff => match event {
+ Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
+ Enter | Right => self.enter()?,
+ Backspace | Left => {
+ 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') => {
+ 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('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::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event {
+ Event::Key(key) if key.kind == KeyEventKind::Press => {
+ 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::Diff,
+ PromptResult::None => {}
+ },
+ CurrentScreen::PromptLeave(prompt) => match prompt.input(event) {
+ PromptResult::Ok => return Ok(DiffResult::Return),
+ PromptResult::Cancel => self.current_screen = CurrentScreen::Diff,
+ PromptResult::None => {}
+ },
+ }
+ Ok(DiffResult::None)
+ }
+}
+
+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
+ 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::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);
+ }
+ }
+ }
+}
diff --git a/src/commands/tui/ls.rs b/src/commands/tui/ls.rs
index 71b5be775..216e640e9 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"
+Ls 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 7b2ec47f5..2f3c9cba8 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 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)
@@ -109,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,
@@ -484,6 +487,38 @@ impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> {
})
}
+ pub fn diff(&self) -> Result