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>> { + let snaps: Vec<_> = self + .snapshots + .iter() + .zip(self.snaps_status.iter()) + .filter_map(|(snap, status)| status.marked.then_some(snap)) + .collect(); + + let from_parent = |sn: &SnapshotFile| { + 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())) + }) + }; + + let snaps = match snaps.len() { + 2 => Some((snaps[0].clone(), snaps[1].clone())), + 1 => from_parent(snaps[0]), + 0 => self.selected_snapshot().and_then(from_parent), + _ => 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 { self.snaps_status.iter().filter(|s| s.marked).count() } @@ -718,8 +753,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 => { @@ -805,6 +843,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,16 +928,28 @@ 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) } +} - 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; } + 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 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(())