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
9pub 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 Content,
23 HunkHeader,
25 Meta,
27}
28
29#[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
49pub struct DiffLine {
52 pub line: Line<'static>,
53 pub fill: Style,
54}
55
56pub 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
69pub 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
93pub 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
112const MAX_PREVIEW_ROWS: usize = 20;
114
115const 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 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 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
234fn 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
250fn 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
274fn 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
298enum SplitGroup<'a> {
301 Single { line: &'a PatchLine, index: usize },
302 Changed { removed: Vec<SplitSide<'a>>, added: Vec<SplitSide<'a>> },
303}
304
305struct SplitSide<'a> {
308 line: &'a PatchLine,
309 index: usize,
310}
311
312fn 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