From 7214c70ea74efaee9ac740f962863ab998ac2a41 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 08:01:23 +0700 Subject: [PATCH] feat: add diff view component --- Cargo.lock | 7 + crates/core/src/scroll_area.rs | 26 +- crates/core/src/scrollbar.rs | 37 +-- crates/editor/Cargo.toml | 3 + crates/editor/src/code_view.rs | 6 +- crates/editor/src/diff_view.rs | 353 +++++++++++++++++++++++++ crates/editor/src/lib.rs | 3 + crates/editor/tests/diff_view.rs | 349 ++++++++++++++++++++++++ crates/editor/tests/diff_view_bench.rs | 109 ++++++++ docs/CHECKLIST.md | 21 +- examples/gallery/src/main.rs | 188 ++++++++++++- 11 files changed, 1067 insertions(+), 35 deletions(-) create mode 100644 crates/editor/src/diff_view.rs create mode 100644 crates/editor/tests/diff_view.rs create mode 100644 crates/editor/tests/diff_view_bench.rs diff --git a/Cargo.lock b/Cargo.lock index 0473252..483cb6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1914,6 +1914,7 @@ dependencies = [ "gpui-native-core", "gpui-native-theme", "gpui-native-utils", + "similar", "tree-sitter", "tree-sitter-javascript", "tree-sitter-python", @@ -5205,6 +5206,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simplecss" version = "0.2.2" diff --git a/crates/core/src/scroll_area.rs b/crates/core/src/scroll_area.rs index fee3574..afad100 100644 --- a/crates/core/src/scroll_area.rs +++ b/crates/core/src/scroll_area.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -54,9 +55,9 @@ struct AutohideState { } 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 state = window.use_keyed_state("scrollbar-autohide", cx, |_, _| AutohideState { + let state = window.use_keyed_state(key, cx, |_, _| AutohideState { last_activity: 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. pub(crate) fn thumb_geometry( viewport: Pixels, @@ -247,11 +253,12 @@ impl RenderOnce for ScrollArea { ScrollbarVisibility::Always => false, ScrollbarVisibility::Autohide => true, }; + let id = self.id.clone(); wrapper.extend(std::iter::once( canvas(|_, _, _| {}, { let handle = handle.clone(); move |_, _, window, cx| { - paint_scrollbar(&handle, autohide, window, cx); + paint_scrollbar(&handle, &id, autohide, window, cx); } }) .absolute() @@ -265,7 +272,13 @@ impl RenderOnce for ScrollArea { } /// 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 view = window.current_view(); @@ -281,8 +294,9 @@ fn paint_scrollbar(handle: &ScrollHandle, autohide: bool, window: &mut Window, c return; } - let autohide_state = autohide.then(|| Autohide::new(window, cx)); - let drag = window.use_keyed_state("scrollbar-thumb-drag", cx, |_, _| { + let autohide_state = + 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 }); let dragging = drag.read(cx).is_some(); diff --git a/crates/core/src/scrollbar.rs b/crates/core/src/scrollbar.rs index c5a2415..d63d77c 100644 --- a/crates/core/src/scrollbar.rs +++ b/crates/core/src/scrollbar.rs @@ -1,23 +1,15 @@ -//! Overlay scrollbars for scroll surfaces, including gpui's virtualized -//! `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)) -//! ``` +//! Overlay scrollbars for scroll surfaces. use std::rc::Rc; use gpui::{ - App, Bounds, DispatchPhase, IntoElement, ListState, MouseButton, MouseDownEvent, + App, Bounds, DispatchPhase, ElementId, IntoElement, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle, Styled as _, UniformListScrollHandle, Window, canvas, fill, point, }; 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. 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)] pub struct Scrollbar { axis: Axis, handle: Rc, visibility: ScrollbarVisibility, + key: ElementId, } impl Scrollbar { @@ -148,6 +141,7 @@ impl Scrollbar { axis: Axis::Vertical, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), + key: ElementId::Name("scrollbar".into()), } } @@ -160,6 +154,7 @@ impl Scrollbar { axis: Axis::Horizontal, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), + key: ElementId::Name("scrollbar".into()), } } @@ -168,6 +163,12 @@ impl Scrollbar { self.visibility = visibility; self } + + /// Names this scrollbar's state. + pub fn key(mut self, key: impl Into) -> Self { + self.key = key.into(); + self + } } impl RenderOnce for Scrollbar { @@ -176,7 +177,8 @@ impl RenderOnce for Scrollbar { let handle = self.handle; let axis = self.axis; 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() .inset_0() @@ -190,6 +192,7 @@ fn paint_scrollbar( handle: &Rc, axis: Axis, visibility: ScrollbarVisibility, + key: &ElementId, window: &mut Window, cx: &mut App, ) { @@ -214,7 +217,9 @@ fn paint_scrollbar( ScrollbarVisibility::Always => false, 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; @@ -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 alpha = match &autohide_state { diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index e8f26fa..a7326fc 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -10,6 +10,7 @@ gpui-native-core = { workspace = true, optional = true } gpui-native-theme = { workspace = true } gpui-native-utils = { workspace = true } tree-sitter = "0.25" +similar = "2" tree-sitter-rust = { version = "0.24", optional = true } tree-sitter-typescript = { 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"] full = [ "code_view", + "diff_view", "rust", "typescript", "javascript", @@ -32,6 +34,7 @@ code_view = [ "gpui-native-core/scroll_area", "gpui-native-core/scrollbar", ] +diff_view = ["code_view"] rust = ["dep:tree-sitter-rust"] typescript = ["dep:tree-sitter-typescript"] javascript = ["dep:tree-sitter-javascript"] diff --git a/crates/editor/src/code_view.rs b/crates/editor/src/code_view.rs index 9b99242..2b0a118 100644 --- a/crates/editor/src/code_view.rs +++ b/crates/editor/src/code_view.rs @@ -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::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( +pub(crate) fn gutter_cell( 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, diff --git a/crates/editor/src/diff_view.rs b/crates/editor/src/diff_view.rs new file mode 100644 index 0000000..4916f31 --- /dev/null +++ b/crates/editor/src/diff_view.rs @@ -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, + /// 1-based line number in the new version, when the row exists there. + pub new_number: Option, +} + +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 { + 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, + /// Drives both scroll axes of the virtualized row list. + handle: UniformListScrollHandle, + a11y_label: Option, +} + +impl DiffViewState { + /// Builds the state, diffing `old` against `new`. + pub fn new(id: I, old: O, new: N, language: Language) -> Self + where + I: Into, + O: AsRef, + N: AsRef, + { + 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(&mut self, old: O, new: N, language: Language, cx: &mut Context) + where + O: AsRef, + N: AsRef, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +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 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, 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 + } +} diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 14b5f25..891bd52 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -5,4 +5,7 @@ #[cfg(feature = "code_view")] pub mod code_view; +#[cfg(feature = "diff_view")] +pub mod diff_view; + pub mod highlight; diff --git a/crates/editor/tests/diff_view.rs b/crates/editor/tests/diff_view.rs new file mode 100644 index 0000000..10e454a --- /dev/null +++ b/crates/editor/tests/diff_view.rs @@ -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 = 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, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.diff.clone()) + } +} + +fn diff_view_window( + rows: usize, + changes_at: 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 { + 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>>> = 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>>> = 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>>> = 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>>> = 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" + ); +} diff --git a/crates/editor/tests/diff_view_bench.rs b/crates/editor/tests/diff_view_bench.rs new file mode 100644 index 0000000..c086b1e --- /dev/null +++ b/crates/editor/tests/diff_view_bench.rs @@ -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, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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>>> = 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:?}" + ); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 4977406..af7d752 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,22 +9,17 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Code view interactions** (M15, `editor`) — active line, text - selection with copy, incremental line re-highlight. - - [x] API sketch - - [x] Implementation - - [x] Tests - - [x] Gallery panel +- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms. + - [ ] API sketch + - [ ] Implementation + - [ ] Tests + - [ ] Gallery panel - [ ] Review ## 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 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 unit time). - [ ] **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 and the overlay scrollbar; per-language grammar features, roles map to 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`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 394ccfe..927073d 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -41,6 +41,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_editor::code_view::CodeViewState; +use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState}; use gpui_native_editor::highlight::Language; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection}; @@ -84,7 +85,10 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("tabs", "Tabs"), ], ), - ("Editor", &[("code-view", "Code view")]), + ( + "Editor", + &[("code-view", "Code view"), ("diff-view", "Diff view")], + ), ( "Feedback", &[ @@ -632,6 +636,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("diff-view", "Diff view", |_, cx| { + cx.new(DiffViewDemo::new).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("sidebar", "Sidebar", |_, cx| { 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, + 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, + 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, + 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 { + 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) -> 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 { selected: Option, }