Skip to main content

rustic_rs/commands/tui/
diff.rs

1use std::{ffi::OsString, path::PathBuf};
2
3use anyhow::Result;
4use crossterm::event::{Event, KeyCode, KeyEventKind};
5use itertools::{EitherOrBoth, Itertools};
6use ratatui::{
7    prelude::*,
8    widgets::{Block, Borders, Paragraph},
9};
10use rustic_core::repofile::{Node, SnapshotFile, Tree};
11use style::palette::tailwind;
12
13use crate::{
14    commands::{
15        diff::{DiffStatistics, NodeDiff},
16        snapshots::fill_table,
17        tui::widgets::{
18            Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock,
19            popup_prompt, popup_text,
20        },
21    },
22    helpers::bytes_size_to_string,
23    repository::IndexedRepo,
24};
25
26use super::{
27    TuiResult,
28    summary::SummaryMap,
29    widgets::{PopUpTable, popup_table},
30};
31
32// the states this screen can be in
33enum CurrentScreen {
34    Diff,
35    ShowHelp(PopUpText),
36    Table(PopUpTable),
37    PromptExit(PopUpPrompt),
38    PromptLeave(PopUpPrompt),
39}
40
41const INFO_TEXT: &str =
42    "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands";
43
44const HELP_TEXT: &str = r"
45Diff Commands:
46
47          m : toggle ignoring metadata
48          d : toggle show only different entries
49          s : compute information for (sub-)dirs and show summary
50          S : compute information for selected nodes and show summary
51          I : show information about snapshots
52
53General Commands:
54
55      q,Esc : exit
56      Enter : enter dir
57  Backspace : return to parent dir
58          ? : show this help page
59
60 ";
61
62#[derive(Clone)]
63pub struct DiffNode(pub EitherOrBoth<Node>);
64
65impl DiffNode {
66    fn only_subtrees(&self) -> Option<Self> {
67        let (left, right) = self
68            .0
69            .clone()
70            .map_any(
71                |n| n.subtree.is_some().then_some(n),
72                |n| n.subtree.is_some().then_some(n),
73            )
74            .left_and_right();
75        match (left.flatten(), right.flatten()) {
76            (Some(l), Some(r)) => Some(Self(EitherOrBoth::Both(l, r))),
77            (Some(l), None) => Some(Self(EitherOrBoth::Left(l))),
78            (None, Some(r)) => Some(Self(EitherOrBoth::Right(r))),
79            (None, None) => None,
80        }
81    }
82
83    fn name(&self) -> OsString {
84        self.0.as_ref().reduce(|l, _| l).name()
85    }
86
87    pub fn map<'a, F, T>(&'a self, f: F) -> EitherOrBoth<T>
88    where
89        F: Fn(&'a Node) -> T,
90    {
91        self.0.as_ref().map_any(&f, &f)
92    }
93}
94
95#[derive(Default)]
96struct DiffTree {
97    nodes: Vec<DiffNode>,
98}
99
100impl DiffTree {
101    fn from_node(repo: &IndexedRepo, node: &DiffNode) -> Result<Self> {
102        let tree_from_node = |node: Option<&Node>| {
103            node.map_or_else(
104                || Ok(Tree::default()),
105                |node| {
106                    node.subtree.map_or_else(
107                        || {
108                            Ok(Tree {
109                                nodes: vec![node.clone()],
110                            })
111                        },
112                        |id| repo.get_tree(&id),
113                    )
114                },
115            )
116        };
117
118        let left_tree = tree_from_node(node.0.as_ref().left())?;
119        let right_tree = tree_from_node(node.0.as_ref().right())?;
120        let nodes = left_tree
121            .nodes
122            .into_iter()
123            .merge_join_by(right_tree.nodes, |node_l, node_r| {
124                node_l.name().cmp(&node_r.name())
125            })
126            .map(DiffNode)
127            .collect();
128        Ok(Self { nodes })
129    }
130}
131
132pub struct Diff<'a> {
133    current_screen: CurrentScreen,
134    table: WithBlock<SelectTable>,
135    repo: &'a IndexedRepo,
136    snapshot_left: SnapshotFile,
137    snapshot_right: SnapshotFile,
138    path_left: PathBuf,
139    path_right: PathBuf,
140    trees: Vec<(DiffTree, DiffNode, usize)>, // Stack of parent trees with position
141    tree: DiffTree,
142    node: DiffNode,
143    summary_map: SummaryMap,
144    ignore_metadata: bool,
145    ignore_identical: bool,
146}
147
148pub enum DiffResult {
149    Exit,
150    Return(SummaryMap),
151    None,
152}
153
154impl TuiResult for DiffResult {
155    fn exit(&self) -> bool {
156        !matches!(self, Self::None)
157    }
158}
159
160impl<'a> Diff<'a> {
161    pub fn new(
162        repo: &'a IndexedRepo,
163        snap_left: SnapshotFile,
164        snap_right: SnapshotFile,
165        path_left: &str,
166        path_right: &str,
167        summary_map: SummaryMap,
168    ) -> Result<Self> {
169        let header = [
170            "Name",
171            "Time",
172            "Size",
173            "- RepoSize",
174            "Time",
175            "Size",
176            "+ RepoSize",
177        ]
178        .into_iter()
179        .map(Text::from)
180        .collect();
181
182        let left = repo.node_from_snapshot_and_path(&snap_left, path_left)?;
183        let right = repo.node_from_snapshot_and_path(&snap_right, path_right)?;
184        let node = DiffNode(EitherOrBoth::Both(left, right));
185
186        let mut tree = DiffTree::from_node(repo, &node)?;
187        let mut app = Self {
188            current_screen: CurrentScreen::Diff,
189            table: WithBlock::new(SelectTable::new(header), Block::new()),
190            repo,
191            snapshot_left: snap_left,
192            snapshot_right: snap_right,
193            path_left: path_left.parse()?,
194            path_right: path_right.parse()?,
195            trees: Vec::new(),
196            tree: DiffTree::default(),
197            node,
198            summary_map,
199            ignore_metadata: true,
200            ignore_identical: true,
201        };
202        tree.nodes.retain(|node| app.show_node(node));
203        app.tree = tree;
204        app.update_table();
205        Ok(app)
206    }
207
208    fn node_changed(&self, node: &DiffNode) -> NodeDiff {
209        let (left, right) = node.0.as_ref().left_and_right();
210        let mut changed = NodeDiff::from(left, right, |left, right| {
211            if left.content != right.content {
212                return false;
213            }
214            if self.ignore_metadata
215                && let (Some(id_left), Some(id_right)) = (left.subtree, right.subtree)
216                && let (Some(summary_left), Some(summary_right)) = (
217                    self.summary_map.get(&id_left),
218                    self.summary_map.get(&id_right),
219                )
220            {
221                return summary_left.id_without_meta == summary_right.id_without_meta;
222            }
223            left.subtree == right.subtree
224        });
225        if self.ignore_metadata {
226            changed = changed.ignore_metadata();
227        }
228        changed
229    }
230
231    fn show_node(&self, node: &DiffNode) -> bool {
232        !self.ignore_identical || !self.node_changed(node).is_identical()
233    }
234
235    fn ls_row(&self, node: &DiffNode, stat: &mut DiffStatistics) -> Vec<Text<'static>> {
236        let node_mtime = |node: &Node| {
237            node.meta.mtime.map_or_else(
238                || "?".to_string(),
239                |t| t.strftime("%Y-%m-%d %H:%M:%S").to_string(),
240            )
241        };
242
243        let statistics = self
244            .summary_map
245            .compute_diff_statistics(node, self.repo)
246            .unwrap_or_default();
247
248        let (left, right) = statistics.stats.as_ref().left_and_right();
249
250        let left_size = left.map_or_else(String::new, |s| bytes_size_to_string(s.summary.size));
251        let right_size = right.map_or_else(String::new, |s| bytes_size_to_string(s.summary.size));
252        let left_only = left.map_or_else(String::new, |s| bytes_size_to_string(s.sizes.repo_size));
253        let right_only =
254            right.map_or_else(String::new, |s| bytes_size_to_string(s.sizes.repo_size));
255
256        let changed = self.node_changed(node);
257        stat.apply(changed);
258        let name = node.name();
259        let name = format!("{changed} {}", name.to_string_lossy());
260        let (left_mtime, right_mtime) = node.map(node_mtime).left_and_right();
261        [
262            name,
263            left_mtime.unwrap_or_default(),
264            left_size,
265            left_only,
266            right_mtime.unwrap_or_default(),
267            right_size,
268            right_only,
269        ]
270        .into_iter()
271        .map(Text::from)
272        .collect()
273    }
274
275    pub fn update_table(&mut self) {
276        let mut stat = DiffStatistics::default();
277        let old_selection = if self.tree.nodes.is_empty() {
278            None
279        } else {
280            Some(self.table.widget.selected().unwrap_or_default())
281        };
282        let mut rows = Vec::new();
283        for node in &self.tree.nodes {
284            let row = self.ls_row(node, &mut stat);
285            rows.push(row);
286        }
287
288        self.table.widget.set_content(rows, 1);
289
290        self.table.block = Block::new()
291            .borders(Borders::BOTTOM | Borders::TOP)
292            .title_bottom(format!(
293                "total: {}, files: {}, dirs: {}; {} equal, {} metadata",
294                self.tree.nodes.len(),
295                stat.files,
296                stat.dirs,
297                if self.ignore_identical {
298                    "hide"
299                } else {
300                    "show"
301                },
302                if self.ignore_metadata {
303                    "with"
304                } else {
305                    "without"
306                }
307            ))
308            .title(format!(
309                "{} | {}",
310                if self.node.0.has_left() {
311                    format!("{}:{}", self.snapshot_left.id, self.path_left.display())
312                } else {
313                    format!("({})", self.snapshot_left.id)
314                },
315                if self.node.0.has_right() {
316                    format!("{}:{}", self.snapshot_right.id, self.path_right.display())
317                } else {
318                    format!("({})", self.snapshot_right.id)
319                },
320            ))
321            .title_alignment(Alignment::Center);
322        self.table.widget.select(old_selection);
323    }
324
325    pub fn selected_node(&self) -> Option<DiffNode> {
326        self.table
327            .widget
328            .selected()
329            .map(|idx| self.tree.nodes[idx].clone())
330    }
331
332    pub fn enter(&mut self) -> Result<()> {
333        if let Some(idx) = self.table.widget.selected() {
334            let node = &self.tree.nodes[idx];
335            if let Some(node) = node.only_subtrees() {
336                self.path_left.push(node.name());
337                self.path_right.push(node.name());
338                let tree = std::mem::take(&mut self.tree);
339                self.trees.push((tree, self.node.clone(), idx));
340                let mut tree = DiffTree::from_node(self.repo, &node)?;
341                tree.nodes.retain(|node| self.show_node(node));
342                self.tree = tree;
343                self.node = node;
344                self.table.widget.set_to(0);
345                self.update_table();
346            }
347        }
348        Ok(())
349    }
350
351    pub fn in_root(&self) -> bool {
352        self.trees.is_empty()
353    }
354
355    pub fn goback(&mut self) {
356        _ = self.path_left.pop();
357        _ = self.path_right.pop();
358        if let Some((tree, node, idx)) = self.trees.pop() {
359            self.tree = tree;
360            self.node = node;
361            self.table.widget.set_to(idx);
362            self.update_table();
363        }
364    }
365
366    pub fn toggle_ignore_metadata(&mut self) {
367        self.ignore_metadata = !self.ignore_metadata;
368        self.update_table();
369    }
370
371    pub fn toggle_ignore_identical(&mut self) -> Result<()> {
372        self.ignore_identical = !self.ignore_identical;
373
374        let mut tree = DiffTree::from_node(self.repo, &self.node)?;
375        tree.nodes.retain(|node| self.show_node(node));
376        self.tree = tree;
377
378        self.update_table();
379        Ok(())
380    }
381
382    pub fn compute_summary(&mut self, node: &DiffNode) -> Result<()> {
383        let p = self
384            .repo
385            .progress_counter("computing (sub)-dir information");
386
387        let (left, right) = node.0.as_ref().left_and_right();
388        if let Some(node) = left
389            && let Some(id) = node.subtree
390        {
391            self.summary_map.compute(self.repo, id, &p)?;
392        }
393        if let Some(node) = right
394            && let Some(id) = node.subtree
395        {
396            self.summary_map.compute(self.repo, id, &p)?;
397        }
398
399        p.finish();
400        self.update_table();
401        Ok(())
402    }
403
404    pub fn show_summary(&mut self, node: &DiffNode) -> Result<PopUpTable> {
405        self.compute_summary(node)?;
406        // Compute total sizes diff
407        let stats = self
408            .summary_map
409            .compute_diff_statistics(node, self.repo)
410            .unwrap_or_default();
411
412        let title_left = if node.0.has_left() {
413            format!("{}:{}", self.snapshot_left.id, self.path_left.display())
414        } else {
415            format!("({})", self.snapshot_left.id)
416        };
417        let title_right = if node.0.has_right() {
418            format!("{}:{}", self.snapshot_right.id, self.path_right.display())
419        } else {
420            format!("({})", self.snapshot_right.id)
421        };
422
423        let rows = stats.table(title_left, title_right);
424        Ok(popup_table("diff summary", rows))
425    }
426
427    pub fn snapshot_details(&self) -> PopUpTable {
428        let mut rows = Vec::new();
429        let mut rows_right = Vec::new();
430        fill_table(&self.snapshot_left, |title, value| {
431            rows.push(vec![Text::from(title.to_string()), Text::from(value)]);
432        });
433        fill_table(&self.snapshot_right, |_, value| {
434            rows_right.push(Text::from(value));
435        });
436        for (row, right) in rows.iter_mut().zip(rows_right) {
437            row.push(right);
438        }
439        popup_table("snapshot details", rows)
440    }
441}
442
443impl<'a> ProcessEvent for Diff<'a> {
444    type Result = Result<DiffResult>;
445    fn input(&mut self, event: Event) -> Result<DiffResult> {
446        use KeyCode::{Backspace, Char, Enter, Esc, Left, Right};
447        match &mut self.current_screen {
448            CurrentScreen::Diff => match event {
449                Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
450                    Enter | Right => self.enter()?,
451                    Backspace | Left => {
452                        if self.in_root() {
453                            self.current_screen = CurrentScreen::PromptLeave(popup_prompt(
454                                "leave diff",
455                                "do you want to leave the diff view? (y/n)".into(),
456                            ));
457                        } else {
458                            self.goback();
459                        }
460                    }
461                    Esc | Char('q') => {
462                        self.current_screen = CurrentScreen::PromptExit(popup_prompt(
463                            "exit rustic",
464                            "do you want to exit? (y/n)".into(),
465                        ));
466                    }
467                    Char('?') => {
468                        self.current_screen =
469                            CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
470                    }
471                    Char('m') => self.toggle_ignore_metadata(),
472                    Char('d') => self.toggle_ignore_identical()?,
473                    Char('s') => {
474                        self.current_screen =
475                            CurrentScreen::Table(self.show_summary(&self.node.clone())?);
476                    }
477                    Char('S') => {
478                        if let Some(node) = self.selected_node() {
479                            self.current_screen = CurrentScreen::Table(self.show_summary(&node)?);
480                        }
481                    }
482                    Char('I') => {
483                        self.current_screen = CurrentScreen::Table(self.snapshot_details());
484                    }
485                    _ => self.table.input(event),
486                },
487                _ => {}
488            },
489            CurrentScreen::Table(_) | CurrentScreen::ShowHelp(_) => match event {
490                Event::Key(key) if key.kind == KeyEventKind::Press => {
491                    if matches!(key.code, Char('q' | ' ' | 'I' | '?') | Esc | Enter) {
492                        self.current_screen = CurrentScreen::Diff;
493                    }
494                }
495                _ => {}
496            },
497            CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
498                PromptResult::Ok => return Ok(DiffResult::Exit),
499                PromptResult::Cancel => self.current_screen = CurrentScreen::Diff,
500                PromptResult::None => {}
501            },
502            CurrentScreen::PromptLeave(prompt) => match prompt.input(event) {
503                PromptResult::Ok => {
504                    return Ok(DiffResult::Return(std::mem::take(&mut self.summary_map)));
505                }
506                PromptResult::Cancel => self.current_screen = CurrentScreen::Diff,
507                PromptResult::None => {}
508            },
509        }
510        Ok(DiffResult::None)
511    }
512}
513
514impl<'a> Draw for Diff<'a> {
515    fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
516        let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
517
518        // draw the table
519        self.table.draw(rects[0], f);
520
521        // draw the footer
522        let buffer_bg = tailwind::SLATE.c950;
523        let row_fg = tailwind::SLATE.c200;
524        let info_footer = Paragraph::new(Line::from(INFO_TEXT))
525            .style(Style::new().fg(row_fg).bg(buffer_bg))
526            .centered();
527        f.render_widget(info_footer, rects[1]);
528
529        // draw popups
530        match &mut self.current_screen {
531            CurrentScreen::Diff => {}
532            CurrentScreen::Table(popup) => popup.draw(area, f),
533            CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
534            CurrentScreen::PromptExit(popup) | CurrentScreen::PromptLeave(popup) => {
535                popup.draw(area, f);
536            }
537        }
538    }
539}