1use crate::tui::theme::{Component, Theme};
2use ratatui::{
3 style::{Modifier, Style},
4 text::{Line, Span},
5};
6use similar::{Algorithm, ChangeTag, TextDiff};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum DiffMode {
10 Unified,
11 Split,
12}
13
14pub struct DiffWidget<'a> {
15 old: &'a str,
16 new: &'a str,
17 mode: DiffMode,
18 wrap_width: usize,
19 theme: &'a Theme,
20 context_radius: usize,
21 max_lines: Option<usize>,
22}
23
24impl<'a> DiffWidget<'a> {
25 pub fn new(old: &'a str, new: &'a str, theme: &'a Theme) -> Self {
26 Self {
27 old,
28 new,
29 mode: DiffMode::Unified,
30 wrap_width: 80,
31 theme,
32 context_radius: 3,
33 max_lines: None,
34 }
35 }
36
37 pub fn with_mode(mut self, mode: DiffMode) -> Self {
38 self.mode = mode;
39 self
40 }
41
42 pub fn with_wrap_width(mut self, width: usize) -> Self {
43 self.wrap_width = width;
44 self
45 }
46
47 pub fn with_context_radius(mut self, radius: usize) -> Self {
48 self.context_radius = radius;
49 self
50 }
51
52 pub fn with_max_lines(mut self, max: usize) -> Self {
53 self.max_lines = Some(max);
54 self
55 }
56
57 pub fn lines(&self) -> Vec<Line<'static>> {
58 match self.mode {
59 DiffMode::Unified => self.unified_diff(),
60 DiffMode::Split => {
61 if self.wrap_width < 40 {
64 self.unified_diff()
65 } else {
66 self.split_diff()
67 }
68 }
69 }
70 }
71
72 fn unified_diff(&self) -> Vec<Line<'static>> {
73 let diff = TextDiff::configure()
74 .algorithm(Algorithm::Myers)
75 .diff_lines(self.old, self.new);
76
77 let changes: Vec<_> = diff.iter_all_changes().collect();
78
79 let mut show_line = vec![false; changes.len()];
81 for (idx, change) in changes.iter().enumerate() {
82 if change.tag() != ChangeTag::Equal {
83 show_line[idx] = true;
85
86 let start = idx.saturating_sub(self.context_radius);
88 for line in show_line.iter_mut().take(idx).skip(start) {
89 *line = true;
90 }
91
92 let end = (idx + 1 + self.context_radius).min(changes.len());
94 for line in show_line.iter_mut().take(end).skip(idx + 1) {
95 *line = true;
96 }
97 }
98 }
99
100 let mut lines = Vec::new();
102 let mut last_shown: Option<usize> = None;
103
104 for (idx, (change, &should_show)) in changes.iter().zip(&show_line).enumerate() {
105 if !should_show {
106 continue;
107 }
108
109 match last_shown {
111 None if idx > 0 => {
112 lines.push(self.separator_line());
114 }
115 Some(last) if idx > last + 1 => {
116 lines.push(self.separator_line());
118 }
119 _ => {}
120 }
121
122 let (prefix, style) = match change.tag() {
123 ChangeTag::Delete => ("-", self.theme.style(Component::CodeDeletion)),
124 ChangeTag::Insert => ("+", self.theme.style(Component::CodeAddition)),
125 ChangeTag::Equal => (" ", self.theme.style(Component::DimText)),
126 };
127
128 let content = change.value().trim_end();
129 lines.extend(self.format_line(prefix, content, style));
130
131 last_shown = Some(idx);
132
133 if let Some(max) = self.max_lines
135 && lines.len() >= max
136 {
137 let remaining = changes.len() - idx - 1;
138 if remaining > 0 {
139 lines.push(Line::from(Span::styled(
140 format!("... ({remaining} more lines)"),
141 self.theme
142 .style(Component::DimText)
143 .add_modifier(Modifier::ITALIC),
144 )));
145 }
146 break;
147 }
148 }
149
150 lines
151 }
152
153 fn split_diff(&self) -> Vec<Line<'static>> {
154 let mut lines = Vec::new();
155
156 let half_width = (self.wrap_width.saturating_sub(5)) / 2;
158
159 let diff = TextDiff::configure()
160 .algorithm(Algorithm::Myers)
161 .diff_lines(self.old, self.new);
162
163 let changes: Vec<_> = diff.iter_all_changes().collect();
165 let mut i = 0;
166
167 while i < changes.len() {
168 let change = changes[i];
169
170 match change.tag() {
171 ChangeTag::Equal => {
172 let content = change.value().trim_end();
174 let left = Self::truncate_or_pad(content, half_width);
175 let right = Self::truncate_or_pad(content, half_width);
176
177 lines.push(Line::from(vec![
178 Span::styled(" ", self.theme.style(Component::DimText)),
179 Span::styled(left, self.theme.style(Component::DimText)),
180 Span::styled(" │ ", self.theme.style(Component::DimText)),
181 Span::styled(" ", self.theme.style(Component::DimText)),
182 Span::styled(right, self.theme.style(Component::DimText)),
183 ]));
184 i += 1;
185 }
186 ChangeTag::Delete => {
187 let mut deletes = vec![change];
189 let mut j = i + 1;
190
191 while j < changes.len() && changes[j].tag() == ChangeTag::Delete {
193 deletes.push(changes[j]);
194 j += 1;
195 }
196
197 let mut inserts = Vec::new();
199 while j < changes.len() && changes[j].tag() == ChangeTag::Insert {
200 inserts.push(changes[j]);
201 j += 1;
202 }
203
204 if inserts.is_empty() {
205 for del in deletes {
207 let content = del.value().trim_end();
208 let left = Self::truncate_or_pad(content, half_width);
209 let right = " ".repeat(half_width);
210
211 lines.push(Line::from(vec![
212 Span::styled("-", self.theme.style(Component::CodeDeletion)),
213 Span::styled(left, self.theme.style(Component::CodeDeletion)),
214 Span::styled(" │ ", self.theme.style(Component::DimText)),
215 Span::styled(" ", self.theme.style(Component::DimText)),
216 Span::styled(right, self.theme.style(Component::DimText)),
217 ]));
218 }
219 i = j;
220 } else {
221 let max_len = deletes.len().max(inserts.len());
223
224 for idx in 0..max_len {
225 let left_content = if idx < deletes.len() {
226 deletes[idx].value().trim_end()
227 } else {
228 ""
229 };
230 let right_content = if idx < inserts.len() {
231 inserts[idx].value().trim_end()
232 } else {
233 ""
234 };
235
236 let left = Self::truncate_or_pad(left_content, half_width);
237 let right = Self::truncate_or_pad(right_content, half_width);
238
239 let left_prefix = if left_content.is_empty() { " " } else { "-" };
241 let right_prefix = if right_content.is_empty() { " " } else { "+" };
242
243 lines.push(Line::from(vec![
244 Span::styled(
245 left_prefix,
246 self.theme.style(Component::CodeDeletion),
247 ),
248 Span::styled(
249 left,
250 if left_content.is_empty() {
251 self.theme.style(Component::DimText)
252 } else {
253 self.theme.style(Component::CodeDeletion)
254 },
255 ),
256 Span::styled(" │ ", self.theme.style(Component::DimText)),
257 Span::styled(
258 right_prefix,
259 self.theme.style(Component::CodeAddition),
260 ),
261 Span::styled(
262 right,
263 if right_content.is_empty() {
264 self.theme.style(Component::DimText)
265 } else {
266 self.theme.style(Component::CodeAddition)
267 },
268 ),
269 ]));
270 }
271
272 i = j;
273 }
274 }
275 ChangeTag::Insert => {
276 let content = change.value().trim_end();
278 let left = " ".repeat(half_width);
279 let right = Self::truncate_or_pad(content, half_width);
280
281 lines.push(Line::from(vec![
282 Span::styled(" ", self.theme.style(Component::DimText)),
283 Span::styled(left, self.theme.style(Component::DimText)),
284 Span::styled(" │ ", self.theme.style(Component::DimText)),
285 Span::styled("+", self.theme.style(Component::CodeAddition)),
286 Span::styled(right, self.theme.style(Component::CodeAddition)),
287 ]));
288 i += 1;
289 }
290 }
291
292 if let Some(max) = self.max_lines
294 && lines.len() >= max
295 {
296 break;
297 }
298 }
299
300 lines
301 }
302
303 fn format_line(&self, prefix: &str, content: &str, style: Style) -> Vec<Line<'static>> {
304 let mut lines = Vec::new();
305
306 let wrapped = textwrap::wrap(content, self.wrap_width.saturating_sub(2));
308
309 if wrapped.is_empty() {
310 lines.push(Line::from(vec![
311 Span::styled(prefix.to_string(), style),
312 Span::styled(" ", style),
313 ]));
314 } else {
315 for (i, wrapped_line) in wrapped.iter().enumerate() {
316 if i == 0 {
317 lines.push(Line::from(vec![
318 Span::styled(prefix.to_string(), style),
319 Span::styled(format!(" {wrapped_line}"), style),
320 ]));
321 } else {
322 lines.push(Line::from(vec![
324 Span::styled(" ", style),
325 Span::styled(wrapped_line.to_string(), style),
326 ]));
327 }
328 }
329 }
330
331 lines
332 }
333
334 fn separator_line(&self) -> Line<'static> {
335 Line::from(Span::styled(
336 "···",
337 self.theme
338 .style(Component::DimText)
339 .add_modifier(Modifier::DIM),
340 ))
341 }
342
343 fn truncate_or_pad(s: &str, width: usize) -> String {
344 let char_count = s.chars().count();
346 if char_count > width {
347 let truncated: String = s.chars().take(width.saturating_sub(1)).collect();
349 format!("{truncated}…")
350 } else {
351 format!("{s:width$}")
353 }
354 }
355}
356
357fn truncate_summary_preview(text: &str, max_len: usize) -> String {
359 let trimmed = text.trim();
360 if trimmed.chars().count() <= max_len {
361 trimmed.to_string()
362 } else {
363 let prefix: String = trimmed.chars().take(max_len.saturating_sub(3)).collect();
364 format!("{prefix}...")
365 }
366}
367
368pub fn diff_summary(old: &str, new: &str, max_len: usize) -> (String, String) {
369 let old_preview = if old.is_empty() {
370 String::new()
371 } else {
372 truncate_summary_preview(old, max_len)
373 };
374
375 let new_preview = if new.is_empty() {
376 String::new()
377 } else {
378 truncate_summary_preview(new, max_len)
379 };
380
381 (old_preview, new_preview)
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::tui::theme::Theme;
388
389 fn extract_text_from_line(line: &Line) -> String {
390 line.spans
391 .iter()
392 .map(|span| span.content.as_ref())
393 .collect()
394 }
395
396 #[test]
397 fn test_unified_diff_basic() {
398 let theme = Theme::default();
399 let widget = DiffWidget::new("hello\nworld", "hello\nthere", &theme);
400 let lines = widget
401 .lines()
402 .iter()
403 .map(extract_text_from_line)
404 .collect::<Vec<_>>();
405
406 let expected = vec![" hello", "- world", "+ there"];
407
408 assert_eq!(lines, expected);
409 }
410
411 #[test]
412 fn test_split_diff_equal_lines() {
413 let theme = Theme::default();
414 let old = "line1\nline2\nline3";
415 let new = "line1\nmodified2\nline3";
416
417 let widget = DiffWidget::new(old, new, &theme)
418 .with_mode(DiffMode::Split)
419 .with_wrap_width(80);
420
421 let lines = widget
422 .lines()
423 .iter()
424 .map(extract_text_from_line)
425 .collect::<Vec<_>>();
426 let expected = vec![
427 " line1 │ line1 ",
428 "-line2 │ +modified2 ",
429 " line3 │ line3 ",
430 ];
431
432 assert_eq!(lines.len(), expected.len());
433 assert_eq!(lines, expected);
434 }
435
436 #[test]
437 fn test_split_diff_more_deletes_than_inserts() {
438 let theme = Theme::default();
439 let old = "line1\nline2\nline3\nline4\nline5";
440 let new = "line1\nreplacement";
441
442 let widget = DiffWidget::new(old, new, &theme)
443 .with_mode(DiffMode::Split)
444 .with_wrap_width(80);
445
446 let lines = widget
447 .lines()
448 .iter()
449 .map(extract_text_from_line)
450 .collect::<Vec<_>>();
451 let expected = vec![
452 " line1 │ line1 ",
453 "-line2 │ +replacement ",
454 "-line3 │ ",
455 "-line4 │ ",
456 "-line5 │ ",
457 ];
458
459 assert_eq!(lines, expected);
460 }
461
462 #[test]
463 fn test_split_diff_more_inserts_than_deletes() {
464 let theme = Theme::default();
465 let old = "line1\nold";
466 let new = "line1\nnew1\nnew2\nnew3";
467
468 let widget = DiffWidget::new(old, new, &theme)
469 .with_mode(DiffMode::Split)
470 .with_wrap_width(80);
471
472 let lines = widget
473 .lines()
474 .iter()
475 .map(extract_text_from_line)
476 .collect::<Vec<_>>();
477 let expected = vec![
478 " line1 │ line1 ",
479 "-old │ +new1 ",
480 " │ +new2 ",
481 " │ +new3 ",
482 ];
483
484 assert_eq!(lines, expected);
485 }
486
487 #[test]
488 fn test_unicode_truncation() {
489 let theme = Theme::default();
490 let old = "Short";
491 let new = "This is a line with unicode: → ← ↑ ↓ — and more symbols";
492
493 let widget = DiffWidget::new(old, new, &theme)
494 .with_mode(DiffMode::Split)
495 .with_wrap_width(40); let lines = widget
498 .lines()
499 .iter()
500 .map(extract_text_from_line)
501 .collect::<Vec<_>>();
502 let expected = vec!["-Short │ +This is a line w…"];
504 assert_eq!(lines, expected);
505 }
506
507 #[test]
508 fn test_narrow_terminal_fallback() {
509 let theme = Theme::default();
510 let widget = DiffWidget::new("old", "new", &theme)
511 .with_mode(DiffMode::Split)
512 .with_wrap_width(30); let lines = widget
515 .lines()
516 .iter()
517 .map(extract_text_from_line)
518 .collect::<Vec<_>>();
519 let expected = vec!["- old", "+ new"];
520
521 assert_eq!(lines, expected);
522 }
523
524 #[test]
525 fn test_context_radius() {
526 let theme = Theme::default();
527 let old = "a\nb\nc\nd\ne\nf\ng";
528 let new = "a\nb\nc\nX\ne\nf\ng";
529
530 let widget = DiffWidget::new(old, new, &theme).with_context_radius(1); let lines = widget
533 .lines()
534 .iter()
535 .map(extract_text_from_line)
536 .collect::<Vec<_>>();
537 let expected = vec!["···", " c", "- d", "+ X", " e"];
539
540 assert_eq!(lines, expected);
541 }
542
543 #[test]
544 fn test_max_lines_limit() {
545 let theme = Theme::default();
546 let old = "line 0\nline 1\nline 2\nline 3\nline 4\nline 5";
547 let new = "modified 0\nmodified 1\nmodified 2\nmodified 3\nmodified 4\nmodified 5";
548
549 let widget = DiffWidget::new(old, new, &theme).with_max_lines(10);
550
551 let lines = widget
552 .lines()
553 .iter()
554 .map(extract_text_from_line)
555 .collect::<Vec<_>>();
556 let expected = vec![
557 "- line 0",
558 "- line 1",
559 "- line 2",
560 "- line 3",
561 "- line 4",
562 "- line 5",
563 "+ modified 0",
564 "+ modified 1",
565 "+ modified 2",
566 "+ modified 3",
567 "... (2 more lines)",
568 ];
569
570 assert_eq!(lines, expected);
571 }
572
573 #[test]
574 fn test_diff_summary() {
575 let (old_preview, new_preview) = diff_summary(
576 "This is a very long line that should be truncated",
577 "Short",
578 20,
579 );
580
581 assert_eq!(old_preview, "This is a very lo...");
582 assert_eq!(new_preview, "Short");
583 }
584
585 #[test]
586 fn test_diff_summary_unicode_boundary() {
587 let input = "// ── Composite type conversions ───────────────────────────────────────";
588 let (old_preview, new_preview) = diff_summary(input, "", 60);
589
590 let expected_prefix: String = input.trim().chars().take(57).collect();
591 assert_eq!(old_preview, format!("{expected_prefix}..."));
592 assert_eq!(new_preview, "");
593 }
594
595 #[test]
596 fn test_empty_strings() {
597 let theme = Theme::default();
598
599 let widget = DiffWidget::new("", "new content", &theme);
601 let lines = widget
602 .lines()
603 .iter()
604 .map(extract_text_from_line)
605 .collect::<Vec<_>>();
606 let expected = vec!["+ new content"];
607 assert_eq!(lines, expected);
608
609 let widget = DiffWidget::new("old content", "", &theme);
611 let lines = widget
612 .lines()
613 .iter()
614 .map(extract_text_from_line)
615 .collect::<Vec<_>>();
616 let expected = vec!["- old content"];
617 assert_eq!(lines, expected);
618
619 let widget = DiffWidget::new("", "", &theme);
621 let lines = widget
622 .lines()
623 .iter()
624 .map(extract_text_from_line)
625 .collect::<Vec<_>>();
626 assert!(lines.is_empty());
627 }
628
629 #[test]
630 fn test_line_wrapping() {
631 let theme = Theme::default();
632 let old = "short";
633 let new = "This is a very long line that should be wrapped when displayed in the diff widget because it exceeds the wrap width";
634
635 let widget = DiffWidget::new(old, new, &theme).with_wrap_width(30);
636
637 let lines = widget
638 .lines()
639 .iter()
640 .map(extract_text_from_line)
641 .collect::<Vec<_>>();
642 let expected = vec![
644 "- short",
645 "+ This is a very long line",
646 " that should be wrapped when",
647 " displayed in the diff widget",
648 " because it exceeds the wrap",
649 " width",
650 ];
651
652 assert_eq!(lines, expected);
653 }
654
655 #[test]
656 fn test_unified_diff_exact_output() {
657 let theme = Theme::default();
658 let widget = DiffWidget::new("line1\nline2\nline3", "line1\nmodified\nline3", &theme)
659 .with_context_radius(1);
660
661 let lines = widget
662 .lines()
663 .iter()
664 .map(extract_text_from_line)
665 .collect::<Vec<_>>();
666 let expected = vec![" line1", "- line2", "+ modified", " line3"];
669
670 assert_eq!(lines, expected);
671 }
672
673 #[test]
674 fn test_split_diff_exact_output() {
675 let theme = Theme::default();
676 let widget = DiffWidget::new("same\nold\nsame", "same\nnew\nsame", &theme)
677 .with_mode(DiffMode::Split)
678 .with_wrap_width(80); let lines = widget
681 .lines()
682 .iter()
683 .map(extract_text_from_line)
684 .collect::<Vec<_>>();
685 let expected = vec![
686 " same │ same ",
687 "-old │ +new ",
688 " same │ same ",
689 ];
690
691 assert_eq!(lines, expected);
692 }
693
694 #[test]
695 fn test_split_diff_uneven_replacement_exact() {
696 let theme = Theme::default();
697 let widget = DiffWidget::new("a\nb\nc\nd\ne", "a\nX\ne", &theme)
698 .with_mode(DiffMode::Split)
699 .with_wrap_width(80);
700
701 let lines = widget
702 .lines()
703 .iter()
704 .map(extract_text_from_line)
705 .collect::<Vec<_>>();
706 let expected = vec![
707 " a │ a ",
708 "-b │ +X ",
709 "-c │ ",
710 "-d │ ",
711 " e │ e ",
712 ];
713 assert_eq!(lines, expected);
714 }
715
716 #[test]
717 fn test_context_radius_exact() {
718 let theme = Theme::default();
719 let widget = DiffWidget::new(
720 "1\n2\n3\n4\n5\n6\n7\n8\n9",
721 "1\n2\n3\n4\nX\n6\n7\n8\n9",
722 &theme,
723 )
724 .with_context_radius(2);
725
726 let lines = widget
727 .lines()
728 .iter()
729 .map(extract_text_from_line)
730 .collect::<Vec<_>>();
731 let expected = vec!["···", " 3", " 4", "- 5", "+ X", " 6", " 7"];
733
734 assert_eq!(lines, expected);
735 }
736
737 #[test]
738 fn test_line_wrapping_exact() {
739 let theme = Theme::default();
740 let widget =
741 DiffWidget::new("short", "This is a long line that wraps", &theme).with_wrap_width(20); let lines = widget
744 .lines()
745 .iter()
746 .map(extract_text_from_line)
747 .collect::<Vec<_>>();
748 let expected = vec!["- short", "+ This is a long", " line that wraps"];
749
750 assert_eq!(lines, expected);
751 }
752}