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(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    // Syntax-highlighted content
106    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    // content_width is derived from terminal width (u16), so it always fits in u16
138    #[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        // Full content should be present across all wrapped lines
213        let all_text: String = lines.iter().map(Line::plain_text).collect();
214        let x_count = all_text.chars().filter(|&c| c == 'x').count();
215        // Both left and right panels contain 200 x's each
216        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        // Short lines don't wrap, so 20 visual lines + 1 overflow
231        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        // Unified renderer uses prefix "  - "
255        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        // A new file (only additions, no removals) should use unified view
265        // since split view would have an empty left panel
266        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        // Unified renderer uses prefix "  + "
283        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        // Split renderer shows line number gutter, not unified "- " prefix
299        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        // Left side has a long line that wraps, right side is short
317        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        // All lines should have consistent width
326        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}