From e8e1cb39e0f7d2b18499a74d510545727137d4b6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 15:21:20 +0700 Subject: [PATCH] feat: add pierre-style presentation to diff view --- crates/editor/src/code_view.rs | 63 +--- crates/editor/src/diff_view.rs | 490 ++++++++++++++++++++++--------- crates/editor/src/lib.rs | 2 + crates/editor/src/utils.rs | 138 +++++++++ crates/editor/tests/diff_view.rs | 172 ++++++++--- 5 files changed, 636 insertions(+), 229 deletions(-) create mode 100644 crates/editor/src/utils.rs diff --git a/crates/editor/src/code_view.rs b/crates/editor/src/code_view.rs index 2b0a118..7520d38 100644 --- a/crates/editor/src/code_view.rs +++ b/crates/editor/src/code_view.rs @@ -14,6 +14,7 @@ use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibilit use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; use crate::highlight::{HighlightedCode, Language, Role, highlight_with_tree}; +use crate::utils::{gutter_cell, run_style}; actions!(code_view, [Copy]); @@ -285,9 +286,7 @@ impl Render for CodeViewState { let state = cx.entity(); let line_count = self.code.lines.len(); let selection = self.selection(); - - let gutter_chars = line_count.max(1).to_string().len().max(2); - let gutter_width = text_size * 0.62 * (gutter_chars as f32 + 1.0); + let gutter_width = root_font_size * 2.5; let bounds_cell: std::rc::Rc>>> = std::rc::Rc::new(std::cell::RefCell::new(None)); @@ -313,7 +312,7 @@ impl Render for CodeViewState { theme.ramps, theme.sizes.small.text_size, line_height, - gutter_width, + cx, ) }) .collect() @@ -474,7 +473,7 @@ fn render_row( ramps: Ramps, text_size: Pixels, line_height: Pixels, - gutter_width: Pixels, + cx: &App, ) -> gpui::AnyElement { let line = code.code().lines.get(line_ix).cloned().unwrap_or_default(); @@ -575,10 +574,7 @@ fn render_row( }) .child( gutter_cell( - gutter_width, - line_height, - mono_font, - text_size, + cx, if active { colors.foreground } else { @@ -601,52 +597,3 @@ fn render_row( ) .into_any_element() } - -fn run_style(role: Role, selected: bool, colors: ColorTokens, ramps: Ramps) -> HighlightStyle { - HighlightStyle { - color: Some(role_color(role, colors, ramps)), - background_color: selected.then_some(colors.selection), - ..Default::default() - } -} - -/// A gutter cell sized to the whole row. -pub(crate) fn gutter_cell( - width: Pixels, - height: Pixels, - mono_font: &'static str, - text_size: Pixels, - color: Hsla, - content: E, -) -> gpui::Div -where - E: IntoElement, -{ - div() - .w(width) - .h(height) - .flex() - .items_center() - .justify_center() - .flex_none() - .font_family(mono_font) - .text_size(text_size) - .line_height(gpui::relative(1.0)) - .text_color(color) - .child(content) -} - -/// Maps a highlight role to the theme's semantic palette. -pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla { - match role { - Role::Text => colors.foreground, - Role::Keyword => ramps.primary.base, - Role::String | Role::Escape => ramps.success.base, - Role::Comment | Role::Punctuation => colors.muted_foreground, - Role::Function | Role::Property => ramps.info.base, - Role::Type => ramps.warning.base, - Role::Number | Role::Constant => ramps.secondary.base, - Role::Operator => colors.foreground, - Role::Tag => ramps.destructive.base, - } -} diff --git a/crates/editor/src/diff_view.rs b/crates/editor/src/diff_view.rs index 4916f31..674b4cb 100644 --- a/crates/editor/src/diff_view.rs +++ b/crates/editor/src/diff_view.rs @@ -1,21 +1,25 @@ //! The diff view: a unified, syntax-highlighted rendering of two versions of -//! a file. Rows reuse the code view's highlighting; consumers that already -//! hold a git diff can build [`DiffRow`]s themselves instead of calling -//! [`diff_rows`]. +//! 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::{ - Context, ElementId, HighlightStyle, Hsla, InteractiveElement as _, IntoElement, + 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 _, ColorTokens, Ramps}; +use gpui_native_theme::ActiveTheme as _; use similar::{ChangeTag, TextDiff}; -use crate::code_view::{gutter_cell, role_color}; +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)] @@ -26,18 +30,69 @@ pub enum DiffRowKind { Added, /// Only in the old version. Removed, + /// A hunk separator introducing the rows after it. + Hunk, } -/// One row of a unified diff. +/// 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. + /// 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 { @@ -58,23 +113,121 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec { let diff = TextDiff::from_lines(old, new); let mut rows = Vec::new(); + let mut collapsed_before = 0usize; - for change in diff.iter_all_changes() { - let code = match change.tag() { - ChangeTag::Equal => change.new_index().and_then(|ix| new_code.lines.get(ix)), - ChangeTag::Delete => change.old_index().and_then(|ix| old_code.lines.get(ix)), - ChangeTag::Insert => change.new_index().and_then(|ix| new_code.lines.get(ix)), + 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 }; - rows.push(DiffRow { - kind: match change.tag() { - ChangeTag::Equal => DiffRowKind::Context, - ChangeTag::Delete => DiffRowKind::Removed, - ChangeTag::Insert => DiffRowKind::Added, - }, - content: code.cloned().unwrap_or_default(), - old_number: change.old_index().map(|ix| ix + 1), - new_number: change.new_index().map(|ix| ix + 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 @@ -84,6 +237,7 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec { pub struct DiffViewState { id: ElementId, rows: Vec, + options: DiffViewOptions, /// Drives both scroll axes of the virtualized row list. handle: UniformListScrollHandle, a11y_label: Option, @@ -100,11 +254,30 @@ impl DiffViewState { 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 @@ -124,12 +297,12 @@ impl DiffViewState { &self.rows } - /// Scrolls the first non-context row to the top of the view. + /// 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| row.kind != DiffRowKind::Context) + .position(|row| matches!(row.kind, DiffRowKind::Added | DiffRowKind::Removed)) else { return; }; @@ -150,53 +323,25 @@ 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 line_height = text_size * 1.5; let id = self.id.clone(); let state = cx.entity(); let row_count = self.rows.len(); - - let widest_number = self - .rows - .iter() - .flat_map(|row| [row.old_number, row.new_number]) - .flatten() - .max() - .unwrap_or(1); - - let number_chars = widest_number.to_string().len().max(2); - let number_width = text_size * 0.62 * (number_chars as f32 + 1.0); - let marker_width = text_size; - let char_width = text_size * 0.62; + 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, { - let id = id.clone(); move |range, _window, cx| { let diff = state.read(cx); - let theme = cx.theme(); range - .map(|row_ix| { - render_row( - &diff.rows[row_ix], - &id, - row_ix, - theme.mono_font, - theme.colors, - theme.ramps, - theme.sizes.small.text_size, - line_height, - char_width, - marker_width, - number_width, - ) - }) + .map(|row_ix| render_row(&diff.rows[row_ix], row_ix, options, cx)) .collect() } }) @@ -230,55 +375,134 @@ impl Render for DiffViewState { } } -#[allow(clippy::too_many_arguments)] fn render_row( row: &DiffRow, - id: &ElementId, row_ix: usize, - mono_font: &'static str, - colors: ColorTokens, - ramps: Ramps, - text_size: Pixels, - line_height: Pixels, - char_width: Pixels, - marker_width: Pixels, - number_width: Pixels, + 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::Context => None, DiffRowKind::Added => Some(wash(ramps.success.base)), DiffRowKind::Removed => Some(wash(ramps.destructive.base)), - }; - - let marker_color = match row.kind { - DiffRowKind::Added => Some(ramps.success.base), - DiffRowKind::Removed => Some(ramps.destructive.base), - DiffRowKind::Context => None, - }; - - let marker = match row.kind { - DiffRowKind::Added => "+", - DiffRowKind::Removed => "-", - DiffRowKind::Context => "", + DiffRowKind::Context | DiffRowKind::Hunk => None, }; let mut text = String::new(); - let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::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 { - highlights.push(( - start..text.len(), - HighlightStyle { - color: Some(role_color(span.role, colors, ramps)), - ..Default::default() - }, - )); + 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), @@ -287,67 +511,67 @@ fn render_row( ..Default::default() }; - div() - .debug_selector({ - let name = format!("{id}-row-{row_ix}"); - move || name - }) - .id(("diff-row", row_ix)) - .h(line_height) + frame .flex() .items_center() - .overflow_hidden() - .when_some(wash, |row_element, wash| row_element.bg(wash)) - .child(gutter_cell( - marker_width, - line_height, - mono_font, - text_size, - marker_color.unwrap_or(gpui::transparent_black()), - marker, - )) - .child( - gutter_cell( - number_width, - line_height, - mono_font, - text_size, - colors.muted_foreground, - row.old_number - .map(|number| number.to_string()) - .unwrap_or_default(), - ) - .justify_end() - .whitespace_nowrap(), + .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 => "-", + _ => "", + }, + )) + }, ) - .child( - gutter_cell( - number_width, - line_height, - mono_font, - text_size, + .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(), ) - .justify_end() - .whitespace_nowrap(), + .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() } - -/// A change wash: the ramp color at the alpha rows tint with. -fn wash(color: Hsla) -> Hsla { - Hsla { - a: color.a * 0.14, - ..color - } -} diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 891bd52..947718d 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -9,3 +9,5 @@ pub mod code_view; pub mod diff_view; pub mod highlight; + +mod utils; diff --git a/crates/editor/src/utils.rs b/crates/editor/src/utils.rs new file mode 100644 index 0000000..cace8c4 --- /dev/null +++ b/crates/editor/src/utils.rs @@ -0,0 +1,138 @@ +//! Shared helpers for the editor's views: gutter cells, role colors, change +//! washes and diff row building. + +#[cfg(feature = "diff_view")] +use std::ops::Range; + +use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div}; +use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; +#[cfg(feature = "diff_view")] +use similar::{Change, ChangeTag}; + +#[cfg(feature = "diff_view")] +use crate::diff_view::{DiffRow, DiffRowKind}; +#[cfg(feature = "diff_view")] +use crate::highlight::HighlightedCode; +use crate::highlight::Role; + +/// Maps a highlight role to the theme's semantic palette. +pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla { + match role { + Role::Text => colors.foreground, + Role::Keyword => ramps.primary.base, + Role::String | Role::Escape => ramps.success.base, + Role::Comment | Role::Punctuation => colors.muted_foreground, + Role::Function | Role::Property => ramps.info.base, + Role::Type => ramps.warning.base, + Role::Number | Role::Constant => ramps.secondary.base, + Role::Operator => colors.foreground, + Role::Tag => ramps.destructive.base, + } +} + +/// Builds the highlight style for one run of a code view row. +pub(crate) fn run_style( + role: Role, + selected: bool, + colors: ColorTokens, + ramps: Ramps, +) -> HighlightStyle { + HighlightStyle { + color: Some(role_color(role, colors, ramps)), + background_color: selected.then_some(colors.selection), + ..Default::default() + } +} + +/// A gutter cell sized to the whole row. +pub(crate) fn gutter_cell(cx: &App, color: Hsla, content: E) -> gpui::Div +where + E: IntoElement, +{ + let theme = cx.theme(); + + div() + .size_10() + .flex() + .items_center() + .justify_center() + .flex_none() + .font_family(theme.mono_font) + .text_size(theme.sizes.small.text_size) + .line_height(gpui::relative(1.0)) + .text_color(color) + .child(content) +} + +/// Builds a diff line row from a `similar` change. +#[cfg(feature = "diff_view")] +pub(crate) fn line_row( + change: &Change<&str>, + code: &HighlightedCode, + changed_ranges: Vec>, +) -> DiffRow { + let content = match change.tag() { + ChangeTag::Equal => change + .new_index() + .and_then(|ix| code.lines.get(ix)) + .cloned() + .unwrap_or_default(), + ChangeTag::Delete => change + .old_index() + .and_then(|ix| code.lines.get(ix)) + .cloned() + .unwrap_or_default(), + ChangeTag::Insert => change + .new_index() + .and_then(|ix| code.lines.get(ix)) + .cloned() + .unwrap_or_default(), + }; + + let (old_number, new_number) = match change.tag() { + ChangeTag::Equal => ( + change.old_index().map(|ix| ix + 1), + change.new_index().map(|ix| ix + 1), + ), + ChangeTag::Delete => (change.old_index().map(|ix| ix + 1), None), + ChangeTag::Insert => (None, change.new_index().map(|ix| ix + 1)), + }; + + DiffRow { + kind: match change.tag() { + ChangeTag::Equal => DiffRowKind::Context, + ChangeTag::Delete => DiffRowKind::Removed, + ChangeTag::Insert => DiffRowKind::Added, + }, + content, + old_number, + new_number, + changed_ranges, + header: Default::default(), + hidden_lines: 0, + } +} + +/// A change wash: the ramp color at the alpha rows tint with. +pub(crate) fn wash(color: Hsla) -> Hsla { + Hsla { + a: color.a * 0.14, + ..color + } +} + +/// The stronger wash under number gutters and intra-line word changes. +pub(crate) fn wash_strong(color: Hsla) -> Hsla { + Hsla { + a: color.a * 0.34, + ..color + } +} + +/// The subtle wash that sets the number gutter off from the code. +pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla { + Hsla { + a: colors.muted.a * 0.18, + ..colors.muted + } +} diff --git a/crates/editor/tests/diff_view.rs b/crates/editor/tests/diff_view.rs index 10e454a..c6878d0 100644 --- a/crates/editor/tests/diff_view.rs +++ b/crates/editor/tests/diff_view.rs @@ -5,7 +5,7 @@ use gpui::{ AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; -use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState, diff_rows}; +use gpui_native_editor::diff_view::{DiffRow, DiffRowKind, DiffViewState, diff_rows}; use gpui_native_editor::highlight::{Language, Role}; const OLD: &str = "fn a() {}\nfn b() {}\nfn c() {}\n"; @@ -26,6 +26,10 @@ fn rows_mark_removals_additions_and_context_with_line_numbers() { DiffRowKind::Context, ] ); + assert!( + rows.iter().all(|row| row.kind != DiffRowKind::Hunk), + "changes within one context window need no separator" + ); let removed = &rows[1]; assert_eq!(removed.old_number, Some(2)); @@ -54,6 +58,64 @@ fn rows_mark_removals_additions_and_context_with_line_numbers() { assert_eq!(context.new_number, Some(1)); } +#[test] +fn hunk_separators_carry_the_collapsed_line_count_and_header() { + // Ten lines with only line 6 changed: three lines of context trim the + // leading unchanged region to three hidden lines. + let mut old = String::new(); + let mut new = String::new(); + for line in 0..10 { + old.push_str(&format!("line {line}\n")); + if line == 6 { + new.push_str(&format!("line {line} changed\n")); + } else { + new.push_str(&format!("line {line}\n")); + } + } + let rows = diff_rows(&old, &new, Language::Rust); + + let separators: Vec<&DiffRow> = rows + .iter() + .filter(|row| row.kind == DiffRowKind::Hunk) + .collect(); + assert_eq!(separators.len(), 1, "one collapsed region above the hunk"); + assert_eq!(separators[0].hidden_lines, 3); + assert_eq!(separators[0].header, "@@ -4,7 +4,7 @@"); + + let changed = rows + .iter() + .find(|row| row.kind == DiffRowKind::Removed) + .expect("the changed line renders as a removal"); + assert_eq!(changed.old_number, Some(7)); +} + +#[test] +fn word_changes_tint_only_the_changed_words() { + let rows = diff_rows(OLD, NEW, Language::Rust); + + let removed = &rows[1]; + let removed_text: String = removed + .content + .spans + .iter() + .map(|span| span.text.as_ref()) + .collect(); + assert_eq!(removed.changed_ranges.len(), 1, "one word moved"); + let range = &removed.changed_ranges[0]; + assert_eq!(&removed_text[range.clone()], "b()"); + + let added = &rows[2]; + let added_text: String = added + .content + .spans + .iter() + .map(|span| span.text.as_ref()) + .collect(); + assert_eq!(added.changed_ranges.len(), 1); + let range = &added.changed_ranges[0]; + assert_eq!(&added_text[range.clone()], "x()"); +} + #[test] fn an_added_line_carries_its_highlight_spans() { let rows = diff_rows("", "fn zed() {}\n", Language::Rust); @@ -72,13 +134,6 @@ fn an_added_line_carries_its_highlight_spans() { ); } -#[test] -fn identical_versions_are_all_context() { - let rows = diff_rows(OLD, OLD, Language::Rust); - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| row.kind == DiffRowKind::Context)); -} - struct Host { diff: Entity, } @@ -90,18 +145,17 @@ impl Render for Host { } fn diff_view_window( - rows: usize, - changes_at: usize, + lines: usize, + changed_lines: &[usize], ) -> (TestAppContext, VisualTestContext, Entity) { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let mut old = String::new(); let mut new = String::new(); - for line in 0..rows { + for line in 0..lines { old.push_str(&format!("line {line} unchanged\n")); - if line == changes_at { + if changed_lines.contains(&line) { new.push_str(&format!("line {line} rewritten\n")); - new.push_str(&format!("line {line} added\n")); } else { new.push_str(&format!("line {line} unchanged\n")); } @@ -124,17 +178,33 @@ fn diff_view_window( #[test] fn rows_render_virtualized_and_the_view_jumps_to_the_first_change() { - let (_cx, mut visual, state) = diff_view_window(200, 150); + let changed: Vec = (10..200).step_by(10).collect(); + let (_cx, mut visual, state) = diff_view_window(200, &changed); assert!( - visual.debug_bounds("test-diff-row-0").is_some(), + visual.debug_bounds("diff-row-0").is_some(), "the first row renders" ); assert!( - visual.debug_bounds("test-diff-row-199").is_none(), + visual.debug_bounds("diff-row-100").is_none(), "rows past the viewport never render" ); + // Scroll to the bottom of the diff first, so the jump has somewhere to + // go. + visual.simulate_event(gpui::ScrollEvent { + position: point(px(500.), px(400.)), + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-5000.))), + ..Default::default() + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + assert!( + visual.debug_bounds("diff-row-0").is_none(), + "the view scrolled to the bottom" + ); + state.update_in(&mut visual, |state, _window, _cx| { state.scroll_to_first_change() }); @@ -143,13 +213,23 @@ fn rows_render_virtualized_and_the_view_jumps_to_the_first_change() { }); assert!( - visual.debug_bounds("test-diff-row-150").is_some(), + visual.debug_bounds("diff-row-4").is_some(), "the first change scrolled into view" ); assert!( - visual.debug_bounds("test-diff-row-0").is_none(), - "the context before it scrolled out" + visual.debug_bounds("diff-row-0").is_none(), + "the hunk header above it scrolled out" ); + assert!( + visual.debug_bounds("diff-row-100").is_none(), + "rows past the viewport still never render" + ); +} + +#[test] +fn identical_versions_produce_no_rows() { + let rows = diff_rows(OLD, OLD, Language::Rust); + assert!(rows.is_empty(), "identical versions have no diff to show"); } #[test] @@ -163,14 +243,8 @@ fn set_versions_re_diffs() { }); }); - let all_context = cx.update(|cx| { - state - .read(cx) - .rows() - .iter() - .all(|row| row.kind == DiffRowKind::Context) - }); - assert!(all_context, "identical versions diff to context rows"); + let empty = cx.update(|cx| state.read(cx).rows().is_empty()); + assert!(empty, "identical versions have no diff to show"); } #[test] @@ -193,7 +267,7 @@ fn scrolling_horizontally_moves_rows_without_vertical_drift() { }); let before = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row renders"); visual.simulate_event(gpui::ScrollEvent { @@ -206,7 +280,7 @@ fn scrolling_horizontally_moves_rows_without_vertical_drift() { }); let after = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row still renders"); assert!( (after.origin.x - before.origin.x + px(200.)).abs() <= px(2.), @@ -222,8 +296,19 @@ fn scrolling_horizontally_moves_rows_without_vertical_drift() { fn scrolling_vertically_does_not_drift_horizontally() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); - let old = "fn same() {}\n".repeat(200); - let new = format!("{old}fn long() {{ {} }}\n", "text ".repeat(300)); + // Spread changes keep the diff taller than the viewport, so there is + // something to scroll vertically; the long line forces a horizontal bar. + let mut old = String::new(); + let mut body = String::new(); + for line in 0..200 { + old.push_str(&format!("fn same_{line}() {{}}\n")); + if line % 10 == 0 { + body.push_str(&format!("fn changed_{line}() {{}}\n")); + } else { + body.push_str(&format!("fn same_{line}() {{}}\n")); + } + } + let new = format!("{body}fn long() {{ {} }}\n", "text ".repeat(300)); let state = DiffViewState::new("test-diff", old, new, Language::Rust); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); @@ -238,7 +323,7 @@ fn scrolling_vertically_does_not_drift_horizontally() { }); let before = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row renders"); // A pure vertical gesture... @@ -259,7 +344,7 @@ fn scrolling_vertically_does_not_drift_horizontally() { }); let after = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row still renders"); assert_eq!( after.origin.x, before.origin.x, @@ -275,8 +360,19 @@ fn scrolling_vertically_does_not_drift_horizontally() { fn dragging_the_vertical_thumb_leaves_the_horizontal_offset_alone() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); - let old = "fn same() {}\n".repeat(200); - let new = format!("{old}fn long() {{ {} }}\n", "text ".repeat(300)); + // Spread changes keep the diff taller than the viewport, so the vertical + // scrollbar exists; the long line forces the horizontal one. + let mut old = String::new(); + let mut body = String::new(); + for line in 0..200 { + old.push_str(&format!("fn same_{line}() {{}}\n")); + if line % 10 == 0 { + body.push_str(&format!("fn changed_{line}() {{}}\n")); + } else { + body.push_str(&format!("fn same_{line}() {{}}\n")); + } + } + let new = format!("{body}fn long() {{ {} }}\n", "text ".repeat(300)); let state = DiffViewState::new("test-diff", old, new, Language::Rust); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); @@ -292,7 +388,7 @@ fn dragging_the_vertical_thumb_leaves_the_horizontal_offset_alone() { // Scroll the strip left first, so horizontal drift would be observable. let row = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row renders"); visual.simulate_event(gpui::ScrollEvent { position: row.center(), @@ -303,7 +399,7 @@ fn dragging_the_vertical_thumb_leaves_the_horizontal_offset_alone() { window.draw(cx).clear(cx); }); let before = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row still renders"); // Press the vertical thumb: right edge, within its minimum length. @@ -336,7 +432,7 @@ fn dragging_the_vertical_thumb_leaves_the_horizontal_offset_alone() { }); let after = visual - .debug_bounds("test-diff-row-0") + .debug_bounds("diff-row-0") .expect("the first row still renders"); assert_eq!( after.origin.x, before.origin.x,