feat: add diff view component
This commit is contained in:
@@ -287,7 +287,7 @@ impl Render for CodeViewState {
|
||||
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 + text_size;
|
||||
let gutter_width = text_size * 0.62 * (gutter_chars as f32 + 1.0);
|
||||
|
||||
let bounds_cell: std::rc::Rc<std::cell::RefCell<Option<Bounds<Pixels>>>> =
|
||||
std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
@@ -611,7 +611,7 @@ fn run_style(role: Role, selected: bool, colors: ColorTokens, ramps: Ramps) -> H
|
||||
}
|
||||
|
||||
/// A gutter cell sized to the whole row.
|
||||
fn gutter_cell<E>(
|
||||
pub(crate) fn gutter_cell<E>(
|
||||
width: Pixels,
|
||||
height: Pixels,
|
||||
mono_font: &'static str,
|
||||
@@ -637,7 +637,7 @@ where
|
||||
}
|
||||
|
||||
/// Maps a highlight role to the theme's semantic palette.
|
||||
fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||
pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||
match role {
|
||||
Role::Text => colors.foreground,
|
||||
Role::Keyword => ramps.primary.base,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! 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`].
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Context, ElementId, HighlightStyle, Hsla, 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 similar::{ChangeTag, TextDiff};
|
||||
|
||||
use crate::code_view::{gutter_cell, role_color};
|
||||
use crate::highlight::{Language, Line, Role, highlight};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// One row of a unified diff.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DiffRow {
|
||||
pub kind: DiffRowKind,
|
||||
/// The highlighted line content.
|
||||
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>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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)),
|
||||
};
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// Retained state for a diff view.
|
||||
pub struct DiffViewState {
|
||||
id: ElementId,
|
||||
rows: Vec<DiffRow>,
|
||||
/// 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>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
rows: diff_rows(old.as_ref(), new.as_ref(), language),
|
||||
handle: UniformListScrollHandle::new(),
|
||||
a11y_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.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 non-context 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)
|
||||
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 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 widest_row = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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,
|
||||
)
|
||||
})
|
||||
.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
) -> gpui::AnyElement {
|
||||
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 => "",
|
||||
};
|
||||
|
||||
let mut text = String::new();
|
||||
let mut highlights: Vec<(std::ops::Range<usize>, HighlightStyle)> = 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()
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let style = TextStyle {
|
||||
color: colors.foreground,
|
||||
font_family: SharedString::from(mono_font),
|
||||
font_size: text_size.into(),
|
||||
line_height: line_height.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
div()
|
||||
.debug_selector({
|
||||
let name = format!("{id}-row-{row_ix}");
|
||||
move || name
|
||||
})
|
||||
.id(("diff-row", row_ix))
|
||||
.h(line_height)
|
||||
.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(),
|
||||
)
|
||||
.child(
|
||||
gutter_cell(
|
||||
number_width,
|
||||
line_height,
|
||||
mono_font,
|
||||
text_size,
|
||||
colors.muted_foreground,
|
||||
row.new_number
|
||||
.map(|number| number.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.justify_end()
|
||||
.whitespace_nowrap(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,7 @@
|
||||
#[cfg(feature = "code_view")]
|
||||
pub mod code_view;
|
||||
|
||||
#[cfg(feature = "diff_view")]
|
||||
pub mod diff_view;
|
||||
|
||||
pub mod highlight;
|
||||
|
||||
Reference in New Issue
Block a user