//! The diff view: a unified, syntax-highlighted rendering of two versions of //! a file, in the style of the Pierre diff engine used by tangled.org: change //! bars instead of markers, tinted number gutters, word-level intra-line //! highlights and hunk separators. Rows reuse the code view's highlighting; //! consumers that already hold a git diff can build [`DiffRow`]s themselves //! instead of calling [`diff_rows`]. use gpui::prelude::FluentBuilder as _; use gpui::{ App, Context, ElementId, HighlightStyle, InteractiveElement as _, IntoElement, ListHorizontalSizingBehavior, ParentElement as _, Pixels, Render, ScrollStrategy, SharedString, StatefulInteractiveElement as _, Styled as _, TextStyle, UniformListScrollHandle, Window, div, point, uniform_list, }; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; use gpui_native_theme::ActiveTheme as _; use similar::{ChangeTag, TextDiff}; use std::ops::Range; use crate::highlight::{Language, Line, Role, highlight}; use crate::utils::{gutter_cell, line_row, role_color, wash, wash_gutter, wash_strong}; /// What a diff row did to the file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DiffRowKind { /// Present in both versions. Context, /// Only in the new version. Added, /// Only in the old version. Removed, /// A hunk separator introducing the rows after it. Hunk, } /// One row of a unified diff: a diff line or a hunk separator. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiffRow { pub kind: DiffRowKind, /// The highlighted line content; empty for hunk separators. pub content: Line, /// 1-based line number in the old version, when the row exists there. pub old_number: Option, /// 1-based line number in the new version, when the row exists there. pub new_number: Option, /// Byte ranges of intra-line word changes, tinted on changed rows. pub changed_ranges: Vec>, /// The `@@ -a,b +c,d @@` header, for hunk separator rows. pub header: SharedString, /// Unchanged lines collapsed into this separator row; zero elsewhere. pub hidden_lines: usize, } /// How changed rows are marked. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum DiffIndicators { /// A two-pixel bar on the row's left edge. #[default] Bars, /// Classic `+`/`-` glyphs in a marker column. Classic, /// Only the background washes. None, } /// How collapsed regions between hunks render. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum HunkSeparator { /// The collapsed unchanged line count, centered. #[default] LineInfo, /// The patch-style `@@ -a,b +c,d @@` header. Metadata, /// A minimal `···` bar. Simple, } /// Presentation options for the diff view. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DiffViewOptions { pub indicators: DiffIndicators, pub separator: HunkSeparator, /// Whether the line-number gutter renders. pub line_numbers: bool, } impl Default for DiffViewOptions { fn default() -> Self { Self { indicators: DiffIndicators::Bars, separator: HunkSeparator::LineInfo, line_numbers: true, } } } impl DiffRow { /// The row's text length, in characters. fn char_count(&self) -> usize { self.content .spans .iter() .map(|span| span.text.chars().count()) .sum() } } /// Computes unified rows between two texts, highlighting every line with `language`. pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec { let old_code = highlight(old, language); let new_code = highlight(new, language); let diff = TextDiff::from_lines(old, new); let mut rows = Vec::new(); let mut collapsed_before = 0usize; for group in diff.grouped_ops(3) { let first = group.first().expect("hunk groups are never empty"); let last = group.last().expect("hunk groups are never empty"); let old_range = first.old_range().start..last.old_range().end; let new_range = first.new_range().start..last.new_range().end; // git prints the line before a pure insertion when the count is zero. let old_start = if old_range.is_empty() { old_range.start } else { old_range.start + 1 }; let new_start = if new_range.is_empty() { new_range.start } else { new_range.start + 1 }; let collapsed = first.old_range().start - collapsed_before; if collapsed > 0 { rows.push(DiffRow { kind: DiffRowKind::Hunk, content: Line::default(), old_number: None, new_number: None, changed_ranges: Vec::new(), header: format!( "@@ -{},{} +{},{} @@", old_start, old_range.len(), new_start, new_range.len() ) .into(), hidden_lines: collapsed, }); } collapsed_before = last.old_range().end; for op in &group { let changes: Vec<_> = diff.iter_changes(op).collect(); let mut ix = 0; while ix < changes.len() { if changes[ix].tag() == ChangeTag::Equal { rows.push(line_row(&changes[ix], &new_code, Vec::new())); ix += 1; continue; } let deletions = changes[ix..] .iter() .take_while(|change| change.tag() == ChangeTag::Delete) .count(); let insertions = changes[ix + deletions..] .iter() .take_while(|change| change.tag() == ChangeTag::Insert) .count(); let mut deletion_ranges = vec![Vec::new(); deletions]; let mut insertion_ranges = vec![Vec::new(); insertions]; for pair in 0..deletions.min(insertions) { let old_text = changes[ix + pair].value().trim_end_matches('\n'); let new_text = changes[ix + deletions + pair] .value() .trim_end_matches('\n'); let words = TextDiff::from_words(old_text, new_text); let (mut old_offset, mut new_offset) = (0usize, 0usize); for word in words.iter_all_changes() { let len = word.value().len(); match word.tag() { ChangeTag::Delete => { deletion_ranges[pair].push(old_offset..old_offset + len); old_offset += len; } ChangeTag::Insert => { insertion_ranges[pair].push(new_offset..new_offset + len); new_offset += len; } ChangeTag::Equal => { old_offset += len; new_offset += len; } } } } for pair in 0..deletions { rows.push(line_row( &changes[ix + pair], &old_code, deletion_ranges[pair].clone(), )); } for pair in 0..insertions { rows.push(line_row( &changes[ix + deletions + pair], &new_code, insertion_ranges[pair].clone(), )); } ix += deletions + insertions; } } } rows } /// Retained state for a diff view. pub struct DiffViewState { id: ElementId, rows: Vec, options: DiffViewOptions, /// Drives both scroll axes of the virtualized row list. handle: UniformListScrollHandle, a11y_label: Option, } impl DiffViewState { /// Builds the state, diffing `old` against `new`. pub fn new(id: I, old: O, new: N, language: Language) -> Self where I: Into, O: AsRef, N: AsRef, { Self { id: id.into(), rows: diff_rows(old.as_ref(), new.as_ref(), language), options: DiffViewOptions::default(), handle: UniformListScrollHandle::new(), a11y_label: None, } } /// Sets how changed rows are marked. pub fn indicators(mut self, indicators: DiffIndicators) -> Self { self.options.indicators = indicators; self } /// Sets how collapsed regions between hunks render. pub fn hunk_separator(mut self, separator: HunkSeparator) -> Self { self.options.separator = separator; self } /// Shows or hides the line-number gutter. pub fn line_numbers(mut self, show: bool) -> Self { self.options.line_numbers = show; self } /// Replaces the versions and re-diffs them. pub fn set_versions(&mut self, old: O, new: N, language: Language, cx: &mut Context) where O: AsRef, N: AsRef, { self.rows = diff_rows(old.as_ref(), new.as_ref(), language); self.handle.scroll_to_item(0, ScrollStrategy::Top); // The replaced versions rarely share the old width; start at the left. let offset = ScrollbarHandle::offset(&self.handle); ScrollbarHandle::set_offset(&self.handle, point(Pixels::ZERO, offset.y)); cx.notify(); } /// The diff rows. pub fn rows(&self) -> &[DiffRow] { &self.rows } /// Scrolls the first added or removed row to the top of the view. pub fn scroll_to_first_change(&self) { let Some(row) = self .rows .iter() .position(|row| matches!(row.kind, DiffRowKind::Added | DiffRowKind::Removed)) else { return; }; self.handle.scroll_to_item(row, ScrollStrategy::Top); } /// Overrides the accessibility label of the view. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl Render for DiffViewState { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let text_size = theme.sizes.small.text_size; let id = self.id.clone(); let state = cx.entity(); let row_count = self.rows.len(); let options = self.options; let widest_row = self .rows .iter() .enumerate() .filter(|(_, row)| row.kind != DiffRowKind::Hunk) .max_by_key(|(_, row)| row.char_count()) .map(|(ix, _)| ix); let mut rows = uniform_list((id.clone(), "rows"), row_count, { move |range, _window, cx| { let diff = state.read(cx); range .map(|row_ix| render_row(&diff.rows[row_ix], row_ix, options, cx)) .collect() } }) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) .with_width_from_item(widest_row) .track_scroll(&self.handle) .h_full(); // Lock each gesture to its initial axis rows.interactivity().base_style.restrict_scroll_to_axis = Some(true); div() .id((id.clone(), "view")) .relative() .size_full() .text_size(text_size) .when_some(self.a11y_label.clone(), |view, label| { view.aria_label(label) }) .child(rows) .child( Scrollbar::vertical(self.handle.clone()) .key("v-scrollbar") .visibility(ScrollbarVisibility::Platform), ) .child( Scrollbar::horizontal(self.handle.clone()) .key("h-scrollbar") .visibility(ScrollbarVisibility::Platform), ) } } fn render_row( row: &DiffRow, row_ix: usize, options: DiffViewOptions, cx: &App, ) -> gpui::AnyElement { let theme = cx.theme(); let mono_font = theme.mono_font; let colors = theme.colors; let ramps = theme.ramps; let text_size = theme.sizes.small.text_size; let line_height = text_size * 1.5; let char_width = text_size * 0.62; let frame = div() .debug_selector(move || format!("diff-row-{row_ix}")) .id(("diff-row", row_ix)) .w_full() .h(line_height) .font_family(mono_font) .text_size(text_size) .overflow_hidden(); if row.kind == DiffRowKind::Hunk { let content = match options.separator { HunkSeparator::LineInfo => { SharedString::from(format!("{} unchanged lines", row.hidden_lines)) } HunkSeparator::Metadata => row.header.clone(), HunkSeparator::Simple => SharedString::from("···"), }; return frame .flex() .items_center() .text_color(colors.muted_foreground) .when( options.indicators == DiffIndicators::Classic, |row_element| row_element.child(div().w_10().flex_none()), ) .when(options.line_numbers, |row_element| { row_element.child(div().w_10().flex_none()) }) .when( options.indicators == DiffIndicators::Bars && !options.line_numbers, |row_element| { row_element.child( div() .flex_none() .border_l_4() .border_color(gpui::transparent_black()), ) }, ) .child(div().flex_none().ml_2().child(content)) .into_any_element(); } let change_color = match row.kind { DiffRowKind::Added => ramps.success.base, DiffRowKind::Removed => ramps.destructive.base, DiffRowKind::Context | DiffRowKind::Hunk => gpui::transparent_black(), }; let wash = match row.kind { DiffRowKind::Added => Some(wash(ramps.success.base)), DiffRowKind::Removed => Some(wash(ramps.destructive.base)), DiffRowKind::Context | DiffRowKind::Hunk => None, }; let mut text = String::new(); let mut spans: Vec<(Range, Role)> = Vec::new(); for span in &row.content.spans { let start = text.len(); text.push_str(&span.text); if span.role != Role::Text { spans.push((start..text.len(), span.role)); } } let mut boundaries: Vec = spans .iter() .flat_map(|(range, _)| [range.start, range.end]) .chain( row.changed_ranges .iter() .flat_map(|range| [range.start, range.end]), ) .collect(); boundaries.sort_unstable(); boundaries.dedup(); let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); let mut span_ix = 0; for window in boundaries.windows(2) { let (start, end) = (window[0], window[1]); while span_ix < spans.len() && spans[span_ix].0.end <= start { span_ix += 1; } let mut segment = HighlightStyle::default(); if let Some((range, role)) = spans.get(span_ix) && range.contains(&start) && *role != Role::Text { segment = segment.highlight(HighlightStyle { color: Some(role_color(*role, colors, ramps)), ..Default::default() }); } if row .changed_ranges .iter() .any(|range| range.contains(&start)) { segment = segment.highlight(HighlightStyle { background_color: Some(wash_strong(change_color)), ..Default::default() }); } highlights.push((start..end, segment)); } let style = TextStyle { color: colors.foreground, font_family: SharedString::from(mono_font), font_size: text_size.into(), line_height: line_height.into(), ..Default::default() }; frame .flex() .items_center() .when_some(wash, |row_element, tint| row_element.bg(tint)) .when( options.indicators == DiffIndicators::Classic, |row_element| { row_element.child(gutter_cell( cx, if row.kind == DiffRowKind::Context { gpui::transparent_black() } else { change_color }, match row.kind { DiffRowKind::Added => "+", DiffRowKind::Removed => "-", _ => "", }, )) }, ) .when(options.line_numbers, |row_element| { let mut number = gutter_cell( cx, colors.muted_foreground, row.new_number .or(row.old_number) .map(|number| number.to_string()) .unwrap_or_default(), ) .w_10() .whitespace_nowrap() .bg(match row.kind { DiffRowKind::Context => wash_gutter(&colors), _ => wash_strong(change_color), }); if options.indicators == DiffIndicators::Bars { number = number.border_l_4().border_color(change_color); } row_element.child(number) }) .when( options.indicators == DiffIndicators::Bars && !options.line_numbers, |row_element| { row_element.child( div() .h_full() .flex_none() .border_l_4() .border_color(change_color), ) }, ) .child( div() .flex_none() .ml_2() .w(char_width * row.char_count() as f32) .whitespace_nowrap() .child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)), ) .into_any_element() }