Skip to main content

tui/diffs/
split_diff.rs

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