Skip to main content

wisp/view/
diff.rs

1use crate::git_review::{FileDiff, PatchLine, PatchLineKind};
2use crate::view::syntax::SyntaxHighlighter;
3use crate::theme::Theme;
4use crate::view::wrap::fit_line;
5use ratatui::style::{Color, Style};
6use ratatui::text::{Line, Span};
7use similar::{DiffOp, TextDiff};
8
9/// One canonical rendered diff row: the styled line plus the patch position it
10/// was laid out from, so the full-screen review can anchor comments to it and
11/// the inline preview can select its content subset.
12pub struct DiffRow {
13    pub line: Line<'static>,
14    pub hunk: usize,
15    pub index: usize,
16    pub kind: DiffRowKind,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DiffRowKind {
21    /// A source line: context, added, removed, or a paired split row.
22    Content,
23    /// A `@@` hunk header.
24    HunkHeader,
25    /// A metadata line that is not part of the file.
26    Meta,
27}
28
29/// How a diff row is tinted. Both the inline previews in the transcript and the
30/// full git-diff screen render through this, so a line looks the same wherever
31/// it is shown.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DiffTone {
34    Context,
35    Added,
36    Removed,
37}
38
39impl DiffTone {
40    pub fn colors(self, theme: &Theme) -> (Color, Color) {
41        match self {
42            Self::Context => (theme.text_secondary, theme.background),
43            Self::Added => (theme.diff_added_fg, theme.diff_added_bg),
44            Self::Removed => (theme.diff_removed_fg, theme.diff_removed_bg),
45        }
46    }
47}
48
49/// A diff line rendered but not yet fitted to the viewport: callers wrap it or
50/// truncate it, using `fill` for the padding and ellipsis they add.
51pub struct DiffLine {
52    pub line: Line<'static>,
53    pub fill: Style,
54}
55
56/// Lays out `file` for `width` columns: a split view when the width allows it
57/// and the diff has removals to put on the left, unified otherwise. Every diff
58/// rendering in the application draws these rows or a subset of them.
59pub fn diff_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
60    let has_removals =
61        file.hunks.iter().flat_map(|hunk| hunk.lines.iter()).any(|line| line.kind == PatchLineKind::Removed);
62    if width >= SPLIT_VIEW_MIN_WIDTH && has_removals {
63        split_rows(file, width, theme, highlighter)
64    } else {
65        unified_rows(file, width, theme, highlighter)
66    }
67}
68
69/// The bounded inline preview: the first `MAX_PREVIEW_ROWS` content rows of
70/// the canonical layout, with the hidden remainder collapsed into a count.
71pub fn render_diff(
72    file: &FileDiff,
73    width: u16,
74    theme: &Theme,
75    highlighter: &mut SyntaxHighlighter,
76) -> Vec<Line<'static>> {
77    let content: Vec<Line<'static>> = diff_rows(file, width, theme, highlighter)
78        .into_iter()
79        .filter(|row| row.kind == DiffRowKind::Content)
80        .map(|row| row.line)
81        .collect();
82    let total = content.len();
83    let mut lines: Vec<Line<'static>> = content.into_iter().take(MAX_PREVIEW_ROWS).collect();
84    lines.extend(
85        total
86            .checked_sub(MAX_PREVIEW_ROWS)
87            .filter(|&hidden| hidden > 0)
88            .map(|hidden| Line::styled(format!("    … {hidden} more rows"), Style::new().fg(theme.muted))),
89    );
90    lines
91}
92
93/// Renders `gutter` (line numbers and any change marker) followed by the
94/// syntax-highlighted source, tinted for `tone`.
95pub fn diff_line(
96    gutter: &str,
97    text: &str,
98    language: &str,
99    tone: DiffTone,
100    theme: &Theme,
101    highlighter: &mut SyntaxHighlighter,
102) -> DiffLine {
103    let (foreground, background) = tone.colors(theme);
104    let fill = Style::new().fg(foreground).bg(background);
105    let mut spans = vec![Span::styled(gutter.to_string(), fill)];
106    spans.extend(highlighted_spans(text, language, background, theme, highlighter));
107    DiffLine { line: Line::from(spans).style(Style::new().bg(background)), fill }
108}
109
110const SPLIT_VIEW_MIN_WIDTH: u16 = 96;
111
112/// Most rows an inline preview shows before collapsing the rest into a count.
113const MAX_PREVIEW_ROWS: usize = 20;
114
115/// The column a split view puts between its two halves.
116const SPLIT_SEPARATOR: &str = "│";
117
118fn unified_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
119    file.hunks
120        .iter()
121        .enumerate()
122        .flat_map(|(hunk, entry)| entry.lines.iter().enumerate().map(move |(index, line)| (hunk, index, line)))
123        .map(|(hunk, index, line)| match line.kind {
124            PatchLineKind::HunkHeader => {
125                DiffRow { line: full_width(&line.text, width, theme.info), hunk, index, kind: DiffRowKind::HunkHeader }
126            }
127            PatchLineKind::Meta => {
128                DiffRow { line: full_width(&line.text, width, theme.muted), hunk, index, kind: DiffRowKind::Meta }
129            }
130            kind => {
131                let marker = match kind {
132                    PatchLineKind::Added => '+',
133                    PatchLineKind::Removed => '-',
134                    _ => ' ',
135                };
136                let gutter = format!("{} {} {marker} ", line_number(line.old_line_no), line_number(line.new_line_no));
137                let rendered = diff_line(&gutter, &line.text, file.language(), tone_of(kind), theme, highlighter);
138                DiffRow {
139                    line: fit_line(rendered.line, usize::from(width), rendered.fill),
140                    hunk,
141                    index,
142                    kind: DiffRowKind::Content,
143                }
144            }
145        })
146        .collect()
147}
148
149fn split_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
150    let left_width = width.saturating_sub(1) / 2;
151    let right_width = width.saturating_sub(left_width + 1);
152    let mut rows = Vec::new();
153    for (hunk, entry) in file.hunks.iter().enumerate() {
154        for group in split_groups(&entry.lines) {
155            match group {
156                SplitGroup::Changed { removed, added } => {
157                    for (left, right) in pair_changed_block(&removed, &added) {
158                        // Anchor comments on the added side when present, falling back
159                        // to the removed side, so each line keeps its own comment slot.
160                        let index = right.or(left).map_or(0, |side| side.index);
161                        let line = split_row(
162                            left.map(|side| side.line),
163                            right.map(|side| side.line),
164                            file.language(),
165                            left_width,
166                            right_width,
167                            theme,
168                            highlighter,
169                        );
170                        rows.push(DiffRow { line, hunk, index, kind: DiffRowKind::Content });
171                    }
172                }
173                SplitGroup::Single { line, index } => rows.push(match line.kind {
174                    PatchLineKind::HunkHeader => DiffRow {
175                        line: full_width(&line.text, width, theme.info),
176                        hunk,
177                        index,
178                        kind: DiffRowKind::HunkHeader,
179                    },
180                    // A leftover removed line has no right-hand side to pair with, and
181                    // a meta line is not part of the file, so neither is content.
182                    PatchLineKind::Meta | PatchLineKind::Removed => DiffRow {
183                        line: full_width(&line.text, width, theme.muted),
184                        hunk,
185                        index,
186                        kind: DiffRowKind::Meta,
187                    },
188                    PatchLineKind::Added | PatchLineKind::Context => {
189                        let old = (line.kind == PatchLineKind::Context).then_some(line);
190                        let rendered =
191                            split_row(old, Some(line), file.language(), left_width, right_width, theme, highlighter);
192                        DiffRow { line: rendered, hunk, index, kind: DiffRowKind::Content }
193                    }
194                }),
195            }
196        }
197    }
198    rows
199}
200
201fn split_row(
202    old: Option<&PatchLine>,
203    new: Option<&PatchLine>,
204    language: &str,
205    left_width: u16,
206    right_width: u16,
207    theme: &Theme,
208    highlighter: &mut SyntaxHighlighter,
209) -> Line<'static> {
210    let left = split_side(
211        old.and_then(|line| line.old_line_no),
212        old.map(|line| line.text.as_str()),
213        language,
214        old.map_or(DiffTone::Context, |line| tone_of(line.kind)),
215        left_width,
216        theme,
217        highlighter,
218    );
219    let right = split_side(
220        new.and_then(|line| line.new_line_no),
221        new.map(|line| line.text.as_str()),
222        language,
223        new.map_or(DiffTone::Context, |line| tone_of(line.kind)),
224        right_width,
225        theme,
226        highlighter,
227    );
228    let mut spans = left.spans;
229    spans.push(Span::styled(SPLIT_SEPARATOR, Style::new().fg(theme.muted).bg(theme.background)));
230    spans.extend(right.spans);
231    Line::from(spans)
232}
233
234/// One half of a split diff, occupying exactly `width` columns. An absent line
235/// renders as an empty, still-tinted gap so the two sides stay aligned.
236fn split_side(
237    number: Option<usize>,
238    text: Option<&str>,
239    language: &str,
240    tone: DiffTone,
241    width: u16,
242    theme: &Theme,
243    highlighter: &mut SyntaxHighlighter,
244) -> Line<'static> {
245    let gutter = format!("{} ", line_number(number));
246    let rendered = diff_line(&gutter, text.unwrap_or_default(), language, tone, theme, highlighter);
247    fit_line(rendered.line, usize::from(width), rendered.fill)
248}
249
250/// Syntax-highlighted spans for one source line, re-tinted to sit on
251/// `background` so the highlighting does not punch holes in a diff row.
252fn highlighted_spans(
253    source: &str,
254    language: &str,
255    background: Color,
256    theme: &Theme,
257    highlighter: &mut SyntaxHighlighter,
258) -> Vec<Span<'static>> {
259    let lines = highlighter.highlight(source, language, theme);
260    let Some(first) = lines.first() else {
261        return vec![Span::styled(source.to_string(), Style::new().bg(background))];
262    };
263    first
264        .spans
265        .iter()
266        .map(|span| {
267            let mut span = span.clone();
268            span.style = span.style.patch(Style::new().bg(background));
269            span
270        })
271        .collect()
272}
273
274/// Splits a hunk into the units a split view draws: a removed run followed by
275/// an added run is one changed block, and everything else stands alone.
276fn split_groups(lines: &[PatchLine]) -> Vec<SplitGroup<'_>> {
277    let mut groups = Vec::new();
278    let mut index = 0;
279    while index < lines.len() {
280        if lines[index].kind != PatchLineKind::Removed {
281            groups.push(SplitGroup::Single { line: &lines[index], index });
282            index += 1;
283            continue;
284        }
285        let sides = |range: std::ops::Range<usize>| {
286            range.map(|index| SplitSide { line: &lines[index], index }).collect::<Vec<_>>()
287        };
288        let removed_start = index;
289        index += lines[index..].iter().take_while(|line| line.kind == PatchLineKind::Removed).count();
290        let added_start = index;
291        index += lines[index..].iter().take_while(|line| line.kind == PatchLineKind::Added).count();
292        groups
293            .push(SplitGroup::Changed { removed: sides(removed_start..added_start), added: sides(added_start..index) });
294    }
295    groups
296}
297
298/// One unit of a split view: a standalone line, or a removed/added run drawn
299/// side by side.
300enum SplitGroup<'a> {
301    Single { line: &'a PatchLine, index: usize },
302    Changed { removed: Vec<SplitSide<'a>>, added: Vec<SplitSide<'a>> },
303}
304
305/// A patch line paired with its index into the owning hunk's `lines` vector,
306/// used to preserve per-line comment anchors in the split view.
307struct SplitSide<'a> {
308    line: &'a PatchLine,
309    index: usize,
310}
311
312/// Aligns a contiguous removed/added block using a real line-level diff so that
313/// unchanged lines within the block stay paired, instead of naïvely pairing
314/// `removed[i]` with `added[i]`.
315fn pair_changed_block<'a>(
316    removed: &'a [SplitSide<'a>],
317    added: &'a [SplitSide<'a>],
318) -> Vec<(Option<&'a SplitSide<'a>>, Option<&'a SplitSide<'a>>)> {
319    let old: Vec<&str> = removed.iter().map(|side| side.line.text.as_str()).collect();
320    let new: Vec<&str> = added.iter().map(|side| side.line.text.as_str()).collect();
321    let diff = TextDiff::from_slices(&old, &new);
322    let mut pairs = Vec::new();
323    for op in diff.ops() {
324        match *op {
325            DiffOp::Equal { old_index, new_index, len } => {
326                for offset in 0..len {
327                    pairs.push((Some(&removed[old_index + offset]), Some(&added[new_index + offset])));
328                }
329            }
330            DiffOp::Delete { old_index, old_len, .. } => {
331                pairs.extend(removed[old_index..old_index + old_len].iter().map(|side| (Some(side), None)));
332            }
333            DiffOp::Insert { new_index, new_len, .. } => {
334                pairs.extend(added[new_index..new_index + new_len].iter().map(|side| (None, Some(side))));
335            }
336            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
337                let pair_len = old_len.min(new_len);
338                for offset in 0..pair_len {
339                    pairs.push((Some(&removed[old_index + offset]), Some(&added[new_index + offset])));
340                }
341                pairs.extend(removed[old_index + pair_len..old_index + old_len].iter().map(|side| (Some(side), None)));
342                pairs.extend(added[new_index + pair_len..new_index + new_len].iter().map(|side| (None, Some(side))));
343            }
344        }
345    }
346    pairs
347}
348
349fn tone_of(kind: PatchLineKind) -> DiffTone {
350    match kind {
351        PatchLineKind::Added => DiffTone::Added,
352        PatchLineKind::Removed => DiffTone::Removed,
353        _ => DiffTone::Context,
354    }
355}
356
357fn line_number(number: Option<usize>) -> String {
358    number.map_or_else(|| "    ".to_string(), |number| format!("{number:>4}"))
359}
360
361fn full_width(text: &str, width: u16, color: Color) -> Line<'static> {
362    let style = Style::new().fg(color);
363    fit_line(Line::styled(text.to_string(), style), usize::from(width), style)
364}
365