use crate::git_review::{FileDiff, PatchLine, PatchLineKind};
use crate::view::syntax::SyntaxHighlighter;
use crate::theme::Theme;
use crate::view::wrap::fit_line;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use similar::{DiffOp, TextDiff};
pub struct DiffRow {
pub line: Line<'static>,
pub hunk: usize,
pub index: usize,
pub kind: DiffRowKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffRowKind {
Content,
HunkHeader,
Meta,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffTone {
Context,
Added,
Removed,
}
impl DiffTone {
pub fn colors(self, theme: &Theme) -> (Color, Color) {
match self {
Self::Context => (theme.text_secondary, theme.background),
Self::Added => (theme.diff_added_fg, theme.diff_added_bg),
Self::Removed => (theme.diff_removed_fg, theme.diff_removed_bg),
}
}
}
pub struct DiffLine {
pub line: Line<'static>,
pub fill: Style,
}
pub fn diff_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
let has_removals =
file.hunks.iter().flat_map(|hunk| hunk.lines.iter()).any(|line| line.kind == PatchLineKind::Removed);
if width >= SPLIT_VIEW_MIN_WIDTH && has_removals {
split_rows(file, width, theme, highlighter)
} else {
unified_rows(file, width, theme, highlighter)
}
}
pub fn render_diff(
file: &FileDiff,
width: u16,
theme: &Theme,
highlighter: &mut SyntaxHighlighter,
) -> Vec<Line<'static>> {
let content: Vec<Line<'static>> = diff_rows(file, width, theme, highlighter)
.into_iter()
.filter(|row| row.kind == DiffRowKind::Content)
.map(|row| row.line)
.collect();
let total = content.len();
let mut lines: Vec<Line<'static>> = content.into_iter().take(MAX_PREVIEW_ROWS).collect();
lines.extend(
total
.checked_sub(MAX_PREVIEW_ROWS)
.filter(|&hidden| hidden > 0)
.map(|hidden| Line::styled(format!(" … {hidden} more rows"), Style::new().fg(theme.muted))),
);
lines
}
pub fn diff_line(
gutter: &str,
text: &str,
language: &str,
tone: DiffTone,
theme: &Theme,
highlighter: &mut SyntaxHighlighter,
) -> DiffLine {
let (foreground, background) = tone.colors(theme);
let fill = Style::new().fg(foreground).bg(background);
let mut spans = vec![Span::styled(gutter.to_string(), fill)];
spans.extend(highlighted_spans(text, language, background, theme, highlighter));
DiffLine { line: Line::from(spans).style(Style::new().bg(background)), fill }
}
const SPLIT_VIEW_MIN_WIDTH: u16 = 96;
const MAX_PREVIEW_ROWS: usize = 20;
const SPLIT_SEPARATOR: &str = "│";
fn unified_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
file.hunks
.iter()
.enumerate()
.flat_map(|(hunk, entry)| entry.lines.iter().enumerate().map(move |(index, line)| (hunk, index, line)))
.map(|(hunk, index, line)| match line.kind {
PatchLineKind::HunkHeader => {
DiffRow { line: full_width(&line.text, width, theme.info), hunk, index, kind: DiffRowKind::HunkHeader }
}
PatchLineKind::Meta => {
DiffRow { line: full_width(&line.text, width, theme.muted), hunk, index, kind: DiffRowKind::Meta }
}
kind => {
let marker = match kind {
PatchLineKind::Added => '+',
PatchLineKind::Removed => '-',
_ => ' ',
};
let gutter = format!("{} {} {marker} ", line_number(line.old_line_no), line_number(line.new_line_no));
let rendered = diff_line(&gutter, &line.text, file.language(), tone_of(kind), theme, highlighter);
DiffRow {
line: fit_line(rendered.line, usize::from(width), rendered.fill),
hunk,
index,
kind: DiffRowKind::Content,
}
}
})
.collect()
}
fn split_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<DiffRow> {
let left_width = width.saturating_sub(1) / 2;
let right_width = width.saturating_sub(left_width + 1);
let mut rows = Vec::new();
for (hunk, entry) in file.hunks.iter().enumerate() {
for group in split_groups(&entry.lines) {
match group {
SplitGroup::Changed { removed, added } => {
for (left, right) in pair_changed_block(&removed, &added) {
let index = right.or(left).map_or(0, |side| side.index);
let line = split_row(
left.map(|side| side.line),
right.map(|side| side.line),
file.language(),
left_width,
right_width,
theme,
highlighter,
);
rows.push(DiffRow { line, hunk, index, kind: DiffRowKind::Content });
}
}
SplitGroup::Single { line, index } => rows.push(match line.kind {
PatchLineKind::HunkHeader => DiffRow {
line: full_width(&line.text, width, theme.info),
hunk,
index,
kind: DiffRowKind::HunkHeader,
},
PatchLineKind::Meta | PatchLineKind::Removed => DiffRow {
line: full_width(&line.text, width, theme.muted),
hunk,
index,
kind: DiffRowKind::Meta,
},
PatchLineKind::Added | PatchLineKind::Context => {
let old = (line.kind == PatchLineKind::Context).then_some(line);
let rendered =
split_row(old, Some(line), file.language(), left_width, right_width, theme, highlighter);
DiffRow { line: rendered, hunk, index, kind: DiffRowKind::Content }
}
}),
}
}
}
rows
}
fn split_row(
old: Option<&PatchLine>,
new: Option<&PatchLine>,
language: &str,
left_width: u16,
right_width: u16,
theme: &Theme,
highlighter: &mut SyntaxHighlighter,
) -> Line<'static> {
let left = split_side(
old.and_then(|line| line.old_line_no),
old.map(|line| line.text.as_str()),
language,
old.map_or(DiffTone::Context, |line| tone_of(line.kind)),
left_width,
theme,
highlighter,
);
let right = split_side(
new.and_then(|line| line.new_line_no),
new.map(|line| line.text.as_str()),
language,
new.map_or(DiffTone::Context, |line| tone_of(line.kind)),
right_width,
theme,
highlighter,
);
let mut spans = left.spans;
spans.push(Span::styled(SPLIT_SEPARATOR, Style::new().fg(theme.muted).bg(theme.background)));
spans.extend(right.spans);
Line::from(spans)
}
fn split_side(
number: Option<usize>,
text: Option<&str>,
language: &str,
tone: DiffTone,
width: u16,
theme: &Theme,
highlighter: &mut SyntaxHighlighter,
) -> Line<'static> {
let gutter = format!("{} ", line_number(number));
let rendered = diff_line(&gutter, text.unwrap_or_default(), language, tone, theme, highlighter);
fit_line(rendered.line, usize::from(width), rendered.fill)
}
fn highlighted_spans(
source: &str,
language: &str,
background: Color,
theme: &Theme,
highlighter: &mut SyntaxHighlighter,
) -> Vec<Span<'static>> {
let lines = highlighter.highlight(source, language, theme);
let Some(first) = lines.first() else {
return vec![Span::styled(source.to_string(), Style::new().bg(background))];
};
first
.spans
.iter()
.map(|span| {
let mut span = span.clone();
span.style = span.style.patch(Style::new().bg(background));
span
})
.collect()
}
fn split_groups(lines: &[PatchLine]) -> Vec<SplitGroup<'_>> {
let mut groups = Vec::new();
let mut index = 0;
while index < lines.len() {
if lines[index].kind != PatchLineKind::Removed {
groups.push(SplitGroup::Single { line: &lines[index], index });
index += 1;
continue;
}
let sides = |range: std::ops::Range<usize>| {
range.map(|index| SplitSide { line: &lines[index], index }).collect::<Vec<_>>()
};
let removed_start = index;
index += lines[index..].iter().take_while(|line| line.kind == PatchLineKind::Removed).count();
let added_start = index;
index += lines[index..].iter().take_while(|line| line.kind == PatchLineKind::Added).count();
groups
.push(SplitGroup::Changed { removed: sides(removed_start..added_start), added: sides(added_start..index) });
}
groups
}
enum SplitGroup<'a> {
Single { line: &'a PatchLine, index: usize },
Changed { removed: Vec<SplitSide<'a>>, added: Vec<SplitSide<'a>> },
}
struct SplitSide<'a> {
line: &'a PatchLine,
index: usize,
}
fn pair_changed_block<'a>(
removed: &'a [SplitSide<'a>],
added: &'a [SplitSide<'a>],
) -> Vec<(Option<&'a SplitSide<'a>>, Option<&'a SplitSide<'a>>)> {
let old: Vec<&str> = removed.iter().map(|side| side.line.text.as_str()).collect();
let new: Vec<&str> = added.iter().map(|side| side.line.text.as_str()).collect();
let diff = TextDiff::from_slices(&old, &new);
let mut pairs = Vec::new();
for op in diff.ops() {
match *op {
DiffOp::Equal { old_index, new_index, len } => {
for offset in 0..len {
pairs.push((Some(&removed[old_index + offset]), Some(&added[new_index + offset])));
}
}
DiffOp::Delete { old_index, old_len, .. } => {
pairs.extend(removed[old_index..old_index + old_len].iter().map(|side| (Some(side), None)));
}
DiffOp::Insert { new_index, new_len, .. } => {
pairs.extend(added[new_index..new_index + new_len].iter().map(|side| (None, Some(side))));
}
DiffOp::Replace { old_index, old_len, new_index, new_len } => {
let pair_len = old_len.min(new_len);
for offset in 0..pair_len {
pairs.push((Some(&removed[old_index + offset]), Some(&added[new_index + offset])));
}
pairs.extend(removed[old_index + pair_len..old_index + old_len].iter().map(|side| (Some(side), None)));
pairs.extend(added[new_index + pair_len..new_index + new_len].iter().map(|side| (None, Some(side))));
}
}
}
pairs
}
fn tone_of(kind: PatchLineKind) -> DiffTone {
match kind {
PatchLineKind::Added => DiffTone::Added,
PatchLineKind::Removed => DiffTone::Removed,
_ => DiffTone::Context,
}
}
fn line_number(number: Option<usize>) -> String {
number.map_or_else(|| " ".to_string(), |number| format!("{number:>4}"))
}
fn full_width(text: &str, width: u16, color: Color) -> Line<'static> {
let style = Style::new().fg(color);
fit_line(Line::styled(text.to_string(), style), usize::from(width), style)
}