Skip to main content

tui/diffs/
diff.rs

1use crossterm::style::Color;
2
3use crate::{DiffPreview, DiffTag};
4
5use crate::line::Line;
6use crate::rendering::render_context::ViewContext;
7use crate::span::Span;
8use crate::style::Style;
9use crate::theme::Theme;
10
11const MAX_DIFF_LINES: usize = 20;
12
13struct DiffStyle<'a> {
14    prefix: &'a str,
15    fg: Color,
16    bg: Option<Color>,
17}
18
19/// Render a diff preview with syntax-highlighted context/removed/added lines.
20///
21/// Context lines are shown with a `"    "` prefix and code background.
22/// Removed lines are shown with a `"  - "` prefix and red-tinted background.
23/// Added lines are shown with a `"  + "` prefix and green-tinted background.
24pub fn highlight_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
25    let theme: &Theme = &context.theme;
26    let total = preview.lines.len();
27    let truncated = total > MAX_DIFF_LINES;
28    let budget = if truncated { MAX_DIFF_LINES } else { total };
29
30    let mut lines = Vec::with_capacity(budget + usize::from(truncated));
31
32    let context_style = DiffStyle {
33        prefix: "    ",
34        fg: theme.code_fg(),
35        bg: None,
36    };
37    let removed_style = DiffStyle {
38        prefix: "  - ",
39        fg: theme.diff_removed_fg(),
40        bg: Some(theme.diff_removed_bg()),
41    };
42    let added_style = DiffStyle {
43        prefix: "  + ",
44        fg: theme.diff_added_fg(),
45        bg: Some(theme.diff_added_bg()),
46    };
47
48    let mut old_line = preview.start_line.unwrap_or(0);
49
50    for diff_line in preview.lines.iter().take(budget) {
51        let style = match diff_line.tag {
52            DiffTag::Context => &context_style,
53            DiffTag::Removed => &removed_style,
54            DiffTag::Added => &added_style,
55        };
56
57        let mut line = Line::default();
58
59        if preview.start_line.is_some() {
60            match diff_line.tag {
61                DiffTag::Context | DiffTag::Removed => {
62                    let line_num = format!("{old_line:>4} ");
63                    line.push_styled(line_num, theme.muted());
64                }
65                DiffTag::Added => {
66                    line.push_styled("     ", theme.muted());
67                }
68            }
69        }
70
71        let mut prefix_style = Style::fg(style.fg);
72        if let Some(bg) = style.bg {
73            prefix_style = prefix_style.bg_color(bg);
74        }
75        line.push_span(Span::with_style(style.prefix, prefix_style));
76
77        let spans = context
78            .highlighter()
79            .highlight(&diff_line.content, &preview.lang_hint, theme);
80        if let Some(content) = spans.first() {
81            for span in content.spans() {
82                let mut span_style = span.style();
83                if let Some(bg) = style.bg {
84                    span_style.bg = Some(bg);
85                }
86                line.push_span(Span::with_style(span.text(), span_style));
87            }
88        }
89        lines.push(line);
90
91        if matches!(diff_line.tag, DiffTag::Context | DiffTag::Removed) {
92            old_line += 1;
93        }
94    }
95
96    if truncated {
97        let remaining = total - budget;
98        let mut overflow = Line::default();
99        overflow.push_styled(format!("    ... {remaining} more lines"), theme.muted());
100        lines.push(overflow);
101    }
102
103    lines
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::DiffLine;
110
111    fn test_context() -> ViewContext {
112        ViewContext::new((80, 24))
113    }
114
115    fn test_theme() -> Theme {
116        Theme::default()
117    }
118
119    fn make_preview(lines: Vec<DiffLine>) -> DiffPreview {
120        DiffPreview {
121            lines,
122            rows: vec![],
123            lang_hint: String::new(),
124            start_line: None,
125        }
126    }
127
128    #[test]
129    fn removed_lines_have_minus_prefix() {
130        let preview = make_preview(vec![DiffLine {
131            tag: DiffTag::Removed,
132            content: "old line".to_string(),
133        }]);
134        let lines = highlight_diff(&preview, &test_context());
135        assert_eq!(lines.len(), 1);
136        assert!(lines[0].plain_text().contains("- old line"));
137    }
138
139    #[test]
140    fn added_lines_have_plus_prefix() {
141        let preview = make_preview(vec![DiffLine {
142            tag: DiffTag::Added,
143            content: "new line".to_string(),
144        }]);
145        let lines = highlight_diff(&preview, &test_context());
146        assert_eq!(lines.len(), 1);
147        assert!(lines[0].plain_text().contains("+ new line"));
148    }
149
150    #[test]
151    fn context_lines_have_no_diff_prefix() {
152        let preview = make_preview(vec![DiffLine {
153            tag: DiffTag::Context,
154            content: "unchanged".to_string(),
155        }]);
156        let lines = highlight_diff(&preview, &test_context());
157        assert_eq!(lines.len(), 1);
158        let text = lines[0].plain_text();
159        assert!(
160            text.starts_with("    "),
161            "context should have space prefix: {text}"
162        );
163        assert!(!text.contains("+ "), "context should not have + prefix");
164        assert!(!text.contains("- "), "context should not have - prefix");
165    }
166
167    #[test]
168    fn mixed_diff_renders_correctly() {
169        let preview = make_preview(vec![
170            DiffLine {
171                tag: DiffTag::Context,
172                content: "before".to_string(),
173            },
174            DiffLine {
175                tag: DiffTag::Removed,
176                content: "old".to_string(),
177            },
178            DiffLine {
179                tag: DiffTag::Added,
180                content: "new".to_string(),
181            },
182            DiffLine {
183                tag: DiffTag::Context,
184                content: "after".to_string(),
185            },
186        ]);
187        let lines = highlight_diff(&preview, &test_context());
188        assert_eq!(lines.len(), 4);
189        assert!(lines[0].plain_text().contains("before"));
190        assert!(lines[1].plain_text().contains("- old"));
191        assert!(lines[2].plain_text().contains("+ new"));
192        assert!(lines[3].plain_text().contains("after"));
193    }
194
195    #[test]
196    fn both_removed_and_added() {
197        let preview = make_preview(vec![
198            DiffLine {
199                tag: DiffTag::Removed,
200                content: "old".to_string(),
201            },
202            DiffLine {
203                tag: DiffTag::Added,
204                content: "new".to_string(),
205            },
206        ]);
207        let lines = highlight_diff(&preview, &test_context());
208        assert_eq!(lines.len(), 2);
209        assert!(lines[0].plain_text().contains("- old"));
210        assert!(lines[1].plain_text().contains("+ new"));
211    }
212
213    #[test]
214    fn truncates_long_diffs() {
215        let diff_lines: Vec<DiffLine> = (0..30)
216            .map(|i| DiffLine {
217                tag: if i % 2 == 0 {
218                    DiffTag::Removed
219                } else {
220                    DiffTag::Added
221                },
222                content: format!("line {i}"),
223            })
224            .collect();
225        let preview = make_preview(diff_lines);
226        let lines = highlight_diff(&preview, &test_context());
227        // 20 content lines + 1 overflow line
228        assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
229        let last = lines.last().unwrap().plain_text();
230        assert!(
231            last.contains("more lines"),
232            "Expected overflow text: {last}"
233        );
234    }
235
236    #[test]
237    fn no_truncation_at_boundary() {
238        let diff_lines: Vec<DiffLine> = (0..20)
239            .map(|i| DiffLine {
240                tag: if i % 2 == 0 {
241                    DiffTag::Removed
242                } else {
243                    DiffTag::Added
244                },
245                content: format!("line {i}"),
246            })
247            .collect();
248        let preview = make_preview(diff_lines);
249        let lines = highlight_diff(&preview, &test_context());
250        assert_eq!(lines.len(), 20);
251        assert!(!lines.last().unwrap().plain_text().contains("more lines"));
252    }
253
254    #[test]
255    fn syntax_highlighting_with_known_lang() {
256        let preview = DiffPreview {
257            lines: vec![
258                DiffLine {
259                    tag: DiffTag::Removed,
260                    content: "fn old() {}".to_string(),
261                },
262                DiffLine {
263                    tag: DiffTag::Added,
264                    content: "fn new() {}".to_string(),
265                },
266            ],
267            rows: vec![],
268            lang_hint: "rs".to_string(),
269            start_line: None,
270        };
271        let lines = highlight_diff(&preview, &test_context());
272        assert_eq!(lines.len(), 2);
273        // With syntax highlighting, there should be multiple spans (not just 2: prefix + text)
274        assert!(
275            lines[0].spans().len() > 2,
276            "Expected syntax-highlighted spans, got {} spans",
277            lines[0].spans().len()
278        );
279    }
280
281    #[test]
282    fn removed_lines_have_red_bg() {
283        let preview = make_preview(vec![DiffLine {
284            tag: DiffTag::Removed,
285            content: "old".to_string(),
286        }]);
287        let theme = test_theme();
288        let ctx = test_context();
289        let lines = highlight_diff(&preview, &ctx);
290        let prefix_span = &lines[0].spans()[0];
291        assert_eq!(prefix_span.style().bg, Some(theme.diff_removed_bg()));
292        assert_eq!(prefix_span.style().fg, Some(theme.diff_removed_fg()));
293    }
294
295    #[test]
296    fn added_lines_have_green_bg() {
297        let preview = make_preview(vec![DiffLine {
298            tag: DiffTag::Added,
299            content: "new".to_string(),
300        }]);
301        let theme = test_theme();
302        let ctx = test_context();
303        let lines = highlight_diff(&preview, &ctx);
304        let prefix_span = &lines[0].spans()[0];
305        assert_eq!(prefix_span.style().bg, Some(theme.diff_added_bg()));
306        assert_eq!(prefix_span.style().fg, Some(theme.diff_added_fg()));
307    }
308
309    #[test]
310    fn context_lines_have_code_bg() {
311        let preview = make_preview(vec![DiffLine {
312            tag: DiffTag::Context,
313            content: "same".to_string(),
314        }]);
315        let ctx = test_context();
316        let lines = highlight_diff(&preview, &ctx);
317        let prefix_span = &lines[0].spans()[0];
318        assert_eq!(prefix_span.style().bg, None);
319    }
320
321    #[test]
322    fn empty_diff_produces_no_lines() {
323        let preview = make_preview(vec![]);
324        let lines = highlight_diff(&preview, &test_context());
325        assert!(lines.is_empty());
326    }
327
328    #[test]
329    fn line_numbers_rendered_when_start_line_set() {
330        let preview = DiffPreview {
331            lines: vec![
332                DiffLine {
333                    tag: DiffTag::Context,
334                    content: "ctx".to_string(),
335                },
336                DiffLine {
337                    tag: DiffTag::Removed,
338                    content: "old".to_string(),
339                },
340                DiffLine {
341                    tag: DiffTag::Added,
342                    content: "new".to_string(),
343                },
344                DiffLine {
345                    tag: DiffTag::Context,
346                    content: "ctx2".to_string(),
347                },
348            ],
349            rows: vec![],
350            lang_hint: String::new(),
351            start_line: Some(10),
352        };
353        let lines = highlight_diff(&preview, &test_context());
354        assert_eq!(lines.len(), 4);
355        assert!(
356            lines[0].plain_text().contains("10"),
357            "context line should show 10"
358        );
359        assert!(
360            lines[1].plain_text().contains("11"),
361            "removed line should show 11"
362        );
363        // Added lines don't show a source line number
364        let added_text = lines[2].plain_text();
365        assert!(
366            !added_text.starts_with("  12"),
367            "added line should not show line number"
368        );
369        assert!(
370            lines[3].plain_text().contains("12"),
371            "next context should show 12"
372        );
373    }
374
375    #[test]
376    fn focused_preview_with_truncation_shows_changes() {
377        // Simulates a DiffPreview produced by compute_diff_preview after trimming:
378        // 3 context lines, then changes, then 22 more context lines (total 27 > MAX_DIFF_LINES).
379        let mut diff_lines: Vec<DiffLine> = (0..3)
380            .map(|_| DiffLine {
381                tag: DiffTag::Context,
382                content: "before".to_string(),
383            })
384            .collect();
385
386        diff_lines.push(DiffLine {
387            tag: DiffTag::Removed,
388            content: "old".to_string(),
389        });
390
391        diff_lines.push(DiffLine {
392            tag: DiffTag::Added,
393            content: "new".to_string(),
394        });
395
396        diff_lines.extend((0..22).map(|_| DiffLine {
397            tag: DiffTag::Context,
398            content: "after".to_string(),
399        }));
400
401        let preview = DiffPreview {
402            lines: diff_lines,
403            rows: vec![],
404            lang_hint: String::new(),
405            start_line: Some(42),
406        };
407
408        let lines = highlight_diff(&preview, &test_context());
409        let has_change = lines.iter().any(|l| {
410            let text = l.plain_text();
411            text.contains("- old") || text.contains("+ new")
412        });
413
414        assert!(
415            has_change,
416            "focused preview should show changes within the truncation budget"
417        );
418    }
419
420    #[test]
421    fn no_line_numbers_when_start_line_none() {
422        let preview = make_preview(vec![DiffLine {
423            tag: DiffTag::Removed,
424            content: "old".to_string(),
425        }]);
426        let lines = highlight_diff(&preview, &test_context());
427        let text = lines[0].plain_text();
428        // Without line numbers, the line should start with the prefix directly
429        assert!(
430            text.starts_with("  - "),
431            "expected prefix without line number gutter: {text}"
432        );
433    }
434}