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
37enum 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)>, 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 self.table.draw(rects[0], f);
491
492 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 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}