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
19pub 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 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 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 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 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 assert!(text.starts_with(" - "), "expected prefix without line number gutter: {text}");
303 }
304}