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