feat: add diff view component
This commit is contained in:
Generated
+7
@@ -1914,6 +1914,7 @@ dependencies = [
|
|||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
"gpui-native-utils",
|
"gpui-native-utils",
|
||||||
|
"similar",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-javascript",
|
"tree-sitter-javascript",
|
||||||
"tree-sitter-python",
|
"tree-sitter-python",
|
||||||
@@ -5205,6 +5206,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simplecss"
|
name = "simplecss"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -54,9 +55,9 @@ struct AutohideState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Autohide {
|
impl Autohide {
|
||||||
pub(crate) fn new(window: &mut Window, cx: &mut App) -> Self {
|
pub(crate) fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self {
|
||||||
let view = window.current_view();
|
let view = window.current_view();
|
||||||
let state = window.use_keyed_state("scrollbar-autohide", cx, |_, _| AutohideState {
|
let state = window.use_keyed_state(key, cx, |_, _| AutohideState {
|
||||||
last_activity: None,
|
last_activity: None,
|
||||||
hide_task: None,
|
hide_task: None,
|
||||||
});
|
});
|
||||||
@@ -195,6 +196,11 @@ impl ScrollArea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names keyed state under the scrollbar's own id.
|
||||||
|
pub fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId {
|
||||||
|
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure thumb geometry for an overlay scrollbar.
|
/// Pure thumb geometry for an overlay scrollbar.
|
||||||
pub(crate) fn thumb_geometry(
|
pub(crate) fn thumb_geometry(
|
||||||
viewport: Pixels,
|
viewport: Pixels,
|
||||||
@@ -247,11 +253,12 @@ impl RenderOnce for ScrollArea {
|
|||||||
ScrollbarVisibility::Always => false,
|
ScrollbarVisibility::Always => false,
|
||||||
ScrollbarVisibility::Autohide => true,
|
ScrollbarVisibility::Autohide => true,
|
||||||
};
|
};
|
||||||
|
let id = self.id.clone();
|
||||||
wrapper.extend(std::iter::once(
|
wrapper.extend(std::iter::once(
|
||||||
canvas(|_, _, _| {}, {
|
canvas(|_, _, _| {}, {
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
move |_, _, window, cx| {
|
move |_, _, window, cx| {
|
||||||
paint_scrollbar(&handle, autohide, window, cx);
|
paint_scrollbar(&handle, &id, autohide, window, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
@@ -265,7 +272,13 @@ impl RenderOnce for ScrollArea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Paints the overlay scrollbar thumb.
|
/// Paints the overlay scrollbar thumb.
|
||||||
fn paint_scrollbar(handle: &ScrollHandle, autohide: bool, window: &mut Window, cx: &mut App) {
|
fn paint_scrollbar(
|
||||||
|
handle: &ScrollHandle,
|
||||||
|
key: &ElementId,
|
||||||
|
autohide: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let view = window.current_view();
|
let view = window.current_view();
|
||||||
|
|
||||||
@@ -281,8 +294,9 @@ fn paint_scrollbar(handle: &ScrollHandle, autohide: bool, window: &mut Window, c
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let autohide_state = autohide.then(|| Autohide::new(window, cx));
|
let autohide_state =
|
||||||
let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| {
|
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx));
|
||||||
|
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| {
|
||||||
Option::<(Pixels, Pixels)>::None
|
Option::<(Pixels, Pixels)>::None
|
||||||
});
|
});
|
||||||
let dragging = drag.read(cx).is_some();
|
let dragging = drag.read(cx).is_some();
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
//! Overlay scrollbars for scroll surfaces, including gpui's virtualized
|
//! Overlay scrollbars for scroll surfaces.
|
||||||
//! `uniform_list` and `list`. Render the scrollbar as a later child of a
|
|
||||||
//! `.relative()` container around the scrolled surface:
|
|
||||||
//!
|
|
||||||
//! ```
|
|
||||||
//! div().relative()
|
|
||||||
//! .child(uniform_list(...).track_scroll(&handle))
|
|
||||||
//! .child(Scrollbar::vertical(handle))
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Bounds, DispatchPhase, IntoElement, ListState, MouseButton, MouseDownEvent,
|
App, Bounds, DispatchPhase, ElementId, IntoElement, ListState, MouseButton, MouseDownEvent,
|
||||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle,
|
MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle,
|
||||||
Styled as _, UniformListScrollHandle, Window, canvas, fill, point,
|
Styled as _, UniformListScrollHandle, Window, canvas, fill, point,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
use crate::scroll_area::{Autohide, thumb_geometry, thumb_style};
|
use crate::scroll_area::{Autohide, scrollbar_state_key, thumb_geometry, thumb_style};
|
||||||
|
|
||||||
/// Re-exported so callers only need the scrollbar module.
|
/// Re-exported so callers only need the scrollbar module.
|
||||||
pub use crate::scroll_area::ScrollbarVisibility;
|
pub use crate::scroll_area::ScrollbarVisibility;
|
||||||
@@ -130,12 +122,13 @@ impl Axis {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An overlay scrollbar; the caller renders it over the scrolled surface.
|
/// An overlay scrollbar, the caller renders it over the scrolled surface.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Scrollbar {
|
pub struct Scrollbar {
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
handle: Rc<dyn ScrollbarHandle>,
|
handle: Rc<dyn ScrollbarHandle>,
|
||||||
visibility: ScrollbarVisibility,
|
visibility: ScrollbarVisibility,
|
||||||
|
key: ElementId,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scrollbar {
|
impl Scrollbar {
|
||||||
@@ -148,6 +141,7 @@ impl Scrollbar {
|
|||||||
axis: Axis::Vertical,
|
axis: Axis::Vertical,
|
||||||
handle: Rc::new(handle),
|
handle: Rc::new(handle),
|
||||||
visibility: ScrollbarVisibility::default(),
|
visibility: ScrollbarVisibility::default(),
|
||||||
|
key: ElementId::Name("scrollbar".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +154,7 @@ impl Scrollbar {
|
|||||||
axis: Axis::Horizontal,
|
axis: Axis::Horizontal,
|
||||||
handle: Rc::new(handle),
|
handle: Rc::new(handle),
|
||||||
visibility: ScrollbarVisibility::default(),
|
visibility: ScrollbarVisibility::default(),
|
||||||
|
key: ElementId::Name("scrollbar".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +163,12 @@ impl Scrollbar {
|
|||||||
self.visibility = visibility;
|
self.visibility = visibility;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names this scrollbar's state.
|
||||||
|
pub fn key(mut self, key: impl Into<ElementId>) -> Self {
|
||||||
|
self.key = key.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for Scrollbar {
|
impl RenderOnce for Scrollbar {
|
||||||
@@ -176,7 +177,8 @@ impl RenderOnce for Scrollbar {
|
|||||||
let handle = self.handle;
|
let handle = self.handle;
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let visibility = self.visibility;
|
let visibility = self.visibility;
|
||||||
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, window, cx)
|
let key = self.key;
|
||||||
|
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &key, window, cx)
|
||||||
})
|
})
|
||||||
.absolute()
|
.absolute()
|
||||||
.inset_0()
|
.inset_0()
|
||||||
@@ -190,6 +192,7 @@ fn paint_scrollbar(
|
|||||||
handle: &Rc<dyn ScrollbarHandle>,
|
handle: &Rc<dyn ScrollbarHandle>,
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
visibility: ScrollbarVisibility,
|
visibility: ScrollbarVisibility,
|
||||||
|
key: &ElementId,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
@@ -214,7 +217,9 @@ fn paint_scrollbar(
|
|||||||
ScrollbarVisibility::Always => false,
|
ScrollbarVisibility::Always => false,
|
||||||
ScrollbarVisibility::Autohide => true,
|
ScrollbarVisibility::Autohide => true,
|
||||||
};
|
};
|
||||||
let autohide_state = autohide.then(|| Autohide::new(window, cx));
|
|
||||||
|
let autohide_state =
|
||||||
|
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx));
|
||||||
|
|
||||||
let track_length = viewport_along - inset * 2.0;
|
let track_length = viewport_along - inset * 2.0;
|
||||||
|
|
||||||
@@ -262,7 +267,9 @@ fn paint_scrollbar(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| Drag::None);
|
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| {
|
||||||
|
Drag::None
|
||||||
|
});
|
||||||
let dragging = drag.read(cx).is_some();
|
let dragging = drag.read(cx).is_some();
|
||||||
|
|
||||||
let alpha = match &autohide_state {
|
let alpha = match &autohide_state {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ gpui-native-core = { workspace = true, optional = true }
|
|||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { workspace = true }
|
gpui-native-utils = { workspace = true }
|
||||||
tree-sitter = "0.25"
|
tree-sitter = "0.25"
|
||||||
|
similar = "2"
|
||||||
tree-sitter-rust = { version = "0.24", optional = true }
|
tree-sitter-rust = { version = "0.24", optional = true }
|
||||||
tree-sitter-typescript = { version = "0.23", optional = true }
|
tree-sitter-typescript = { version = "0.23", optional = true }
|
||||||
tree-sitter-javascript = { version = "0.23", optional = true }
|
tree-sitter-javascript = { version = "0.23", optional = true }
|
||||||
@@ -22,6 +23,7 @@ gpui = { workspace = true, features = ["test-support"] }
|
|||||||
default = ["full"]
|
default = ["full"]
|
||||||
full = [
|
full = [
|
||||||
"code_view",
|
"code_view",
|
||||||
|
"diff_view",
|
||||||
"rust",
|
"rust",
|
||||||
"typescript",
|
"typescript",
|
||||||
"javascript",
|
"javascript",
|
||||||
@@ -32,6 +34,7 @@ code_view = [
|
|||||||
"gpui-native-core/scroll_area",
|
"gpui-native-core/scroll_area",
|
||||||
"gpui-native-core/scrollbar",
|
"gpui-native-core/scrollbar",
|
||||||
]
|
]
|
||||||
|
diff_view = ["code_view"]
|
||||||
rust = ["dep:tree-sitter-rust"]
|
rust = ["dep:tree-sitter-rust"]
|
||||||
typescript = ["dep:tree-sitter-typescript"]
|
typescript = ["dep:tree-sitter-typescript"]
|
||||||
javascript = ["dep:tree-sitter-javascript"]
|
javascript = ["dep:tree-sitter-javascript"]
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ impl Render for CodeViewState {
|
|||||||
let selection = self.selection();
|
let selection = self.selection();
|
||||||
|
|
||||||
let gutter_chars = line_count.max(1).to_string().len().max(2);
|
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>>>> =
|
let bounds_cell: std::rc::Rc<std::cell::RefCell<Option<Bounds<Pixels>>>> =
|
||||||
std::rc::Rc::new(std::cell::RefCell::new(None));
|
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.
|
/// A gutter cell sized to the whole row.
|
||||||
fn gutter_cell<E>(
|
pub(crate) fn gutter_cell<E>(
|
||||||
width: Pixels,
|
width: Pixels,
|
||||||
height: Pixels,
|
height: Pixels,
|
||||||
mono_font: &'static str,
|
mono_font: &'static str,
|
||||||
@@ -637,7 +637,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a highlight role to the theme's semantic palette.
|
/// 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 {
|
match role {
|
||||||
Role::Text => colors.foreground,
|
Role::Text => colors.foreground,
|
||||||
Role::Keyword => ramps.primary.base,
|
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")]
|
#[cfg(feature = "code_view")]
|
||||||
pub mod code_view;
|
pub mod code_view;
|
||||||
|
|
||||||
|
#[cfg(feature = "diff_view")]
|
||||||
|
pub mod diff_view;
|
||||||
|
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
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::highlight::{Language, Role};
|
||||||
|
|
||||||
|
const OLD: &str = "fn a() {}\nfn b() {}\nfn c() {}\n";
|
||||||
|
const NEW: &str = "fn a() {}\nfn x() {}\nfn y() {}\nfn c() {}\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_mark_removals_additions_and_context_with_line_numbers() {
|
||||||
|
let rows = diff_rows(OLD, NEW, Language::Rust);
|
||||||
|
|
||||||
|
let kinds: Vec<DiffRowKind> = rows.iter().map(|row| row.kind).collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
vec![
|
||||||
|
DiffRowKind::Context,
|
||||||
|
DiffRowKind::Removed,
|
||||||
|
DiffRowKind::Added,
|
||||||
|
DiffRowKind::Added,
|
||||||
|
DiffRowKind::Context,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = &rows[1];
|
||||||
|
assert_eq!(removed.old_number, Some(2));
|
||||||
|
assert_eq!(removed.new_number, None);
|
||||||
|
let removed_text: String = removed
|
||||||
|
.content
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.text.as_ref())
|
||||||
|
.collect();
|
||||||
|
assert!(removed_text.contains("fn b() {}"));
|
||||||
|
|
||||||
|
let added = &rows[2];
|
||||||
|
assert_eq!(added.old_number, None);
|
||||||
|
assert_eq!(added.new_number, Some(2));
|
||||||
|
let added_text: String = added
|
||||||
|
.content
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.text.as_ref())
|
||||||
|
.collect();
|
||||||
|
assert!(added_text.contains("fn x() {}"));
|
||||||
|
|
||||||
|
let context = &rows[0];
|
||||||
|
assert_eq!(context.old_number, Some(1));
|
||||||
|
assert_eq!(context.new_number, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_added_line_carries_its_highlight_spans() {
|
||||||
|
let rows = diff_rows("", "fn zed() {}\n", Language::Rust);
|
||||||
|
let added = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.kind == DiffRowKind::Added)
|
||||||
|
.expect("the new file is all additions");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
added
|
||||||
|
.content
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.any(|span| span.role == Role::Keyword),
|
||||||
|
"fn highlights as a keyword inside the diff row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DiffViewState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().child(self.diff.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_view_window(
|
||||||
|
rows: usize,
|
||||||
|
changes_at: usize,
|
||||||
|
) -> (TestAppContext, VisualTestContext, Entity<DiffViewState>) {
|
||||||
|
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 {
|
||||||
|
old.push_str(&format!("line {line} unchanged\n"));
|
||||||
|
if line == changes_at {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = DiffViewState::new("test-diff", old, new, Language::Rust);
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<DiffViewState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx.add_window(move |_, cx| {
|
||||||
|
let diff = cx.new(|_| state);
|
||||||
|
cell.borrow_mut().replace(diff.clone());
|
||||||
|
Host { diff }
|
||||||
|
});
|
||||||
|
let state = state_cell.borrow().clone().expect("state captured");
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, visual, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_render_virtualized_and_the_view_jumps_to_the_first_change() {
|
||||||
|
let (_cx, mut visual, state) = diff_view_window(200, 150);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
visual.debug_bounds("test-diff-row-0").is_some(),
|
||||||
|
"the first row renders"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visual.debug_bounds("test-diff-row-199").is_none(),
|
||||||
|
"rows past the viewport never render"
|
||||||
|
);
|
||||||
|
|
||||||
|
state.update_in(&mut visual, |state, _window, _cx| {
|
||||||
|
state.scroll_to_first_change()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
visual.debug_bounds("test-diff-row-150").is_some(),
|
||||||
|
"the first change scrolled into view"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visual.debug_bounds("test-diff-row-0").is_none(),
|
||||||
|
"the context before it scrolled out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_versions_re_diffs() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
let state = cx.new(|_| DiffViewState::new("test-diff", OLD, NEW, Language::Rust));
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.set_versions("same\n", "same\n", Language::Rust, cx)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_horizontally_moves_rows_without_vertical_drift() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(gpui_native_theme::Theme::init);
|
||||||
|
let old = "fn same() {}\n".repeat(40);
|
||||||
|
let new = format!("{old}fn long() {{ {} }}\n", "text ".repeat(300));
|
||||||
|
let state = DiffViewState::new("test-diff", old, new, Language::Rust);
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<DiffViewState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx.add_window(move |_, cx| {
|
||||||
|
let diff = cx.new(|_| state);
|
||||||
|
cell.borrow_mut().replace(diff.clone());
|
||||||
|
Host { diff }
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let before = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row renders");
|
||||||
|
|
||||||
|
visual.simulate_event(gpui::ScrollEvent {
|
||||||
|
position: before.center(),
|
||||||
|
delta: gpui::ScrollDelta::Pixels(point(px(-200.), px(0.))),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let after = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row still renders");
|
||||||
|
assert!(
|
||||||
|
(after.origin.x - before.origin.x + px(200.)).abs() <= px(2.),
|
||||||
|
"a horizontal wheel gesture scrolls the rows left"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
after.origin.y, before.origin.y,
|
||||||
|
"a horizontal gesture must not drift vertically"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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));
|
||||||
|
let state = DiffViewState::new("test-diff", old, new, Language::Rust);
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<DiffViewState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx.add_window(move |_, cx| {
|
||||||
|
let diff = cx.new(|_| state);
|
||||||
|
cell.borrow_mut().replace(diff.clone());
|
||||||
|
Host { diff }
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let before = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row renders");
|
||||||
|
|
||||||
|
// A pure vertical gesture...
|
||||||
|
visual.simulate_event(gpui::ScrollEvent {
|
||||||
|
position: before.center(),
|
||||||
|
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-5.))),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// ...then a vertical one carrying minor horizontal noise, below the
|
||||||
|
// threshold where gpui would unlock the gesture's axis.
|
||||||
|
visual.simulate_event(gpui::ScrollEvent {
|
||||||
|
position: before.center(),
|
||||||
|
delta: gpui::ScrollDelta::Pixels(point(px(2.), px(-5.))),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let after = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row still renders");
|
||||||
|
assert_eq!(
|
||||||
|
after.origin.x, before.origin.x,
|
||||||
|
"vertical gestures with horizontal noise must not scroll horizontally"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(after.origin.y - (before.origin.y - px(10.))).abs() <= px(2.),
|
||||||
|
"the vertical component scrolls the rows up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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));
|
||||||
|
let state = DiffViewState::new("test-diff", old, new, Language::Rust);
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<DiffViewState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx.add_window(move |_, cx| {
|
||||||
|
let diff = cx.new(|_| state);
|
||||||
|
cell.borrow_mut().replace(diff.clone());
|
||||||
|
Host { diff }
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll the strip left first, so horizontal drift would be observable.
|
||||||
|
let row = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row renders");
|
||||||
|
visual.simulate_event(gpui::ScrollEvent {
|
||||||
|
position: row.center(),
|
||||||
|
delta: gpui::ScrollDelta::Pixels(point(px(-100.), px(0.))),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
let before = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row still renders");
|
||||||
|
|
||||||
|
// Press the vertical thumb: right edge, within its minimum length.
|
||||||
|
let (thumb_width, inset, min_thumb) = cx.update(|cx| {
|
||||||
|
let theme = gpui_native_theme::Theme::global(cx);
|
||||||
|
(
|
||||||
|
theme.scrollbar.width,
|
||||||
|
theme.scrollbar.inset,
|
||||||
|
theme.scrollbar.min_thumb,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let press = point(
|
||||||
|
before.origin.x + before.size.width - thumb_width / 2.,
|
||||||
|
before.origin.y + inset + min_thumb / 2.,
|
||||||
|
);
|
||||||
|
visual.simulate_event(gpui::MouseDownEvent {
|
||||||
|
position: press,
|
||||||
|
button: MouseButton::Left,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move sideways while held; only the vertical thumb is being dragged.
|
||||||
|
visual.simulate_event(gpui::MouseMoveEvent {
|
||||||
|
position: press + point(px(30.), px(0.)),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let after = visual
|
||||||
|
.debug_bounds("test-diff-row-0")
|
||||||
|
.expect("the first row still renders");
|
||||||
|
assert_eq!(
|
||||||
|
after.origin.x, before.origin.x,
|
||||||
|
"dragging the vertical thumb must not scroll the strip horizontally"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
after.origin.y, before.origin.y,
|
||||||
|
"a sideways-only drag must not scroll vertically"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, IntoElement, Modifiers, ParentElement as _, Render,
|
||||||
|
ScrollDelta, ScrollEvent, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||||
|
px,
|
||||||
|
};
|
||||||
|
use gpui_native_editor::diff_view::DiffViewState;
|
||||||
|
|
||||||
|
/// Deterministic old/new pairs of roughly `lines` lines each, with changes
|
||||||
|
/// sprinkled through.
|
||||||
|
fn large_diff_pair(lines: usize) -> (String, String) {
|
||||||
|
let mut old = String::with_capacity(lines * 48);
|
||||||
|
let mut new = String::with_capacity(lines * 48);
|
||||||
|
let mut index = 0usize;
|
||||||
|
while old.lines().count() < lines {
|
||||||
|
let i = index;
|
||||||
|
let comment = format!("// Handler {i} in the generated dispatch table.\n");
|
||||||
|
old.push_str(&comment);
|
||||||
|
new.push_str(&comment);
|
||||||
|
old.push_str(&format!("fn handle_{i}(payload: &str) -> bool {{\n"));
|
||||||
|
new.push_str(&format!("fn handle_{i}(payload: &Payload) -> bool {{\n"));
|
||||||
|
old.push_str(&format!(" payload.len() < {i} + 16\n"));
|
||||||
|
new.push_str(&format!(
|
||||||
|
" payload.size() < {i} + 16 && !payload.is_empty()\n"
|
||||||
|
));
|
||||||
|
if i.is_multiple_of(5) {
|
||||||
|
old.push_str(&format!(" log::debug(\"handler {i} ran\");\n"));
|
||||||
|
}
|
||||||
|
if i.is_multiple_of(7) {
|
||||||
|
new.push_str(" metrics::count(\"handle\");\n");
|
||||||
|
}
|
||||||
|
old.push_str("}\n\n");
|
||||||
|
new.push_str("}\n\n");
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
(old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
diff: Entity<DiffViewState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().child(self.diff.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_a_large_diff_is_not_pathologically_slow() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(gpui_native_theme::Theme::init);
|
||||||
|
|
||||||
|
let (old, new) = large_diff_pair(5000);
|
||||||
|
let started = Instant::now();
|
||||||
|
let state = DiffViewState::new(
|
||||||
|
"bench-diff",
|
||||||
|
&old,
|
||||||
|
&new,
|
||||||
|
gpui_native_editor::highlight::Language::Rust,
|
||||||
|
);
|
||||||
|
let diff_elapsed = started.elapsed();
|
||||||
|
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<DiffViewState>>>> = Rc::new(RefCell::new(None));
|
||||||
|
let cell = state_cell.clone();
|
||||||
|
let window = cx.add_window(move |_, cx| {
|
||||||
|
let diff = cx.new(|_| state);
|
||||||
|
cell.borrow_mut().replace(diff.clone());
|
||||||
|
Host { diff }
|
||||||
|
});
|
||||||
|
let state = state_cell.borrow().clone().expect("state captured");
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let row_count = cx.update(|cx| state.read(cx).rows().len());
|
||||||
|
assert!(row_count > 5000, "changes add rows on top of the context");
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
for _ in 0..30 {
|
||||||
|
visual.simulate_event(ScrollEvent {
|
||||||
|
position: point(px(300.), px(300.)),
|
||||||
|
delta: ScrollDelta::Pixels(point(px(0.), px(-240.))),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let scroll_elapsed = started.elapsed();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"diffing and highlighting into {row_count} rows took {diff_elapsed:?}; 30 wheel frames took {scroll_elapsed:?} ({:?} per frame)",
|
||||||
|
scroll_elapsed / 30
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
diff_elapsed < Duration::from_secs(3),
|
||||||
|
"diffing took {diff_elapsed:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scroll_elapsed < Duration::from_secs(2),
|
||||||
|
"scrolling 30 frames took {scroll_elapsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-10
@@ -9,22 +9,17 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [ ] **Code view interactions** (M15, `editor`) — active line, text
|
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
||||||
selection with copy, incremental line re-highlight.
|
- [ ] API sketch
|
||||||
- [x] API sketch
|
- [ ] Implementation
|
||||||
- [x] Implementation
|
- [ ] Tests
|
||||||
- [x] Tests
|
- [ ] Gallery panel
|
||||||
- [x] Gallery panel
|
|
||||||
- [ ] Review
|
- [ ] Review
|
||||||
|
|
||||||
## Queue — to follow
|
## Queue — to follow
|
||||||
|
|
||||||
- [ ] **Diff view** (`editor`) — implemented, then reverted to
|
|
||||||
`stash@{0}` ("pre-revert: diff view + gutter tweaks"); restore with
|
|
||||||
`git stash pop` and re-land after the code view interactions settle.
|
|
||||||
- [ ] **Editor follow-ups** — language detection by file path, meta line
|
- [ ] **Editor follow-ups** — language detection by file path, meta line
|
||||||
overlay, per-view config overrides (line height, gutter visibility).
|
overlay, per-view config overrides (line height, gutter visibility).
|
||||||
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
|
||||||
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
||||||
unit time).
|
unit time).
|
||||||
- [ ] **Layout follow-ups** — persistence, pane zoom, bottom dock,
|
- [ ] **Layout follow-ups** — persistence, pane zoom, bottom dock,
|
||||||
@@ -77,6 +72,12 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
- [x] Code view — tree-sitter highlighted, virtualized rows with a gutter
|
- [x] Code view — tree-sitter highlighted, virtualized rows with a gutter
|
||||||
and the overlay scrollbar; per-language grammar features, roles map to
|
and the overlay scrollbar; per-language grammar features, roles map to
|
||||||
the theme's semantic palette at render
|
the theme's semantic palette at render
|
||||||
|
- [x] Code view interactions — active line, text selection with copy,
|
||||||
|
incremental line re-highlight
|
||||||
|
- [x] Diff view — unified rows via `similar`, highlighted with the code
|
||||||
|
view's palette; change washes from the ramps, `scroll_to_first_change`
|
||||||
|
- [x] Diff view — unified rows via `similar`, highlighted with the code
|
||||||
|
view's palette; change washes from the ramps, `scroll_to_first_change`
|
||||||
|
|
||||||
### layout (`crates/layout`)
|
### layout (`crates/layout`)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
|||||||
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
||||||
use gpui_native_data::tree::{Tree, TreeItem};
|
use gpui_native_data::tree::{Tree, TreeItem};
|
||||||
use gpui_native_editor::code_view::CodeViewState;
|
use gpui_native_editor::code_view::CodeViewState;
|
||||||
|
use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState};
|
||||||
use gpui_native_editor::highlight::Language;
|
use gpui_native_editor::highlight::Language;
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
@@ -84,7 +85,10 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("tabs", "Tabs"),
|
("tabs", "Tabs"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("Editor", &[("code-view", "Code view")]),
|
(
|
||||||
|
"Editor",
|
||||||
|
&[("code-view", "Code view"), ("diff-view", "Diff view")],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Feedback",
|
"Feedback",
|
||||||
&[
|
&[
|
||||||
@@ -632,6 +636,12 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("diff-view", "Diff view", |_, cx| {
|
||||||
|
cx.new(DiffViewDemo::new).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
||||||
cx.new(|_| SidebarDemo { selected: None }).into()
|
cx.new(|_| SidebarDemo { selected: None }).into()
|
||||||
@@ -2916,6 +2926,182 @@ impl Render for CodeViewDemo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The diff view demo's old version.
|
||||||
|
const DIFF_OLD: &str = r#"// The relay pool keeps every relay warm.
|
||||||
|
struct RelayPool {
|
||||||
|
relays: Vec<Relay>,
|
||||||
|
timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayPool {
|
||||||
|
fn new(timeout: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
relays: Vec::new(),
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, message: &str) -> bool {
|
||||||
|
message.len() < self.timeout as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// The diff view demo's new version.
|
||||||
|
const DIFF_NEW: &str = r#"// The relay pool keeps every relay warm and retrying.
|
||||||
|
struct RelayPool {
|
||||||
|
relays: Vec<Relay>,
|
||||||
|
timeout: u64,
|
||||||
|
retries: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayPool {
|
||||||
|
fn new(timeout: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
relays: Vec::new(),
|
||||||
|
timeout,
|
||||||
|
retries: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, message: &str) -> bool {
|
||||||
|
!message.is_empty() && message.len() < self.timeout as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct DiffViewDemo {
|
||||||
|
diff: Entity<DiffViewState>,
|
||||||
|
scenario: DiffScenario,
|
||||||
|
large_old: SharedString,
|
||||||
|
large_new: SharedString,
|
||||||
|
_subscription: gpui::Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diff view demo's scenario choice.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
enum DiffScenario {
|
||||||
|
#[default]
|
||||||
|
Small,
|
||||||
|
/// Thousands of rows with changes sprinkled through, to test scroll
|
||||||
|
/// performance.
|
||||||
|
Large,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line count of each version in the large-diff sample.
|
||||||
|
const LARGE_DIFF_LINES: usize = 5000;
|
||||||
|
|
||||||
|
/// Generates a matched old/new pair of roughly `lines` lines each, with a
|
||||||
|
/// change every few lines so the diff has spread-out hunks.
|
||||||
|
fn large_diff_pair(lines: usize) -> (String, String) {
|
||||||
|
let mut old = String::with_capacity(lines * 48);
|
||||||
|
let mut new = String::with_capacity(lines * 48);
|
||||||
|
let mut index = 0usize;
|
||||||
|
while old.lines().count() < lines {
|
||||||
|
let i = index;
|
||||||
|
let comment = format!("// Handler {i} in the generated dispatch table.\n");
|
||||||
|
old.push_str(&comment);
|
||||||
|
new.push_str(&comment);
|
||||||
|
old.push_str(&format!("fn handle_{i}(payload: &str) -> bool {{\n"));
|
||||||
|
new.push_str(&format!("fn handle_{i}(payload: &Payload) -> bool {{\n"));
|
||||||
|
old.push_str(&format!(" payload.len() < {i} + 16\n"));
|
||||||
|
new.push_str(&format!(
|
||||||
|
" payload.size() < {i} + 16 && !payload.is_empty()\n"
|
||||||
|
));
|
||||||
|
if i.is_multiple_of(5) {
|
||||||
|
old.push_str(&format!(" log::debug(\"handler {i} ran\");\n"));
|
||||||
|
}
|
||||||
|
if i.is_multiple_of(7) {
|
||||||
|
new.push_str(" metrics::count(\"handle\");\n");
|
||||||
|
}
|
||||||
|
old.push_str("}\n\n");
|
||||||
|
new.push_str("}\n\n");
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
(old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiffViewDemo {
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let diff =
|
||||||
|
cx.new(|_| DiffViewState::new("diff-view-demo", DIFF_OLD, DIFF_NEW, Language::Rust));
|
||||||
|
// The panel is a cached view in the dock: it re-renders only when
|
||||||
|
// the diff state it renders is notified.
|
||||||
|
let subscription = cx.observe(&diff, |_, _, cx| cx.notify());
|
||||||
|
let (large_old, large_new) = large_diff_pair(LARGE_DIFF_LINES);
|
||||||
|
Self {
|
||||||
|
diff,
|
||||||
|
scenario: DiffScenario::Small,
|
||||||
|
large_old: large_old.into(),
|
||||||
|
large_new: large_new.into(),
|
||||||
|
_subscription: subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for DiffViewDemo {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors;
|
||||||
|
let radius = cx.theme().radius;
|
||||||
|
let view = cx.entity();
|
||||||
|
let rows = self.diff.read(cx).rows();
|
||||||
|
let added = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.kind == DiffRowKind::Added)
|
||||||
|
.count();
|
||||||
|
let removed = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.kind == DiffRowKind::Removed)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
ScrollArea::new("diff-view-demo").size_full().p_4().child(
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(demo_section(
|
||||||
|
"scenario",
|
||||||
|
colors,
|
||||||
|
Segmented::new("diff-view-demo-scenario", self.scenario)
|
||||||
|
.options(vec![(DiffScenario::Small, "Small"), (DiffScenario::Large, "Large file")])
|
||||||
|
.on_change(move |next, _, cx| {
|
||||||
|
view.update(cx, |demo, cx| {
|
||||||
|
demo.scenario = next;
|
||||||
|
let (old, new) = match next {
|
||||||
|
DiffScenario::Small => (DIFF_OLD.into(), DIFF_NEW.into()),
|
||||||
|
DiffScenario::Large => (
|
||||||
|
demo.large_old.clone(),
|
||||||
|
demo.large_new.clone(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
demo.diff.update(cx, |diff, cx| {
|
||||||
|
diff.set_versions(old, new, Language::Rust, cx)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"unified, highlighted, virtualized",
|
||||||
|
colors,
|
||||||
|
div()
|
||||||
|
.h(px(480.))
|
||||||
|
.rounded(radius.control)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.diff.clone()),
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!(
|
||||||
|
"+{added} −{removed} across {} rows — long lines scroll horizontally, scroll to test performance",
|
||||||
|
rows.len()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SidebarDemo {
|
struct SidebarDemo {
|
||||||
selected: Option<usize>,
|
selected: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user