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::gutter::digit_count;
5use crate::rendering::render_context::ViewContext;
6use crate::span::Span;
7use crate::style::Style;
8use crossterm::style::Color;
9
10use crate::{DiffPreview, DiffTag, SplitDiffEntry};
11
12const MAX_DIFF_LINES: usize = 20;
13pub const MIN_SPLIT_WIDTH: u16 = 80;
14pub const MIN_GUTTER_WIDTH: usize = 3;
15pub const SEPARATOR: &str = " ";
16pub const SEPARATOR_WIDTH: usize = 1;
17const SEPARATOR_WIDTH_U16: u16 = 1;
18const LEFT_INNER_PAD: usize = 1;
19pub const WRAP_MARKER: &str = "↪";
20
21/// Which pane a [`render_entry`] call is rendering
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Side {
24    Left,
25    Right,
26}
27
28/// Whether changed rows tint the line-number gutter with their diff color.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GutterTint {
31    Neutral,
32    Diff,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SplitLayoutDimensions {
37    pub gutter_width: usize,
38    pub left_content_width: usize,
39    pub right_content_width: usize,
40    pub left_panel_width: u16,
41    pub right_panel_width: u16,
42}
43
44impl SplitLayoutDimensions {
45    pub fn new(total_width: usize, max_line_no: usize) -> Self {
46        let gutter_width = gutter_width_for_max_line(max_line_no);
47        let fixed_overhead = gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD;
48        let usable = total_width.saturating_sub(fixed_overhead);
49        let left_content_width = usable / 2;
50        let right_content_width = usable - left_content_width;
51        Self {
52            gutter_width,
53            left_content_width,
54            right_content_width,
55            left_panel_width: usize_to_u16_saturating(gutter_width + left_content_width + LEFT_INNER_PAD),
56            right_panel_width: usize_to_u16_saturating(gutter_width + right_content_width),
57        }
58    }
59
60    pub fn for_preview(preview: &DiffPreview, total_width: usize) -> Self {
61        Self::new(total_width, max_line_no_for_preview(preview))
62    }
63}
64
65fn gutter_width_for_max_line(max_line_no: usize) -> usize {
66    (digit_count(max_line_no) + 1).max(MIN_GUTTER_WIDTH)
67}
68
69pub fn header_left_padding(max_line_no: usize) -> usize {
70    gutter_width_for_max_line(max_line_no).saturating_sub(1).saturating_sub(digit_count(max_line_no))
71}
72
73fn max_line_no_for_entries(pairs: impl IntoIterator<Item = (Option<usize>, Option<usize>)>) -> usize {
74    pairs.into_iter().flat_map(|(left, right)| left.into_iter().chain(right)).max().unwrap_or(0)
75}
76
77pub fn usize_to_u16_saturating(value: usize) -> u16 {
78    u16::try_from(value).unwrap_or(u16::MAX)
79}
80
81/// Renders a diff preview, choosing split or unified based on terminal width
82/// and whether the diff has removals.
83///
84/// For new files (only additions, no removals), uses unified view since
85/// split view would have an empty left panel.
86pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
87    let has_removals =
88        preview.rows.iter().any(|row| row.left.as_ref().is_some_and(|entry| entry.tag == DiffTag::Removed));
89
90    if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
91        highlight_split_diff(preview, context)
92    } else {
93        highlight_diff(preview, context)
94    }
95}
96
97fn max_line_no_for_preview(preview: &DiffPreview) -> usize {
98    max_line_no_for_entries(preview.rows.iter().map(|row| {
99        (row.left.as_ref().and_then(|entry| entry.line_number), row.right.as_ref().and_then(|entry| entry.line_number))
100    }))
101}
102
103fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
104    let theme = &context.theme;
105    let dimensions = SplitLayoutDimensions::for_preview(preview, context.size.width as usize);
106
107    let mut row_frames: Vec<Frame> = Vec::new();
108    let mut visual_lines = 0usize;
109    let mut rows_consumed = 0usize;
110
111    for row in &preview.rows {
112        let left_frame = render_entry(
113            row.left.as_ref(),
114            dimensions.left_content_width,
115            &preview.lang_hint,
116            Side::Left,
117            dimensions.gutter_width,
118            GutterTint::Neutral,
119            context,
120        );
121        let right_frame = render_entry(
122            row.right.as_ref(),
123            dimensions.right_content_width,
124            &preview.lang_hint,
125            Side::Right,
126            dimensions.gutter_width,
127            GutterTint::Neutral,
128            context,
129        );
130
131        let height = left_frame.lines().len().max(right_frame.lines().len());
132
133        if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
134            break;
135        }
136
137        let sep_line = Line::new(SEPARATOR.to_string());
138        let sep_frame = Frame::new(vec![sep_line; height]);
139        row_frames.push(Frame::hstack([
140            FramePart::new(left_frame, dimensions.left_panel_width),
141            FramePart::new(sep_frame, SEPARATOR_WIDTH_U16),
142            FramePart::new(right_frame, dimensions.right_panel_width),
143        ]));
144
145        visual_lines += height;
146        rows_consumed += 1;
147    }
148
149    let mut lines = Frame::vstack(row_frames).into_lines();
150
151    if rows_consumed < preview.rows.len() {
152        let remaining = preview.rows.len() - rows_consumed;
153        let mut overflow = Line::default();
154        overflow.push_styled(format!("    ... {remaining} more lines"), theme.muted());
155        lines.push(overflow);
156    }
157
158    lines
159}
160
161fn blank_panel(width: usize) -> Line {
162    Line::new(" ".repeat(width))
163}
164
165/// [`GutterTint::Diff`] extends the diff background and foreground through the
166/// line-number gutter of changed rows, forming a clean color block. Inline
167/// previews use [`GutterTint::Neutral`] so indenting the frame cannot pick up a
168/// diff background (see [`Line::prepend`]).
169pub fn render_entry(
170    entry: Option<&SplitDiffEntry>,
171    content_width: usize,
172    lang_hint: &str,
173    side: Side,
174    gutter_width: usize,
175    gutter_tint: GutterTint,
176    context: &ViewContext,
177) -> Frame {
178    let theme = &context.theme;
179    let inner_pad = if side == Side::Left { LEFT_INNER_PAD } else { 0 };
180    let panel_width = gutter_width + content_width + inner_pad;
181
182    let Some(entry) = entry else {
183        return Frame::new(vec![blank_panel(panel_width)]);
184    };
185
186    let palette = DiffEntryStyle::for_entry(entry.tag, gutter_tint, theme);
187    let highlighted = context.highlighter().highlight(&entry.content, lang_hint, theme);
188
189    let content_line = if let Some(hl_line) = highlighted.first() {
190        let mut styled_content = Line::default();
191        for span in hl_line.spans() {
192            styled_content.push_span(Span::with_style(span.text(), palette.content_style(span.style())));
193        }
194        styled_content
195    } else {
196        Line::with_style(&entry.content, palette.fallback_content_style())
197    };
198    let content_line = match palette.bg {
199        Some(bg) => content_line.with_fill(bg),
200        None => content_line,
201    };
202
203    let content_width_u16 = usize_to_u16_saturating(content_width);
204    let extend_to = content_width + inner_pad;
205
206    let digit_field = gutter_width.saturating_sub(1);
207    let head = match entry.line_number {
208        Some(num) => Line::with_style(format!("{num:>digit_field$} "), palette.gutter_style),
209        None => Line::with_style(" ".repeat(gutter_width), palette.gutter_style),
210    };
211    let tail = Line::with_style(format!("{WRAP_MARKER:>digit_field$} "), palette.gutter_style.dim());
212
213    Frame::new(vec![content_line])
214        .fit(content_width_u16, FitOptions::wrap())
215        .map_lines(move |mut line| {
216            line.extend_bg_to_width(extend_to);
217            line
218        })
219        .prefix(&head, &tail)
220}
221
222struct DiffEntryStyle {
223    fg: Color,
224    bg: Option<Color>,
225    dim: bool,
226    gutter_style: Style,
227}
228
229impl DiffEntryStyle {
230    fn for_entry(tag: DiffTag, gutter_tint: GutterTint, theme: &crate::Theme) -> Self {
231        let (fg, bg) = match tag {
232            DiffTag::Removed => (theme.diff_removed_fg(), Some(theme.diff_removed_bg())),
233            DiffTag::Added => (theme.diff_added_fg(), Some(theme.diff_added_bg())),
234            DiffTag::Context => (theme.code_fg(), None),
235        };
236        let gutter_style = match (tag, gutter_tint) {
237            (DiffTag::Removed, GutterTint::Diff) => {
238                Style::fg(theme.diff_removed_fg()).bg_color(theme.diff_removed_bg())
239            }
240            (DiffTag::Added, GutterTint::Diff) => Style::fg(theme.diff_added_fg()).bg_color(theme.diff_added_bg()),
241            _ => Style::fg(theme.muted()),
242        };
243        Self { fg, bg, dim: tag == DiffTag::Context, gutter_style }
244    }
245
246    fn content_style(&self, mut style: Style) -> Style {
247        if let Some(bg) = self.bg {
248            style.bg = Some(bg);
249        }
250        if self.dim {
251            style.dim = true;
252        }
253        style
254    }
255
256    fn fallback_content_style(&self) -> Style {
257        self.content_style(Style::fg(self.fg))
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::rendering::line::Line;
265    use crate::{DiffLine, SplitDiffEntry, SplitDiffRow};
266
267    fn test_context_with_width(width: u16) -> ViewContext {
268        ViewContext::new((width, 24))
269    }
270
271    fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
272        DiffPreview { lines: vec![], rows, lang_hint: String::new(), start_line: None }
273    }
274
275    fn gutter_width_for_preview(preview: &DiffPreview) -> usize {
276        gutter_width_for_max_line(max_line_no_for_preview(preview))
277    }
278
279    fn removed_entry(content: &str, line_num: usize) -> SplitDiffEntry {
280        SplitDiffEntry::new(DiffTag::Removed, content, Some(line_num))
281    }
282
283    fn added_entry(content: &str, line_num: usize) -> SplitDiffEntry {
284        SplitDiffEntry::new(DiffTag::Added, content, Some(line_num))
285    }
286
287    fn context_entry(content: &str, line_num: usize) -> SplitDiffEntry {
288        SplitDiffEntry::new(DiffTag::Context, content, Some(line_num))
289    }
290
291    fn style_at_column(line: &Line, col: usize) -> Style {
292        crate::testing::style_at_column(line, col)
293    }
294
295    #[test]
296    fn wrapped_split_rows_preserve_neutral_boundary_columns() {
297        let preview = make_split_preview(vec![SplitDiffRow {
298            left: Some(removed_entry("LEFT_MARK", 1)),
299            right: Some(added_entry(&format!("RIGHT_HEAD {} RIGHT_TAIL", "y".repeat(140)), 1)),
300        }]);
301        let ctx = test_context_with_width(100);
302        let lines = highlight_split_diff(&preview, &ctx);
303
304        let first_row = lines
305            .iter()
306            .position(|line| {
307                let text = line.plain_text();
308                text.contains("LEFT_MARK") && text.contains("RIGHT_HEAD")
309            })
310            .expect("expected split row containing both left and right markers");
311
312        let right_start =
313            lines[first_row].plain_text().find("RIGHT_HEAD").expect("expected RIGHT_HEAD marker in first split row");
314
315        let wrapped_row = lines
316            .iter()
317            .enumerate()
318            .skip(first_row + 1)
319            .find_map(|(index, line)| line.plain_text().contains("RIGHT_TAIL").then_some(index))
320            .expect("expected wrapped continuation row containing RIGHT_TAIL marker");
321
322        let wrapped_start = lines[wrapped_row]
323            .plain_text()
324            .find("RIGHT_TAIL")
325            .expect("expected RIGHT_TAIL marker in wrapped continuation row");
326
327        assert!(
328            wrapped_start >= right_start,
329            "wrapped continuation should not start left of original right-pane content start (was {wrapped_start}, expected >= {right_start})"
330        );
331
332        let added_bg = ctx.theme.diff_added_bg();
333        let removed_bg = ctx.theme.diff_removed_bg();
334        let gutter_width = gutter_width_for_preview(&preview);
335        let padding_width = gutter_width + SEPARATOR_WIDTH;
336        assert!(right_start >= padding_width, "right pane content should leave room for separator and gutter");
337        for col in (right_start - padding_width)..right_start {
338            let style = style_at_column(&lines[wrapped_row], col);
339            assert_ne!(style.bg, Some(added_bg), "padding column {col} should not inherit added background");
340            assert_ne!(style.bg, Some(removed_bg), "padding column {col} should not inherit removed background");
341        }
342    }
343
344    #[test]
345    fn both_panels_rendered_with_content() {
346        let preview = make_split_preview(vec![SplitDiffRow {
347            left: Some(removed_entry("old code", 1)),
348            right: Some(added_entry("new code", 1)),
349        }]);
350        let ctx = test_context_with_width(100);
351        let lines = highlight_split_diff(&preview, &ctx);
352        assert_eq!(lines.len(), 1);
353        let text = lines[0].plain_text();
354        assert!(text.contains("old code"), "left panel missing: {text}");
355        assert!(text.contains("new code"), "right panel missing: {text}");
356    }
357
358    #[test]
359    fn long_lines_wrapped_within_terminal_width() {
360        let long = "x".repeat(200);
361        let preview = make_split_preview(vec![SplitDiffRow {
362            left: Some(removed_entry(&long, 1)),
363            right: Some(added_entry(&long, 1)),
364        }]);
365        let ctx = test_context_with_width(100);
366        let lines = highlight_split_diff(&preview, &ctx);
367        assert!(lines.len() > 1, "long line should wrap into multiple visual lines, got {}", lines.len());
368        for line in &lines {
369            let width = line.display_width();
370            assert!(width <= 100, "line width {width} should not exceed terminal width 100");
371        }
372        // Full content should be present across all wrapped lines
373        let all_text: String = lines.iter().map(Line::plain_text).collect();
374        let x_count = all_text.chars().filter(|&c| c == 'x').count();
375        // Both left and right panels contain 200 x's each
376        assert_eq!(x_count, 400, "all content should be present across wrapped lines");
377    }
378
379    #[test]
380    fn truncation_budget_applied() {
381        let rows: Vec<SplitDiffRow> = (0..30)
382            .map(|i| SplitDiffRow {
383                left: Some(removed_entry(&format!("old {i}"), i + 1)),
384                right: Some(added_entry(&format!("new {i}"), i + 1)),
385            })
386            .collect();
387        let preview = make_split_preview(rows);
388        let ctx = test_context_with_width(100);
389        let lines = highlight_split_diff(&preview, &ctx);
390        // Short lines don't wrap, so 20 visual lines + 1 overflow
391        assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
392        let last = lines.last().unwrap().plain_text();
393        assert!(last.contains("more lines"), "overflow text missing: {last}");
394    }
395
396    #[test]
397    fn empty_added_entry_pads_content_with_added_background() {
398        let ctx = test_context_with_width(100);
399        let added_bg = ctx.theme.diff_added_bg();
400        let entry = SplitDiffEntry::new(DiffTag::Added, String::new(), Some(1));
401        let content_width = 40;
402        let gutter_width = MIN_GUTTER_WIDTH;
403        let frame =
404            render_entry(Some(&entry), content_width, "rs", Side::Right, gutter_width, GutterTint::Neutral, &ctx);
405        let lines = frame.into_lines();
406        assert_eq!(lines.len(), 1);
407        let row = &lines[0];
408        assert_eq!(
409            row.display_width(),
410            gutter_width + content_width,
411            "row should fill panel width, got {}",
412            row.display_width()
413        );
414        let trailing = row.spans().last().expect("row should have spans");
415        assert_eq!(
416            trailing.style().bg,
417            Some(added_bg),
418            "trailing pad of empty added entry should carry diff_added_bg, got {:?}",
419            trailing.style().bg
420        );
421    }
422
423    #[test]
424    fn empty_preview_produces_no_output() {
425        let preview = make_split_preview(vec![]);
426        let ctx = test_context_with_width(100);
427        let lines = highlight_split_diff(&preview, &ctx);
428        assert!(lines.is_empty());
429    }
430
431    #[test]
432    fn render_diff_dispatches_to_unified_below_80() {
433        let preview = DiffPreview {
434            lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
435            rows: vec![SplitDiffRow { left: Some(removed_entry("old", 1)), right: None }],
436            lang_hint: String::new(),
437            start_line: None,
438        };
439        let ctx = test_context_with_width(79);
440        let lines = render_diff(&preview, &ctx);
441        // Unified renderer uses prefix "  - "
442        assert!(
443            lines[0].plain_text().contains("- old"),
444            "should use unified renderer below 80: {}",
445            lines[0].plain_text()
446        );
447    }
448
449    #[test]
450    fn new_file_uses_unified_view_even_at_wide_width() {
451        // A new file (only additions, no removals) should use unified view
452        // since split view would have an empty left panel
453        let preview = DiffPreview {
454            lines: vec![
455                DiffLine { tag: DiffTag::Added, content: "fn main() {".to_string() },
456                DiffLine { tag: DiffTag::Added, content: "    println!(\"Hello\");".to_string() },
457                DiffLine { tag: DiffTag::Added, content: "}".to_string() },
458            ],
459            rows: vec![
460                SplitDiffRow { left: None, right: Some(added_entry("fn main() {", 1)) },
461                SplitDiffRow { left: None, right: Some(added_entry("    println!(\"Hello\");", 2)) },
462                SplitDiffRow { left: None, right: Some(added_entry("}", 3)) },
463            ],
464            lang_hint: "rs".to_string(),
465            start_line: None,
466        };
467        let ctx = test_context_with_width(100);
468        let lines = render_diff(&preview, &ctx);
469        // Unified renderer uses prefix "  + "
470        let text = lines[0].plain_text();
471        assert!(text.contains("+ fn main()"), "should use unified renderer for new file: {text}");
472    }
473
474    #[test]
475    fn render_diff_dispatches_to_split_at_80() {
476        let preview = DiffPreview {
477            lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
478            rows: vec![SplitDiffRow { left: Some(removed_entry("old", 1)), right: None }],
479            lang_hint: String::new(),
480            start_line: None,
481        };
482        let ctx = test_context_with_width(80);
483        let lines = render_diff(&preview, &ctx);
484        let text = lines[0].plain_text();
485        // Split renderer shows line number gutter, not unified "- " prefix
486        assert!(!text.contains("- old"), "should use split renderer at 80: {text}");
487    }
488
489    #[test]
490    fn line_numbers_rendered_when_start_line_set() {
491        let preview = make_split_preview(vec![SplitDiffRow {
492            left: Some(context_entry("hello", 42)),
493            right: Some(context_entry("hello", 42)),
494        }]);
495        let ctx = test_context_with_width(100);
496        let lines = highlight_split_diff(&preview, &ctx);
497        let text = lines[0].plain_text();
498        assert!(text.contains("42"), "line number should be shown: {text}");
499    }
500
501    #[test]
502    fn wrapped_row_pads_shorter_side_to_match_height() {
503        // Left side has a long line that wraps, right side is short
504        let long = "a".repeat(200);
505        let preview = make_split_preview(vec![SplitDiffRow {
506            left: Some(removed_entry(&long, 1)),
507            right: Some(added_entry("short", 1)),
508        }]);
509        let ctx = test_context_with_width(100);
510        let lines = highlight_split_diff(&preview, &ctx);
511        assert!(lines.len() > 1, "long left side should produce multiple visual lines");
512        // All lines should have consistent width
513        let first_width = lines[0].display_width();
514        for (i, line) in lines.iter().enumerate() {
515            assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
516        }
517    }
518
519    #[test]
520    fn separator_has_no_background_on_context_row() {
521        let preview = make_split_preview(vec![SplitDiffRow {
522            left: Some(context_entry("hello", 1)),
523            right: Some(context_entry("world", 1)),
524        }]);
525        let ctx = test_context_with_width(100);
526        let lines = highlight_split_diff(&preview, &ctx);
527        assert_eq!(lines.len(), 1);
528
529        let gutter_width = gutter_width_for_preview(&preview);
530        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
531        let left_content = usable / 2;
532        let sep_start = gutter_width + left_content + LEFT_INNER_PAD;
533        let sep_end = sep_start + SEPARATOR_WIDTH;
534        for col in sep_start..sep_end {
535            let style = style_at_column(&lines[0], col);
536            assert!(
537                style.bg.is_none(),
538                "separator column {col} should have no background on context row, got {:?}",
539                style.bg
540            );
541        }
542    }
543
544    #[test]
545    fn blank_gutter_when_line_number_none() {
546        let preview = make_split_preview(vec![SplitDiffRow {
547            left: Some(SplitDiffEntry::new(DiffTag::Removed, "old", None)),
548            right: None,
549        }]);
550        let ctx = test_context_with_width(100);
551        let lines = highlight_split_diff(&preview, &ctx);
552        let text = lines[0].plain_text();
553        let blank_gutter = " ".repeat(gutter_width_for_preview(&preview));
554        assert!(text.starts_with(&blank_gutter), "should have blank gutter: {text:?}");
555    }
556
557    #[test]
558    fn left_pane_diff_bg_extends_through_inner_pad_to_separator() {
559        let preview = make_split_preview(vec![SplitDiffRow {
560            left: Some(removed_entry("old", 1)),
561            right: Some(added_entry("new", 1)),
562        }]);
563        let ctx = test_context_with_width(100);
564        let lines = highlight_split_diff(&preview, &ctx);
565        assert_eq!(lines.len(), 1);
566
567        let gutter_width = gutter_width_for_preview(&preview);
568        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
569        let left_content = usable / 2;
570        let inner_pad_col = gutter_width + left_content + LEFT_INNER_PAD - 1;
571        let style = style_at_column(&lines[0], inner_pad_col);
572        assert_eq!(
573            style.bg,
574            Some(ctx.theme.diff_removed_bg()),
575            "left inner-edge pad column {inner_pad_col} should carry diff_removed_bg, got {:?}",
576            style.bg,
577        );
578    }
579
580    #[test]
581    fn absent_entry_renders_without_background() {
582        let ctx = test_context_with_width(100);
583        let frame = render_entry(None, 40, "", Side::Left, MIN_GUTTER_WIDTH, GutterTint::Diff, &ctx);
584        let line = &frame.into_lines()[0];
585        assert_eq!(style_at_column(line, 0).bg, None, "absent side should blend with the pane background");
586    }
587
588    #[test]
589    fn tinted_gutter_carries_diff_colors() {
590        let ctx = test_context_with_width(100);
591        let frame =
592            render_entry(Some(&removed_entry("old", 7)), 40, "", Side::Left, MIN_GUTTER_WIDTH, GutterTint::Diff, &ctx);
593        let line = &frame.into_lines()[0];
594        let style = style_at_column(line, 0);
595        assert_eq!(style.bg, Some(ctx.theme.diff_removed_bg()), "tinted gutter should carry removed bg");
596        assert_eq!(style.fg, Some(ctx.theme.diff_removed_fg()), "tinted gutter number should use removed fg");
597    }
598
599    #[test]
600    fn inline_preview_gutter_stays_neutral() {
601        let preview = make_split_preview(vec![SplitDiffRow {
602            left: Some(removed_entry("old", 7)),
603            right: Some(added_entry("new", 7)),
604        }]);
605        let ctx = test_context_with_width(100);
606        let lines = highlight_split_diff(&preview, &ctx);
607        let style = style_at_column(&lines[0], 0);
608        assert_eq!(style.bg, None, "inline preview gutter must stay neutral so frame indents do not inherit it");
609    }
610
611    #[test]
612    fn wrapped_continuation_rows_show_wrap_marker() {
613        let preview = make_split_preview(vec![SplitDiffRow {
614            left: Some(removed_entry("short", 1)),
615            right: Some(added_entry(&"y".repeat(140), 1)),
616        }]);
617        let ctx = test_context_with_width(100);
618        let lines = highlight_split_diff(&preview, &ctx);
619        assert!(lines.len() > 1, "long right side should wrap");
620        assert!(
621            lines[1].plain_text().contains(WRAP_MARKER),
622            "continuation row should mark the wrap in its gutter: {:?}",
623            lines[1].plain_text()
624        );
625    }
626
627    #[test]
628    fn right_pane_line_number_sits_adjacent_to_separator() {
629        let preview = make_split_preview(vec![SplitDiffRow {
630            left: Some(removed_entry("old", 89)),
631            right: Some(added_entry("new", 89)),
632        }]);
633        let ctx = test_context_with_width(100);
634        let lines = highlight_split_diff(&preview, &ctx);
635        assert_eq!(lines.len(), 1);
636
637        let gutter_width = gutter_width_for_preview(&preview);
638        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
639        let left_content = usable / 2;
640        let right_pane_first_col = gutter_width + left_content + LEFT_INNER_PAD + SEPARATOR_WIDTH;
641        let text = lines[0].plain_text();
642        let chars: Vec<char> = text.chars().collect();
643        assert_eq!(
644            chars[right_pane_first_col], '8',
645            "right pane line# digit '8' should sit at the very first column after SEP (col {right_pane_first_col}); got line: {text:?}",
646        );
647        assert_eq!(
648            chars[right_pane_first_col + 1],
649            '9',
650            "right pane line# digit '9' should follow at col {}",
651            right_pane_first_col + 1
652        );
653    }
654}