Skip to main content

tui/diffs/
split_diff.rs

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
17/// Renders a diff preview, choosing split or unified based on terminal width
18/// and whether the diff has removals.
19///
20/// For new files (only additions, no removals), uses unified view since
21/// split view would have an empty left panel.
22pub 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(
48            row.right.as_ref(),
49            right_content,
50            &preview.lang_hint,
51            context,
52        );
53
54        let height = left_lines.len().max(right_lines.len());
55
56        if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
57            break;
58        }
59
60        for i in 0..height {
61            let left = left_lines
62                .get(i)
63                .cloned()
64                .unwrap_or_else(|| blank_panel(left_panel));
65            let right = right_lines
66                .get(i)
67                .cloned()
68                .unwrap_or_else(|| blank_panel(right_panel));
69
70            let mut line = left;
71            line.push_styled(SEPARATOR, theme.muted());
72            line.append_line(&right);
73            lines.push(line);
74        }
75
76        visual_lines += height;
77        rows_consumed += 1;
78    }
79
80    if rows_consumed < preview.rows.len() {
81        let remaining = preview.rows.len() - rows_consumed;
82        let mut overflow = Line::default();
83        overflow.push_styled(format!("    ... {remaining} more lines"), theme.muted());
84        lines.push(overflow);
85    }
86
87    lines
88}
89
90pub fn blank_panel(width: usize) -> Line {
91    let mut line = Line::default();
92    line.push_text(" ".repeat(width));
93    line
94}
95
96pub fn render_cell(
97    cell: Option<&SplitDiffCell>,
98    content_width: usize,
99    lang_hint: &str,
100    context: &ViewContext,
101) -> Vec<Line> {
102    let theme = &context.theme;
103    let panel_width = GUTTER_WIDTH + content_width;
104
105    let Some(cell) = cell else {
106        return vec![blank_panel(panel_width)];
107    };
108
109    let is_context = cell.tag == DiffTag::Context;
110    let bg = match cell.tag {
111        DiffTag::Removed => Some(theme.diff_removed_bg()),
112        DiffTag::Added => Some(theme.diff_added_bg()),
113        DiffTag::Context => None,
114    };
115
116    // Syntax-highlighted content
117    let highlighted = context
118        .highlighter()
119        .highlight(&cell.content, lang_hint, theme);
120
121    let content_line = if let Some(hl_line) = highlighted.first() {
122        let mut styled_content = Line::default();
123        for span in hl_line.spans() {
124            let mut span_style = span.style();
125            if let Some(bg) = bg {
126                span_style.bg = Some(bg);
127            }
128            if is_context {
129                span_style.dim = true;
130            }
131            styled_content.push_span(Span::with_style(span.text(), span_style));
132        }
133        styled_content
134    } else {
135        let fg = match cell.tag {
136            DiffTag::Removed => theme.diff_removed_fg(),
137            DiffTag::Added => theme.diff_added_fg(),
138            DiffTag::Context => theme.code_fg(),
139        };
140        let mut style = Style::fg(fg);
141        if let Some(bg) = bg {
142            style = style.bg_color(bg);
143        }
144        if is_context {
145            style.dim = true;
146        }
147        Line::with_style(&cell.content, style)
148    };
149
150    // content_width is derived from terminal width (u16), so it always fits in u16
151    #[allow(clippy::cast_possible_truncation)]
152    let wrapped = soft_wrap_line(&content_line, content_width as u16);
153
154    wrapped
155        .into_iter()
156        .enumerate()
157        .map(|(i, mut wrapped_line)| {
158            wrapped_line.extend_bg_to_width(content_width);
159            let mut line = Line::default();
160            if i == 0 {
161                if let Some(num) = cell.line_number {
162                    line.push_styled(format!("{num:>4} "), theme.muted());
163                } else {
164                    line.push_styled("     ", theme.muted());
165                }
166            } else {
167                line.push_text(" ".repeat(GUTTER_WIDTH));
168            }
169            line.append_line(&wrapped_line);
170            line
171        })
172        .collect()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::{DiffLine, SplitDiffCell, SplitDiffRow};
179
180    fn test_context_with_width(width: u16) -> ViewContext {
181        ViewContext::new((width, 24))
182    }
183
184    fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
185        DiffPreview {
186            lines: vec![],
187            rows,
188            lang_hint: String::new(),
189            start_line: None,
190        }
191    }
192
193    fn removed_cell(content: &str, line_num: usize) -> SplitDiffCell {
194        SplitDiffCell {
195            tag: DiffTag::Removed,
196            content: content.to_string(),
197            line_number: Some(line_num),
198        }
199    }
200
201    fn added_cell(content: &str, line_num: usize) -> SplitDiffCell {
202        SplitDiffCell {
203            tag: DiffTag::Added,
204            content: content.to_string(),
205            line_number: Some(line_num),
206        }
207    }
208
209    #[test]
210    fn both_panels_rendered_with_content() {
211        let preview = make_split_preview(vec![SplitDiffRow {
212            left: Some(removed_cell("old code", 1)),
213            right: Some(added_cell("new code", 1)),
214        }]);
215        let ctx = test_context_with_width(100);
216        let lines = highlight_split_diff(&preview, &ctx);
217        assert_eq!(lines.len(), 1);
218        let text = lines[0].plain_text();
219        assert!(text.contains("old code"), "left panel missing: {text}");
220        assert!(text.contains("new code"), "right panel missing: {text}");
221    }
222
223    #[test]
224    fn long_lines_wrapped_within_terminal_width() {
225        let long = "x".repeat(200);
226        let preview = make_split_preview(vec![SplitDiffRow {
227            left: Some(removed_cell(&long, 1)),
228            right: Some(added_cell(&long, 1)),
229        }]);
230        let ctx = test_context_with_width(100);
231        let lines = highlight_split_diff(&preview, &ctx);
232        assert!(
233            lines.len() > 1,
234            "long line should wrap into multiple visual lines, got {}",
235            lines.len()
236        );
237        for line in &lines {
238            let width = line.display_width();
239            assert!(
240                width <= 100,
241                "line width {width} should not exceed terminal width 100"
242            );
243        }
244        // Full content should be present across all wrapped lines
245        let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
246        let x_count = all_text.chars().filter(|&c| c == 'x').count();
247        // Both left and right panels contain 200 x's each
248        assert_eq!(
249            x_count, 400,
250            "all content should be present across wrapped lines"
251        );
252    }
253
254    #[test]
255    fn truncation_budget_applied() {
256        let rows: Vec<SplitDiffRow> = (0..30)
257            .map(|i| SplitDiffRow {
258                left: Some(removed_cell(&format!("old {i}"), i + 1)),
259                right: Some(added_cell(&format!("new {i}"), i + 1)),
260            })
261            .collect();
262        let preview = make_split_preview(rows);
263        let ctx = test_context_with_width(100);
264        let lines = highlight_split_diff(&preview, &ctx);
265        // Short lines don't wrap, so 20 visual lines + 1 overflow
266        assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
267        let last = lines.last().unwrap().plain_text();
268        assert!(last.contains("more lines"), "overflow text missing: {last}");
269    }
270
271    #[test]
272    fn empty_preview_produces_no_output() {
273        let preview = make_split_preview(vec![]);
274        let ctx = test_context_with_width(100);
275        let lines = highlight_split_diff(&preview, &ctx);
276        assert!(lines.is_empty());
277    }
278
279    #[test]
280    fn render_diff_dispatches_to_unified_below_80() {
281        let preview = DiffPreview {
282            lines: vec![DiffLine {
283                tag: DiffTag::Removed,
284                content: "old".to_string(),
285            }],
286            rows: vec![SplitDiffRow {
287                left: Some(removed_cell("old", 1)),
288                right: None,
289            }],
290            lang_hint: String::new(),
291            start_line: None,
292        };
293        let ctx = test_context_with_width(79);
294        let lines = render_diff(&preview, &ctx);
295        // Unified renderer uses prefix "  - "
296        assert!(
297            lines[0].plain_text().contains("- old"),
298            "should use unified renderer below 80: {}",
299            lines[0].plain_text()
300        );
301    }
302
303    #[test]
304    fn new_file_uses_unified_view_even_at_wide_width() {
305        // A new file (only additions, no removals) should use unified view
306        // since split view would have an empty left panel
307        let preview = DiffPreview {
308            lines: vec![
309                DiffLine {
310                    tag: DiffTag::Added,
311                    content: "fn main() {".to_string(),
312                },
313                DiffLine {
314                    tag: DiffTag::Added,
315                    content: "    println!(\"Hello\");".to_string(),
316                },
317                DiffLine {
318                    tag: DiffTag::Added,
319                    content: "}".to_string(),
320                },
321            ],
322            rows: vec![
323                SplitDiffRow {
324                    left: None,
325                    right: Some(added_cell("fn main() {", 1)),
326                },
327                SplitDiffRow {
328                    left: None,
329                    right: Some(added_cell("    println!(\"Hello\");", 2)),
330                },
331                SplitDiffRow {
332                    left: None,
333                    right: Some(added_cell("}", 3)),
334                },
335            ],
336            lang_hint: "rs".to_string(),
337            start_line: None,
338        };
339        let ctx = test_context_with_width(100);
340        let lines = render_diff(&preview, &ctx);
341        // Unified renderer uses prefix "  + "
342        let text = lines[0].plain_text();
343        assert!(
344            text.contains("+ fn main()"),
345            "should use unified renderer for new file: {text}"
346        );
347    }
348
349    #[test]
350    fn render_diff_dispatches_to_split_at_80() {
351        let preview = DiffPreview {
352            lines: vec![DiffLine {
353                tag: DiffTag::Removed,
354                content: "old".to_string(),
355            }],
356            rows: vec![SplitDiffRow {
357                left: Some(removed_cell("old", 1)),
358                right: None,
359            }],
360            lang_hint: String::new(),
361            start_line: None,
362        };
363        let ctx = test_context_with_width(80);
364        let lines = render_diff(&preview, &ctx);
365        let text = lines[0].plain_text();
366        // Split renderer shows line number gutter, not unified "- " prefix
367        assert!(
368            !text.contains("- old"),
369            "should use split renderer at 80: {text}"
370        );
371    }
372
373    #[test]
374    fn line_numbers_rendered_when_start_line_set() {
375        let preview = make_split_preview(vec![SplitDiffRow {
376            left: Some(SplitDiffCell {
377                tag: DiffTag::Context,
378                content: "hello".to_string(),
379                line_number: Some(42),
380            }),
381            right: Some(SplitDiffCell {
382                tag: DiffTag::Context,
383                content: "hello".to_string(),
384                line_number: Some(42),
385            }),
386        }]);
387        let ctx = test_context_with_width(100);
388        let lines = highlight_split_diff(&preview, &ctx);
389        let text = lines[0].plain_text();
390        assert!(text.contains("42"), "line number should be shown: {text}");
391    }
392
393    #[test]
394    fn wrapped_row_pads_shorter_side_to_match_height() {
395        // Left side has a long line that wraps, right side is short
396        let long = "a".repeat(200);
397        let preview = make_split_preview(vec![SplitDiffRow {
398            left: Some(removed_cell(&long, 1)),
399            right: Some(added_cell("short", 1)),
400        }]);
401        let ctx = test_context_with_width(100);
402        let lines = highlight_split_diff(&preview, &ctx);
403        assert!(
404            lines.len() > 1,
405            "long left side should produce multiple visual lines"
406        );
407        // All lines should have consistent width
408        let first_width = lines[0].display_width();
409        for (i, line) in lines.iter().enumerate() {
410            assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
411        }
412    }
413
414    #[test]
415    fn blank_gutter_when_line_number_none() {
416        let preview = make_split_preview(vec![SplitDiffRow {
417            left: Some(SplitDiffCell {
418                tag: DiffTag::Removed,
419                content: "old".to_string(),
420                line_number: None,
421            }),
422            right: None,
423        }]);
424        let ctx = test_context_with_width(100);
425        let lines = highlight_split_diff(&preview, &ctx);
426        let text = lines[0].plain_text();
427        assert!(
428            text.starts_with("     "),
429            "should have blank gutter: {text:?}"
430        );
431    }
432
433}