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 {
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 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 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 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 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 assert!(
430 text.starts_with(" - "),
431 "expected prefix without line number gutter: {text}"
432 );
433 }
434}