1use crate::diffs::diff::highlight_diff;
2use crate::line::Line;
3use crate::rendering::render_context::ViewContext;
4use crate::rendering::soft_wrap::soft_wrap_line;
5use crate::span::Span;
6use crate::style::Style;
7
8use crate::{DiffPreview, DiffTag, SplitDiffCell};
9
10const MAX_DIFF_LINES: usize = 20;
11pub const MIN_SPLIT_WIDTH: u16 = 80;
12pub const GUTTER_WIDTH: usize = 5;
13pub const SEPARATOR: &str = "";
14pub const SEPARATOR_WIDTH: usize = 0;
15const FIXED_OVERHEAD: usize = GUTTER_WIDTH * 2 + SEPARATOR_WIDTH;
16
17pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
23 let has_removals = preview.lines.iter().any(|l| l.tag == DiffTag::Removed);
24
25 if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
26 highlight_split_diff(preview, context)
27 } else {
28 highlight_diff(preview, context)
29 }
30}
31
32fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
33 let theme = &context.theme;
34 let terminal_width = context.size.width as usize;
35 let usable = terminal_width.saturating_sub(FIXED_OVERHEAD);
36 let left_content = usable / 2;
37 let right_content = usable - left_content;
38 let left_panel = GUTTER_WIDTH + left_content;
39 let right_panel = GUTTER_WIDTH + right_content;
40
41 let mut lines = Vec::new();
42 let mut visual_lines = 0usize;
43 let mut rows_consumed = 0usize;
44
45 for row in &preview.rows {
46 let left_lines = render_cell(row.left.as_ref(), left_content, &preview.lang_hint, context);
47 let right_lines = render_cell(row.right.as_ref(), right_content, &preview.lang_hint, context);
48
49 let height = left_lines.len().max(right_lines.len());
50
51 if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
52 break;
53 }
54
55 for i in 0..height {
56 let left = left_lines.get(i).cloned().unwrap_or_else(|| blank_panel(left_panel));
57 let right = right_lines.get(i).cloned().unwrap_or_else(|| blank_panel(right_panel));
58
59 let mut line = left;
60 line.push_styled(SEPARATOR, theme.muted());
61 line.append_line(&right);
62 lines.push(line);
63 }
64
65 visual_lines += height;
66 rows_consumed += 1;
67 }
68
69 if rows_consumed < preview.rows.len() {
70 let remaining = preview.rows.len() - rows_consumed;
71 let mut overflow = Line::default();
72 overflow.push_styled(format!(" ... {remaining} more lines"), theme.muted());
73 lines.push(overflow);
74 }
75
76 lines
77}
78
79pub fn blank_panel(width: usize) -> Line {
80 let mut line = Line::default();
81 line.push_text(" ".repeat(width));
82 line
83}
84
85pub fn render_cell(
86 cell: Option<&SplitDiffCell>,
87 content_width: usize,
88 lang_hint: &str,
89 context: &ViewContext,
90) -> Vec<Line> {
91 let theme = &context.theme;
92 let panel_width = GUTTER_WIDTH + content_width;
93
94 let Some(cell) = cell else {
95 return vec![blank_panel(panel_width)];
96 };
97
98 let is_context = cell.tag == DiffTag::Context;
99 let bg = match cell.tag {
100 DiffTag::Removed => Some(theme.diff_removed_bg()),
101 DiffTag::Added => Some(theme.diff_added_bg()),
102 DiffTag::Context => None,
103 };
104
105 let highlighted = context.highlighter().highlight(&cell.content, lang_hint, theme);
107
108 let content_line = if let Some(hl_line) = highlighted.first() {
109 let mut styled_content = Line::default();
110 for span in hl_line.spans() {
111 let mut span_style = span.style();
112 if let Some(bg) = bg {
113 span_style.bg = Some(bg);
114 }
115 if is_context {
116 span_style.dim = true;
117 }
118 styled_content.push_span(Span::with_style(span.text(), span_style));
119 }
120 styled_content
121 } else {
122 let fg = match cell.tag {
123 DiffTag::Removed => theme.diff_removed_fg(),
124 DiffTag::Added => theme.diff_added_fg(),
125 DiffTag::Context => theme.code_fg(),
126 };
127 let mut style = Style::fg(fg);
128 if let Some(bg) = bg {
129 style = style.bg_color(bg);
130 }
131 if is_context {
132 style.dim = true;
133 }
134 Line::with_style(&cell.content, style)
135 };
136
137 #[allow(clippy::cast_possible_truncation)]
139 let wrapped = soft_wrap_line(&content_line, content_width as u16);
140
141 wrapped
142 .into_iter()
143 .enumerate()
144 .map(|(i, mut wrapped_line)| {
145 wrapped_line.extend_bg_to_width(content_width);
146 let mut line = Line::default();
147 if i == 0 {
148 if let Some(num) = cell.line_number {
149 line.push_styled(format!("{num:>4} "), theme.muted());
150 } else {
151 line.push_styled(" ", theme.muted());
152 }
153 } else {
154 line.push_text(" ".repeat(GUTTER_WIDTH));
155 }
156 line.append_line(&wrapped_line);
157 line
158 })
159 .collect()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::rendering::line::Line;
166 use crate::{DiffLine, SplitDiffCell, SplitDiffRow};
167
168 fn test_context_with_width(width: u16) -> ViewContext {
169 ViewContext::new((width, 24))
170 }
171
172 fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
173 DiffPreview { lines: vec![], rows, lang_hint: String::new(), start_line: None }
174 }
175
176 fn removed_cell(content: &str, line_num: usize) -> SplitDiffCell {
177 SplitDiffCell { tag: DiffTag::Removed, content: content.to_string(), line_number: Some(line_num) }
178 }
179
180 fn added_cell(content: &str, line_num: usize) -> SplitDiffCell {
181 SplitDiffCell { tag: DiffTag::Added, content: content.to_string(), line_number: Some(line_num) }
182 }
183
184 #[test]
185 fn both_panels_rendered_with_content() {
186 let preview = make_split_preview(vec![SplitDiffRow {
187 left: Some(removed_cell("old code", 1)),
188 right: Some(added_cell("new code", 1)),
189 }]);
190 let ctx = test_context_with_width(100);
191 let lines = highlight_split_diff(&preview, &ctx);
192 assert_eq!(lines.len(), 1);
193 let text = lines[0].plain_text();
194 assert!(text.contains("old code"), "left panel missing: {text}");
195 assert!(text.contains("new code"), "right panel missing: {text}");
196 }
197
198 #[test]
199 fn long_lines_wrapped_within_terminal_width() {
200 let long = "x".repeat(200);
201 let preview = make_split_preview(vec![SplitDiffRow {
202 left: Some(removed_cell(&long, 1)),
203 right: Some(added_cell(&long, 1)),
204 }]);
205 let ctx = test_context_with_width(100);
206 let lines = highlight_split_diff(&preview, &ctx);
207 assert!(lines.len() > 1, "long line should wrap into multiple visual lines, got {}", lines.len());
208 for line in &lines {
209 let width = line.display_width();
210 assert!(width <= 100, "line width {width} should not exceed terminal width 100");
211 }
212 let all_text: String = lines.iter().map(Line::plain_text).collect();
214 let x_count = all_text.chars().filter(|&c| c == 'x').count();
215 assert_eq!(x_count, 400, "all content should be present across wrapped lines");
217 }
218
219 #[test]
220 fn truncation_budget_applied() {
221 let rows: Vec<SplitDiffRow> = (0..30)
222 .map(|i| SplitDiffRow {
223 left: Some(removed_cell(&format!("old {i}"), i + 1)),
224 right: Some(added_cell(&format!("new {i}"), i + 1)),
225 })
226 .collect();
227 let preview = make_split_preview(rows);
228 let ctx = test_context_with_width(100);
229 let lines = highlight_split_diff(&preview, &ctx);
230 assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
232 let last = lines.last().unwrap().plain_text();
233 assert!(last.contains("more lines"), "overflow text missing: {last}");
234 }
235
236 #[test]
237 fn empty_preview_produces_no_output() {
238 let preview = make_split_preview(vec![]);
239 let ctx = test_context_with_width(100);
240 let lines = highlight_split_diff(&preview, &ctx);
241 assert!(lines.is_empty());
242 }
243
244 #[test]
245 fn render_diff_dispatches_to_unified_below_80() {
246 let preview = DiffPreview {
247 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
248 rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
249 lang_hint: String::new(),
250 start_line: None,
251 };
252 let ctx = test_context_with_width(79);
253 let lines = render_diff(&preview, &ctx);
254 assert!(
256 lines[0].plain_text().contains("- old"),
257 "should use unified renderer below 80: {}",
258 lines[0].plain_text()
259 );
260 }
261
262 #[test]
263 fn new_file_uses_unified_view_even_at_wide_width() {
264 let preview = DiffPreview {
267 lines: vec![
268 DiffLine { tag: DiffTag::Added, content: "fn main() {".to_string() },
269 DiffLine { tag: DiffTag::Added, content: " println!(\"Hello\");".to_string() },
270 DiffLine { tag: DiffTag::Added, content: "}".to_string() },
271 ],
272 rows: vec![
273 SplitDiffRow { left: None, right: Some(added_cell("fn main() {", 1)) },
274 SplitDiffRow { left: None, right: Some(added_cell(" println!(\"Hello\");", 2)) },
275 SplitDiffRow { left: None, right: Some(added_cell("}", 3)) },
276 ],
277 lang_hint: "rs".to_string(),
278 start_line: None,
279 };
280 let ctx = test_context_with_width(100);
281 let lines = render_diff(&preview, &ctx);
282 let text = lines[0].plain_text();
284 assert!(text.contains("+ fn main()"), "should use unified renderer for new file: {text}");
285 }
286
287 #[test]
288 fn render_diff_dispatches_to_split_at_80() {
289 let preview = DiffPreview {
290 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
291 rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
292 lang_hint: String::new(),
293 start_line: None,
294 };
295 let ctx = test_context_with_width(80);
296 let lines = render_diff(&preview, &ctx);
297 let text = lines[0].plain_text();
298 assert!(!text.contains("- old"), "should use split renderer at 80: {text}");
300 }
301
302 #[test]
303 fn line_numbers_rendered_when_start_line_set() {
304 let preview = make_split_preview(vec![SplitDiffRow {
305 left: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
306 right: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
307 }]);
308 let ctx = test_context_with_width(100);
309 let lines = highlight_split_diff(&preview, &ctx);
310 let text = lines[0].plain_text();
311 assert!(text.contains("42"), "line number should be shown: {text}");
312 }
313
314 #[test]
315 fn wrapped_row_pads_shorter_side_to_match_height() {
316 let long = "a".repeat(200);
318 let preview = make_split_preview(vec![SplitDiffRow {
319 left: Some(removed_cell(&long, 1)),
320 right: Some(added_cell("short", 1)),
321 }]);
322 let ctx = test_context_with_width(100);
323 let lines = highlight_split_diff(&preview, &ctx);
324 assert!(lines.len() > 1, "long left side should produce multiple visual lines");
325 let first_width = lines[0].display_width();
327 for (i, line) in lines.iter().enumerate() {
328 assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
329 }
330 }
331
332 #[test]
333 fn blank_gutter_when_line_number_none() {
334 let preview = make_split_preview(vec![SplitDiffRow {
335 left: Some(SplitDiffCell { tag: DiffTag::Removed, content: "old".to_string(), line_number: None }),
336 right: None,
337 }]);
338 let ctx = test_context_with_width(100);
339 let lines = highlight_split_diff(&preview, &ctx);
340 let text = lines[0].plain_text();
341 assert!(text.starts_with(" "), "should have blank gutter: {text:?}");
342 }
343}