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::{
11    IndexedFull, Progress, ProgressBars, Repository,
12    repofile::{Node, SnapshotFile, Tree},
13};
14use style::palette::tailwind;
15
16use crate::{
17    commands::{
18        diff::{DiffStatistics, NodeDiff},
19        snapshots::fill_table,
20        tui::{
21            summary::BlobInfoRef,
22            widgets::{
23                Draw, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, SelectTable, WithBlock,
24                popup_prompt, popup_text,
25            },
26        },
27    },
28    helpers::bytes_size_to_string,
29};
30
31use super::{
32    TuiResult,
33    summary::SummaryMap,
34    widgets::{PopUpTable, popup_table},
35};
36
37// the states this screen can be in
38enum CurrentScreen {
39    Diff,
40    ShowHelp(PopUpText),
41    SnapshotDetails(PopUpTable),
42    PromptExit(PopUpPrompt),
43    PromptLeave(PopUpPrompt),
44}
45
46const INFO_TEXT: &str =
47    "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands";
48
49const HELP_TEXT: &str = r"
50Diff Commands:
51
52          m : toggle ignoring metadata
53          d : toggle show only different entries
54          s : compute information for (sub-)dirs
55          I : show information about snapshots
56
57General Commands:
58
59      q,Esc : exit
60      Enter : enter dir
61  Backspace : return to parent dir
62          ? : show this help page
63
64 ";
65
66#[derive(Clone)]
67struct DiffNode(EitherOrBoth<Node>);
68
69impl DiffNode {
70    fn only_subtrees(&self) -> Option<Self> {
71        let (left, right) = self
72            .0
73            .clone()
74            .map_any(
75                |n| n.subtree.is_some().then_some(n),
76                |n| n.subtree.is_some().then_some(n),
77            )
78            .left_and_right();
79        match (left.flatten(), right.flatten()) {
80            (Some(l), Some(r)) => Some(Self(EitherOrBoth::Both(l, r))),
81            (Some(l), None) => Some(Self(EitherOrBoth::Left(l))),
82            (None, Some(r)) => Some(Self(EitherOrBoth::Right(r))),
83            (None, None) => None,
84        }
85    }
86
87    fn name(&self) -> OsString {
88        self.0.as_ref().reduce(|l, _| l).name()
89    }
90}
91
92#[derive(Default)]
93struct DiffTree {
94    nodes: Vec<DiffNode>,
95}
96
97impl DiffTree {
98    fn from_node<P: ProgressBars, S: IndexedFull>(
99        repo: &'_ Repository<P, S>,
100        node: &DiffNode,
101    ) -> 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, P, S> {
133    current_screen: CurrentScreen,
134    table: WithBlock<SelectTable>,
135    repo: &'a Repository<P, S>,
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, P: ProgressBars, S: IndexedFull> Diff<'a, P, S> {
161    pub fn new(
162        repo: &'a Repository<P, S>,
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                if let (Some(id_left), Some(id_right)) = (left.subtree, right.subtree) {
216                    if let (Some(summary_left), Some(summary_right)) = (
217                        self.summary_map.get(&id_left),
218                        self.summary_map.get(&id_right),
219                    ) {
220                        return summary_left.id_without_meta == summary_right.id_without_meta;
221                    }
222                }
223            }
224            left.subtree == right.subtree
225        });
226        if self.ignore_metadata {
227            changed = changed.ignore_metadata();
228        }
229        changed
230    }
231
232    fn show_node(&self, node: &DiffNode) -> bool {
233        !self.ignore_identical || !self.node_changed(node).is_identical()
234    }
235
236    fn ls_row(&self, node: &DiffNode, stat: &mut DiffStatistics) -> Vec<Text<'static>> {
237        let node_info = |node: &Node| {
238            let size = node.subtree.map_or(node.meta.size, |id| {
239                self.summary_map
240                    .get(&id)
241                    .map_or(node.meta.size, |summary| summary.summary.size)
242            });
243            (
244                bytes_size_to_string(size),
245                node.meta.mtime.map_or_else(
246                    || "?".to_string(),
247                    |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")),
248                ),
249            )
250        };
251
252        let (left, right) = node.0.as_ref().left_and_right();
253        let left_blobs = left.map(|node| BlobInfoRef::from_node_or_map(node, &self.summary_map));
254        let right_blobs = right.map(|node| BlobInfoRef::from_node_or_map(node, &self.summary_map));
255        let left_only = BlobInfoRef::text_diff(&left_blobs, &right_blobs, self.repo);
256        let right_only = BlobInfoRef::text_diff(&right_blobs, &left_blobs, self.repo);
257
258        let changed = self.node_changed(node);
259        stat.apply(changed);
260        let name = node.name();
261        let name = format!("{changed} {}", name.to_string_lossy());
262        let (left_size, left_mtime) = match &node.0 {
263            EitherOrBoth::Left(node) | EitherOrBoth::Both(node, _) => node_info(node),
264            _ => (String::new(), String::new()),
265        };
266        let (right_size, right_mtime) = match &node.0 {
267            EitherOrBoth::Right(node) | EitherOrBoth::Both(_, node) => node_info(node),
268            _ => (String::new(), String::new()),
269        };
270        [
271            name,
272            left_mtime,
273            left_size,
274            left_only,
275            right_mtime,
276            right_size,
277            right_only,
278        ]
279        .into_iter()
280        .map(Text::from)
281        .collect()
282    }
283
284    pub fn update_table(&mut self) {
285        let mut stat = DiffStatistics::default();
286        let old_selection = if self.tree.nodes.is_empty() {
287            None
288        } else {
289            Some(self.table.widget.selected().unwrap_or_default())
290        };
291        let mut rows = Vec::new();
292        for node in &self.tree.nodes {
293            let row = self.ls_row(node, &mut stat);
294            rows.push(row);
295        }
296
297        self.table.widget.set_content(rows, 1);
298
299        self.table.block = Block::new()
300            .borders(Borders::BOTTOM | Borders::TOP)
301            .title_bottom(format!(
302                "total: {}, files: {}, dirs: {}; {} equal, {} metadata",
303                self.tree.nodes.len(),
304                stat.files,
305                stat.dirs,
306                if self.ignore_identical {
307                    "hide"
308                } else {
309                    "show"
310                },
311                if self.ignore_metadata {
312                    "with"
313                } else {
314                    "without"
315                }
316            ))
317            .title(format!(
318                "{} | {}",
319                if self.node.0.has_left() {
320                    format!("{}:{}", self.snapshot_left.id, self.path_left.display())
321                } else {
322                    format!("({})", self.snapshot_left.id)
323                },
324                if self.node.0.has_right() {
325                    format!("{}:{}", self.snapshot_right.id, self.path_right.display())
326                } else {
327                    format!("({})", self.snapshot_right.id)
328                },
329            ))
330            .title_alignment(Alignment::Center);
331        self.table.widget.select(old_selection);
332    }
333
334    pub fn enter(&mut self) -> Result<()> {
335        if let Some(idx) = self.table.widget.selected() {
336            let node = &self.tree.nodes[idx];
337            if let Some(node) = node.only_subtrees() {
338                self.path_left.push(node.name());
339                self.path_right.push(node.name());
340                let tree = std::mem::take(&mut self.tree);
341                self.trees.push((tree, self.node.clone(), idx));
342                let mut tree = DiffTree::from_node(self.repo, &node)?;
343                tree.nodes.retain(|node| self.show_node(node));
344                self.tree = tree;
345                self.node = node;
346                self.table.widget.set_to(0);
347                self.update_table();
348            }
349        }
350        Ok(())
351    }
352
353    pub fn in_root(&self) -> bool {
354        self.trees.is_empty()
355    }
356
357    pub fn goback(&mut self) {
358        _ = self.path_left.pop();
359        _ = self.path_right.pop();
360        if let Some((tree, node, idx)) = self.trees.pop() {
361            self.tree = tree;
362            self.node = node;
363            self.table.widget.set_to(idx);
364            self.update_table();
365        }
366    }
367
368    pub fn toggle_ignore_metadata(&mut self) {
369        self.ignore_metadata = !self.ignore_metadata;
370        self.update_table();
371    }
372
373    pub fn toggle_ignore_identical(&mut self) -> Result<()> {
374        self.ignore_identical = !self.ignore_identical;
375
376        let mut tree = DiffTree::from_node(self.repo, &self.node)?;
377        tree.nodes.retain(|node| self.show_node(node));
378        self.tree = tree;
379
380        self.update_table();
381        Ok(())
382    }
383
384    pub fn compute_summary(&mut self) -> Result<()> {
385        let pb = self.repo.progress_bars();
386        let p = pb.progress_counter("computing (sub)-dir information");
387
388        let (left, right) = self.node.0.as_ref().left_and_right();
389        if let Some(node) = left {
390            if let Some(id) = node.subtree {
391                self.summary_map.compute(self.repo, id, &p)?;
392            }
393        }
394        if let Some(node) = right {
395            if let Some(id) = node.subtree {
396                self.summary_map.compute(self.repo, id, &p)?;
397            }
398        }
399
400        p.finish();
401        self.update_table();
402        Ok(())
403    }
404
405    pub fn snapshot_details(&self) -> PopUpTable {
406        let mut rows = Vec::new();
407        let mut rows_right = Vec::new();
408        fill_table(&self.snapshot_left, |title, value| {
409            rows.push(vec![Text::from(title.to_string()), Text::from(value)]);
410        });
411        fill_table(&self.snapshot_right, |_, value| {
412            rows_right.push(Text::from(value));
413        });
414        for (row, right) in rows.iter_mut().zip(rows_right) {
415            row.push(right);
416        }
417        popup_table("snapshot details", rows)
418    }
419}
420
421impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Diff<'a, P, S> {
422    type Result = Result<DiffResult>;
423    fn input(&mut self, event: Event) -> Result<DiffResult> {
424        use KeyCode::{Backspace, Char, Enter, Esc, Left, Right};
425        match &mut self.current_screen {
426            CurrentScreen::Diff => match event {
427                Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
428                    Enter | Right => self.enter()?,
429                    Backspace | Left => {
430                        if self.in_root() {
431                            self.current_screen = CurrentScreen::PromptLeave(popup_prompt(
432                                "leave diff",
433                                "do you want to leave the diff view? (y/n)".into(),
434                            ));
435                        } else {
436                            self.goback();
437                        }
438                    }
439                    Esc | Char('q') => {
440                        self.current_screen = CurrentScreen::PromptExit(popup_prompt(
441                            "exit rustic",
442                            "do you want to exit? (y/n)".into(),
443                        ));
444                    }
445                    Char('?') => {
446                        self.current_screen =
447                            CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
448                    }
449                    Char('m') => self.toggle_ignore_metadata(),
450                    Char('d') => self.toggle_ignore_identical()?,
451                    Char('s') => self.compute_summary()?,
452                    Char('I') => {
453                        self.current_screen =
454                            CurrentScreen::SnapshotDetails(self.snapshot_details());
455                    }
456                    _ => self.table.input(event),
457                },
458                _ => {}
459            },
460            CurrentScreen::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event {
461                Event::Key(key) if key.kind == KeyEventKind::Press => {
462                    if matches!(key.code, Char('q' | ' ' | 'I' | '?') | Esc | Enter) {
463                        self.current_screen = CurrentScreen::Diff;
464                    }
465                }
466                _ => {}
467            },
468            CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
469                PromptResult::Ok => return Ok(DiffResult::Exit),
470                PromptResult::Cancel => self.current_screen = CurrentScreen::Diff,
471                PromptResult::None => {}
472            },
473            CurrentScreen::PromptLeave(prompt) => match prompt.input(event) {
474                PromptResult::Ok => {
475                    return Ok(DiffResult::Return(std::mem::take(&mut self.summary_map)));
476                }
477                PromptResult::Cancel => self.current_screen = CurrentScreen::Diff,
478                PromptResult::None => {}
479            },
480        }
481        Ok(DiffResult::None)
482    }
483}
484
485impl<'a, P: ProgressBars, S: IndexedFull> Draw for Diff<'a, P, S> {
486    fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
487        let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
488
489        // draw the table
490        self.table.draw(rects[0], f);
491
492        // draw the footer
493        let buffer_bg = tailwind::SLATE.c950;
494        let row_fg = tailwind::SLATE.c200;
495        let info_footer = Paragraph::new(Line::from(INFO_TEXT))
496            .style(Style::new().fg(row_fg).bg(buffer_bg))
497            .centered();
498        f.render_widget(info_footer, rects[1]);
499
500        // draw popups
501        match &mut self.current_screen {
502            CurrentScreen::Diff => {}
503            CurrentScreen::SnapshotDetails(popup) => popup.draw(area, f),
504            CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
505            CurrentScreen::PromptExit(popup) | CurrentScreen::PromptLeave(popup) => {
506                popup.draw(area, f);
507            }
508        }
509    }
510}