590 lines
19 KiB
Rust
590 lines
19 KiB
Rust
//! 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 _, MONO_ADVANCE_RATIO};
|
|
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<usize>,
|
|
/// 1-based line number in the new version, when the row exists there.
|
|
pub new_number: Option<usize>,
|
|
/// Byte ranges of intra-line word changes, tinted on changed rows.
|
|
pub changed_ranges: Vec<Range<usize>>,
|
|
/// 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<DiffRow> {
|
|
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<DiffRow>,
|
|
/// The row whose width drives horizontal scroll extent, `rows`-derived.
|
|
widest_row: Option<usize>,
|
|
options: DiffViewOptions,
|
|
/// Drives both scroll axes of the virtualized row list.
|
|
handle: UniformListScrollHandle,
|
|
a11y_label: Option<SharedString>,
|
|
}
|
|
|
|
impl DiffViewState {
|
|
/// Builds the state, diffing `old` against `new`.
|
|
pub fn new<I, O, N>(id: I, old: O, new: N, language: Language) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
O: AsRef<str>,
|
|
N: AsRef<str>,
|
|
{
|
|
let mut state = Self {
|
|
id: id.into(),
|
|
rows: Vec::new(),
|
|
widest_row: None,
|
|
options: DiffViewOptions::default(),
|
|
handle: UniformListScrollHandle::new(),
|
|
a11y_label: None,
|
|
};
|
|
state.set_rows(old, new, language);
|
|
state
|
|
}
|
|
|
|
/// Replaces the rows, recomputing `widest_row` with them.
|
|
fn set_rows<O, N>(&mut self, old: O, new: N, language: Language)
|
|
where
|
|
O: AsRef<str>,
|
|
N: AsRef<str>,
|
|
{
|
|
self.rows = diff_rows(old.as_ref(), new.as_ref(), language);
|
|
self.widest_row = self
|
|
.rows
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, row)| row.kind != DiffRowKind::Hunk)
|
|
.max_by_key(|(_, row)| row.char_count())
|
|
.map(|(ix, _)| ix);
|
|
}
|
|
|
|
/// 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<O, N>(&mut self, old: O, new: N, language: Language, cx: &mut Context<Self>)
|
|
where
|
|
O: AsRef<str>,
|
|
N: AsRef<str>,
|
|
{
|
|
self.set_rows(old, new, 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<L>(mut self, label: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
self.a11y_label = Some(label.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Render for DiffViewState {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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.widest_row;
|
|
|
|
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.id.clone(), "v-scrollbar"), self.handle.clone())
|
|
.visibility(ScrollbarVisibility::Platform),
|
|
)
|
|
.child(
|
|
Scrollbar::horizontal((self.id.clone(), "h-scrollbar"), self.handle.clone())
|
|
.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 * MONO_ADVANCE_RATIO;
|
|
|
|
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<usize>, 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<usize> = 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<usize>, 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()
|
|
}
|