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
32enum 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)>, 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 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 self.table.draw(rects[0], f);
520
521 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 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}