feat: add pierre-style presentation to diff view

This commit is contained in:
2026-11-06 15:21:20 +07:00
parent 7214c70ea7
commit e8e1cb39e0
5 changed files with 636 additions and 229 deletions
+5 -58
View File
@@ -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::cell::RefCell<Option<Bounds<Pixels>>>> =
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<E>(
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,
}
}
+357 -133
View File
@@ -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<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 {
@@ -58,23 +113,121 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
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<DiffRow> {
pub struct DiffViewState {
id: ElementId,
rows: Vec<DiffRow>,
options: DiffViewOptions,
/// Drives both scroll axes of the virtualized row list.
handle: UniformListScrollHandle,
a11y_label: Option<SharedString>,
@@ -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<O, N>(&mut self, old: O, new: N, language: Language, cx: &mut Context<Self>)
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<Self>) -> 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<usize>, HighlightStyle)> = Vec::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 {
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<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),
@@ -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
}
}
+2
View File
@@ -9,3 +9,5 @@ pub mod code_view;
pub mod diff_view;
pub mod highlight;
mod utils;
+138
View File
@@ -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<E>(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<Range<usize>>,
) -> 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
}
}