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 { prefix: "    ", fg: theme.code_fg(), bg: None };
33    let removed_style = DiffStyle { prefix: "  - ", fg: theme.diff_removed_fg(), bg: Some(theme.diff_removed_bg()) };
34    let added_style = DiffStyle { prefix: "  + ", fg: theme.diff_added_fg(), bg: Some(theme.diff_added_bg()) };
35
36    let mut old_line = preview.start_line.unwrap_or(0);
37
38    for diff_line in preview.lines.iter().take(budget) {
39        let style = match diff_line.tag {
40            DiffTag::Context => &context_style,
41            DiffTag::Removed => &removed_style,
42            DiffTag::Added => &added_style,
43        };
44
45        let mut line = Line::default();
46
47        if preview.start_line.is_some() {
48            match diff_line.tag {
49                DiffTag::Context | DiffTag::Removed => {
50                    let line_num = format!("{old_line:>4} ");
51                    line.push_styled(line_num, theme.muted());
52                }
53                DiffTag::Added => {
54                    line.push_styled("     ", theme.muted());
55                }
56            }
57        }
58
59        let mut prefix_style = Style::fg(style.fg);
60        if let Some(bg) = style.bg {
61            prefix_style = prefix_style.bg_color(bg);
62        }
63        line.push_span(Span::with_style(style.prefix, prefix_style));
64
65        let spans = context.highlighter().highlight(&diff_line.content, &preview.lang_hint, theme);
66        if let Some(content) = spans.first() {
67            for span in content.spans() {
68                let mut span_style = span.style();
69                if let Some(bg) = style.bg {
70                    span_style.bg = Some(bg);
71                }
72                line.push_span(Span::with_style(span.text(), span_style));
73            }
74        }
75        lines.push(line);
76
77        if matches!(diff_line.tag, DiffTag::Context | DiffTag::Removed) {
78            old_line += 1;
79        }
80    }
81
82    if truncated {
83        let remaining = total - budget;
84        let mut overflow = Line::default();
85        overflow.push_styled(format!("    ... {remaining} more lines"), theme.muted());
86        lines.push(overflow);
87    }
88
89    lines
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::DiffLine;
96
97    fn test_context() -> ViewContext {
98        ViewContext::new((80, 24))
99    }
100
101    fn test_theme() -> Theme {
102        Theme::default()
103    }
104
105    fn make_preview(lines: Vec<DiffLine>) -> DiffPreview {
106        DiffPreview { lines, rows: vec![], lang_hint: String::new(), start_line: None }
107    }
108
109    #[test]
110    fn removed_lines_have_minus_prefix() {
111        let preview = make_preview(vec![DiffLine { tag: DiffTag::Removed, content: "old line".to_string() }]);
112        let lines = highlight_diff(&preview, &test_context());
113        assert_eq!(lines.len(), 1);
114        assert!(lines[0].plain_text().contains("- old line"));
115    }
116
117    #[test]
118    fn added_lines_have_plus_prefix() {
119        let preview = make_preview(vec![DiffLine { tag: DiffTag::Added, content: "new line".to_string() }]);
120        let lines = highlight_diff(&preview, &test_context());
121        assert_eq!(lines.len(), 1);
122        assert!(lines[0].plain_text().contains("+ new line"));
123    }
124
125    #[test]
126    fn context_lines_have_no_diff_prefix() {
127        let preview = make_preview(vec![DiffLine { tag: DiffTag::Context, content: "unchanged".to_string() }]);
128        let lines = highlight_diff(&preview, &test_context());
129        assert_eq!(lines.len(), 1);
130        let text = lines[0].plain_text();
131        assert!(text.starts_with("    "), "context should have space prefix: {text}");
132        assert!(!text.contains("+ "), "context should not have + prefix");
133        assert!(!text.contains("- "), "context should not have - prefix");
134    }
135
136    #[test]
137    fn mixed_diff_renders_correctly() {
138        let preview = make_preview(vec![
139            DiffLine { tag: DiffTag::Context, content: "before".to_string() },
140            DiffLine { tag: DiffTag::Removed, content: "old".to_string() },
141            DiffLine { tag: DiffTag::Added, content: "new".to_string() },
142            DiffLine { tag: DiffTag::Context, content: "after".to_string() },
143        ]);
144        let lines = highlight_diff(&preview, &test_context());
145        assert_eq!(lines.len(), 4);
146        assert!(lines[0].plain_text().contains("before"));
147        assert!(lines[1].plain_text().contains("- old"));
148        assert!(lines[2].plain_text().contains("+ new"));
149        assert!(lines[3].plain_text().contains("after"));
150    }
151
152    #[test]
153    fn both_removed_and_added() {
154        let preview = make_preview(vec![
155            DiffLine { tag: DiffTag::Removed, content: "old".to_string() },
156            DiffLine { tag: DiffTag::Added, content: "new".to_string() },
157        ]);
158        let lines = highlight_diff(&preview, &test_context());
159        assert_eq!(lines.len(), 2);
160        assert!(lines[0].plain_text().contains("- old"));
161        assert!(lines[1].plain_text().contains("+ new"));
162    }
163
164    #[test]
165    fn truncates_long_diffs() {
166        let diff_lines: Vec<DiffLine> = (0..30)
167            .map(|i| DiffLine {
168                tag: if i % 2 == 0 { DiffTag::Removed } else { DiffTag::Added },
169                content: format!("line {i}"),
170            })
171            .collect();
172        let preview = make_preview(diff_lines);
173        let lines = highlight_diff(&preview, &test_context());
174        // 20 content lines + 1 overflow line
175        assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
176        let last = lines.last().unwrap().plain_text();
177        assert!(last.contains("more lines"), "Expected overflow text: {last}");
178    }
179
180    #[test]
181    fn no_truncation_at_boundary() {
182        let diff_lines: Vec<DiffLine> = (0..20)
183            .map(|i| DiffLine {
184                tag: if i % 2 == 0 { DiffTag::Removed } else { DiffTag::Added },
185                content: format!("line {i}"),
186            })
187            .collect();
188        let preview = make_preview(diff_lines);
189        let lines = highlight_diff(&preview, &test_context());
190        assert_eq!(lines.len(), 20);
191        assert!(!lines.last().unwrap().plain_text().contains("more lines"));
192    }
193
194    #[test]
195    fn syntax_highlighting_with_known_lang() {
196        let preview = DiffPreview {
197            lines: vec![
198                DiffLine { tag: DiffTag::Removed, content: "fn old() {}".to_string() },
199                DiffLine { tag: DiffTag::Added, content: "fn new() {}".to_string() },
200            ],
201            rows: vec![],
202            lang_hint: "rs".to_string(),
203            start_line: None,
204        };
205        let lines = highlight_diff(&preview, &test_context());
206        assert_eq!(lines.len(), 2);
207        // With syntax highlighting, there should be multiple spans (not just 2: prefix + text)
208        assert!(lines[0].spans().len() > 2, "Expected syntax-highlighted spans, got {} spans", lines[0].spans().len());
209    }
210
211    #[test]
212    fn removed_lines_have_red_bg() {
213        let preview = make_preview(vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }]);
214        let theme = test_theme();
215        let ctx = test_context();
216        let lines = highlight_diff(&preview, &ctx);
217        let prefix_span = &lines[0].spans()[0];
218        assert_eq!(prefix_span.style().bg, Some(theme.diff_removed_bg()));
219        assert_eq!(prefix_span.style().fg, Some(theme.diff_removed_fg()));
220    }
221
222    #[test]
223    fn added_lines_have_green_bg() {
224        let preview = make_preview(vec![DiffLine { tag: DiffTag::Added, content: "new".to_string() }]);
225        let theme = test_theme();
226        let ctx = test_context();
227        let lines = highlight_diff(&preview, &ctx);
228        let prefix_span = &lines[0].spans()[0];
229        assert_eq!(prefix_span.style().bg, Some(theme.diff_added_bg()));
230        assert_eq!(prefix_span.style().fg, Some(theme.diff_added_fg()));
231    }
232
233    #[test]
234    fn context_lines_have_code_bg() {
235        let preview = make_preview(vec![DiffLine { tag: DiffTag::Context, content: "same".to_string() }]);
236        let ctx = test_context();
237        let lines = highlight_diff(&preview, &ctx);
238        let prefix_span = &lines[0].spans()[0];
239        assert_eq!(prefix_span.style().bg, None);
240    }
241
242    #[test]
243    fn empty_diff_produces_no_lines() {
244        let preview = make_preview(vec![]);
245        let lines = highlight_diff(&preview, &test_context());
246        assert!(lines.is_empty());
247    }
248
249    #[test]
250    fn line_numbers_rendered_when_start_line_set() {
251        let preview = DiffPreview {
252            lines: vec![
253                DiffLine { tag: DiffTag::Context, content: "ctx".to_string() },
254                DiffLine { tag: DiffTag::Removed, content: "old".to_string() },
255                DiffLine { tag: DiffTag::Added, content: "new".to_string() },
256                DiffLine { tag: DiffTag::Context, content: "ctx2".to_string() },
257            ],
258            rows: vec![],
259            lang_hint: String::new(),
260            start_line: Some(10),
261        };
262        let lines = highlight_diff(&preview, &test_context());
263        assert_eq!(lines.len(), 4);
264        assert!(lines[0].plain_text().contains("10"), "context line should show 10");
265        assert!(lines[1].plain_text().contains("11"), "removed line should show 11");
266        // Added lines don't show a source line number
267        let added_text = lines[2].plain_text();
268        assert!(!added_text.starts_with("  12"), "added line should not show line number");
269        assert!(lines[3].plain_text().contains("12"), "next context should show 12");
270    }
271
272    #[test]
273    fn focused_preview_with_truncation_shows_changes() {
274        // Simulates a DiffPreview produced by compute_diff_preview after trimming:
275        // 3 context lines, then changes, then 22 more context lines (total 27 > MAX_DIFF_LINES).
276        let mut diff_lines: Vec<DiffLine> =
277            (0..3).map(|_| DiffLine { tag: DiffTag::Context, content: "before".to_string() }).collect();
278
279        diff_lines.push(DiffLine { tag: DiffTag::Removed, content: "old".to_string() });
280
281        diff_lines.push(DiffLine { tag: DiffTag::Added, content: "new".to_string() });
282
283        diff_lines.extend((0..22).map(|_| DiffLine { tag: DiffTag::Context, content: "after".to_string() }));
284
285        let preview = DiffPreview { lines: diff_lines, rows: vec![], lang_hint: String::new(), start_line: Some(42) };
286
287        let lines = highlight_diff(&preview, &test_context());
288        let has_change = lines.iter().any(|l| {
289            let text = l.plain_text();
290            text.contains("- old") || text.contains("+ new")
291        });
292
293        assert!(has_change, "focused preview should show changes within the truncation budget");
294    }
295
296    #[test]
297    fn no_line_numbers_when_start_line_none() {
298        let preview = make_preview(vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }]);
299        let lines = highlight_diff(&preview, &test_context());
300        let text = lines[0].plain_text();
301        // Without line numbers, the line should start with the prefix directly
302        assert!(text.starts_with("  - "), "expected prefix without line number gutter: {text}");
303    }
304}