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
34enum 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)>, 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 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 self.table.draw(rects[0], f);
525
526 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 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}