From 19b0b2997a3e55493e4cd43680c2d80b6397bec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 00:14:41 -0400 Subject: [PATCH] =?UTF-8?q?Git=20=C2=B7=20DiffViewer:=20stretches=20made?= =?UTF-8?q?=20elsewhere,=20a=20hunk=20button,=20pickable=20changed=20lines?= =?UTF-8?q?,=20words=20marked=20on=20built=20lines;=20its=20words=20read?= =?UTF-8?q?=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git/diff.rs | 98 ++++++++++++++--- src/git/mod.rs | 2 +- src/git/tests.rs | 64 +++++++++++ src/git/viewer.rs | 252 +++++++++++++++++++++++++++++++++++--------- src/i18n/builtin.rs | 8 ++ 5 files changed, 357 insertions(+), 67 deletions(-) diff --git a/src/git/diff.rs b/src/git/diff.rs index 057b110..7c2c73f 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -124,33 +124,79 @@ pub fn stat(old: &str, new: &str) -> (usize, usize) { }) } -/// A hunk's lines side by side: unchanged lines on both sides, each run of removals beside the additions after it. -pub fn pairs(lines: &[DiffLine]) -> Vec<(Option<&DiffLine>, Option<&DiffLine>)> { +/// A hunk's rows side by side, as places in `lines`. +pub(crate) fn pair_places(lines: &[DiffLine]) -> Vec<(Option, Option)> { let mut out = Vec::new(); let mut ix = 0; while ix < lines.len() { if lines[ix].kind == LineKind::Same { - out.push((Some(&lines[ix]), Some(&lines[ix]))); + out.push((Some(ix), Some(ix))); ix += 1; continue; } - let removed: Vec<&DiffLine> = lines[ix..] - .iter() - .take_while(|line| line.kind == LineKind::Removed) - .collect(); - ix += removed.len(); - let added: Vec<&DiffLine> = lines[ix..] - .iter() - .take_while(|line| line.kind == LineKind::Added) - .collect(); - ix += added.len(); + let run = |from: usize, kind| { + from..from + + lines[from..] + .iter() + .take_while(|line| line.kind == kind) + .count() + }; + let removed = run(ix, LineKind::Removed); + let added = run(removed.end, LineKind::Added); + ix = added.end; for row in 0..removed.len().max(added.len()) { - out.push((removed.get(row).copied(), added.get(row).copied())); + let at = |range: &Range| (row < range.len()).then(|| range.start + row); + out.push((at(&removed), at(&added))); } } out } +/// A hunk's lines side by side: unchanged lines on both sides, each run of removals beside the additions after it. +pub fn pairs(lines: &[DiffLine]) -> Vec<(Option<&DiffLine>, Option<&DiffLine>)> { + pair_places(lines) + .into_iter() + .map(|(left, right)| (left.map(|ix| &lines[ix]), right.map(|ix| &lines[ix]))) + .collect() +} + +/// Marks the words each removal and the addition beside it changed. +pub fn mark_words(lines: &mut [DiffLine]) { + for (left, right) in pair_places(lines) { + let (Some(left), Some(right)) = (left, right) else { + continue; + }; + if left == right { + continue; + } + let (old, new) = (lines[left].text.clone(), lines[right].text.clone()); + let diff = TextDiff::from_unicode_words(old.as_ref(), new.as_ref()); + let (mut at, mut words, mut kept) = ((0, 0), (Vec::new(), Vec::new()), false); + for change in diff.iter_all_changes() { + let (value, len) = (change.value(), change.value().len()); + let real = !value.trim().is_empty(); + match change.tag() { + ChangeTag::Equal => { + kept |= real; + at = (at.0 + len, at.1 + len); + } + ChangeTag::Delete => { + words.0.extend(real.then(|| at.0..at.0 + len)); + at.0 += len; + } + ChangeTag::Insert => { + words.1.extend(real.then(|| at.1..at.1 + len)); + at.1 += len; + } + } + } + // Lines with nothing in common mark no words. + if kept { + (lines[left].words, lines[right].words) = words; + } + } +} + /// The numbers a side-by-side row shows: the old file's on the left, the new file's on the right. pub(crate) fn side_numbers( left: Option<&DiffLine>, @@ -220,6 +266,30 @@ mod tests { assert_eq!(sides, [(true, true), (true, true), (false, true)]); } + #[test] + fn words_are_marked_on_lines_built_elsewhere() { + let line = |kind, text: &str| DiffLine { + kind, + old: None, + new: None, + text: text.to_string().into(), + words: Vec::new(), + }; + let mut lines = vec![ + line(LineKind::Removed, "let x = 1;"), + line(LineKind::Removed, "abc"), + line(LineKind::Added, "let x = 2;"), + line(LineKind::Added, "xyz"), + ]; + mark_words(&mut lines); + assert_eq!(&lines[0].text[lines[0].words[0].clone()], "1"); + assert_eq!(&lines[2].text[lines[2].words[0].clone()], "2"); + assert!( + lines[1].words.is_empty() && lines[3].words.is_empty(), + "nothing shared" + ); + } + #[test] fn each_side_numbers_its_own_file() { let stretches = diff("a\nb\n", "x\na\nb\n", 3); diff --git a/src/git/mod.rs b/src/git/mod.rs index 2b6034a..63f4a5f 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -18,7 +18,7 @@ pub use badges::{DiffStat, GitStatusBadge}; pub use blame::{Blame, BlameView, GitBlameAnnotation}; pub use changes::{ChangeAction, Changed, ChangesList, CommitInput}; pub use commits::{Commit, CommitItem, CommitList}; -pub use diff::{DiffLine, LineKind, Stretch, diff, pairs, stat}; +pub use diff::{DiffLine, LineKind, Stretch, diff, mark_words, pairs, stat}; pub use graph::{GraphRow, Half, Stroke, lanes}; pub use history::FileHistory; pub use merge::{ConflictResolver, ThreeWayMerge}; diff --git a/src/git/tests.rs b/src/git/tests.rs index f8fa8f3..1213312 100644 --- a/src/git/tests.rs +++ b/src/git/tests.rs @@ -109,3 +109,67 @@ fn every_section_draws_with_its_own_words(cx: &mut TestAppContext) { let (_, cx) = cx.add_window_view(|_, _| Sorted); settle(cx); } + +/// Lines and hunks pressed, from stretches made elsewhere. +#[derive(Default)] +struct Picking { + lines: Vec<((usize, usize), bool)>, + hunks: Vec, +} + +impl Render for Picking { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (lines, hunks) = (cx.entity(), cx.entity()); + let stretches = std::rc::Rc::new(super::diff("a\nb\n", "a\nc\n", 1)); + div().w(px(480.0)).h(px(320.0)).child( + DiffViewer::from_stretches("picking", "a.txt", stretches) + .selected([(0, 2)]) + .on_line(move |place, shift, _, cx| { + lines.update(cx, |picking, _| picking.lines.push((place, shift))) + }) + .hunk_action("Stage", move |hunk, _, cx| { + hunks.update(cx, |picking, _| picking.hunks.push(hunk)) + }), + ) + } +} + +#[gpui::test] +fn changed_lines_and_hunks_answer_presses(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|_, _| Picking::default()); + cx.update(|window, _| window.activate_window()); + settle(cx); + let press = |cx: &mut VisualTestContext, key: &str| { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); + }; + // Past the two layout segments: the hunk's button, then its lines. + let next = |cx: &mut VisualTestContext| { + cx.update(|window, cx| window.focus_next(cx)); + settle(cx); + }; + (0..3).for_each(|_| next(cx)); + press(cx, "enter"); + next(cx); + press(cx, "enter"); + next(cx); + press(cx, "enter"); + let (lines, hunks) = view.read_with(cx, |picking, _| { + (picking.lines.clone(), picking.hunks.clone()) + }); + assert_eq!(hunks, [0], "the hunk's button"); + assert_eq!( + lines, + [((0, 1), false), ((0, 2), false)], + "removed, then added" + ); +} diff --git a/src/git/viewer.rs b/src/git/viewer.rs index b4246f6..8977cc1 100644 --- a/src/git/viewer.rs +++ b/src/git/viewer.rs @@ -2,20 +2,21 @@ use std::{ops::Range, rc::Rc}; use gpui::{ AnyElement, App, ElementId, FontWeight, HighlightStyle, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + MouseButton, ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, relative, transparent_black, uniform_list, }; use super::{ badges::DiffStat, - diff::{DiffLine, LineKind, Stretch, diff, pairs, side_numbers}, + diff::{DiffLine, LineKind, Stretch, diff, pair_places, side_numbers}, }; use crate::{ - buttons::SegmentedControl, + buttons::{Button, ButtonVariant, SegmentedControl}, editor::{code_colors, stack}, + i18n, primitives::FocusRing, theme::{ActiveTheme, ControlSize, Palette, TextSize}, - typography::{LEADING, format}, + typography::LEADING, }; /// How a diff lays out. @@ -28,12 +29,16 @@ pub enum DiffLayout { /// Unchanged lines kept around each change. const CONTEXT: usize = 3; +/// A line's place: its stretch, then its line there. +pub type Place = (usize, usize); + /// A row as the viewer draws it. #[derive(Clone)] enum Shown { - Header(SharedString), - Line(DiffLine), - Pair(Option, Option), + /// A hunk's header and its place among the stretches. + Header(usize, SharedString), + Line(Place, DiffLine), + Pair(Option<(Place, DiffLine)>, Option<(Place, DiffLine)>), /// A folded stretch: its place among the stretches and how many lines it hides. Fold(usize, usize), } @@ -43,20 +48,29 @@ fn shown(stretches: &[Stretch], layout: DiffLayout, open: &[usize]) -> Vec { - rows.push(Shown::Header(header.clone())); + rows.push(Shown::Header(ix, header.clone())); + let at = |line: usize| ((ix, line), lines[line].clone()); match layout { - DiffLayout::Unified => rows.extend(lines.iter().cloned().map(Shown::Line)), + DiffLayout::Unified => rows.extend((0..lines.len()).map(|line| { + let (place, line) = at(line); + Shown::Line(place, line) + })), DiffLayout::Split => rows.extend( - pairs(lines) + pair_places(lines) .into_iter() - .map(|(left, right)| Shown::Pair(left.cloned(), right.cloned())), + .map(|(left, right)| Shown::Pair(left.map(at), right.map(at))), ), } } Stretch::Folded(lines) if open.contains(&ix) => { - rows.extend(lines.iter().map(|line| match layout { - DiffLayout::Unified => Shown::Line(line.clone()), - DiffLayout::Split => Shown::Pair(Some(line.clone()), Some(line.clone())), + rows.extend(lines.iter().enumerate().map(|(at, line)| { + let place = (ix, at); + match layout { + DiffLayout::Unified => Shown::Line(place, line.clone()), + DiffLayout::Split => { + Shown::Pair(Some((place, line.clone())), Some((place, line.clone()))) + } + } })) } Stretch::Folded(lines) => rows.push(Shown::Fold(ix, lines.len())), @@ -67,6 +81,7 @@ fn shown(stretches: &[Stretch], layout: DiffLayout, open: &[usize]) -> Vec; type OnLayout = Rc; +type OnLine = Rc; /// One file's changes: each change in its context with old and new numbers and its changed words marked, the stretches between folded until pressed; unified or side by side. Long diffs draw only the rows in view; it fills its box. #[derive(IntoElement)] @@ -80,6 +95,9 @@ pub struct DiffViewer { on_open: Option, on_layout: Option, headless: bool, + selected: Rc>, + on_line: Option, + hunk_action: Option<(SharedString, OnIndex)>, } impl DiffViewer { @@ -99,9 +117,62 @@ impl DiffViewer { on_open: None, on_layout: None, headless: false, + selected: Rc::default(), + on_line: None, + hunk_action: None, } } + /// Stretches made elsewhere, such as from Git's own diff. + pub fn from_stretches( + id: impl Into, + path: impl Into, + stretches: Rc>, + ) -> Self { + let stat = stretches + .iter() + .filter_map(|stretch| match stretch { + Stretch::Hunk { lines, .. } => Some(lines), + Stretch::Folded(_) => None, + }) + .flatten() + .fold((0, 0), |(added, removed), line| match line.kind { + LineKind::Added => (added + 1, removed), + LineKind::Removed => (added, removed + 1), + LineKind::Same => (added, removed), + }); + Self { + stretches, + stat, + ..Self::new(id, path, "", "") + } + } + + /// Changed lines shown as picked. + pub fn selected(mut self, places: impl IntoIterator) -> Self { + self.selected = Rc::new(places.into_iter().collect()); + self + } + + /// Makes changed lines pressable; true when Shift was held. + pub fn on_line( + mut self, + handler: impl Fn(Place, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_line = Some(Rc::new(handler)); + self + } + + /// A button on each hunk's header, given the hunk's place. + pub fn hunk_action( + mut self, + label: impl Into, + handler: impl Fn(usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.hunk_action = Some((label.into(), Rc::new(handler))); + self + } + /// Leaves the file's header, its path, counts and layout, to the owner, as a change card draws it. pub fn headless(mut self) -> Self { self.headless = true; @@ -178,8 +249,8 @@ impl RenderOnce for DiffViewer { }, ) .size(ControlSize::Sm) - .segment("unified", "Unified", None) - .segment("split", "Split", None); + .segment("unified", i18n::text(cx, "git.diff.unified", &[]), None) + .segment("split", i18n::text(cx, "git.diff.split", &[]), None); let layout = match self.on_layout { Some(on_layout) => layout.on_change(move |key, window, cx| { let next = if key.as_ref() == "split" { @@ -205,6 +276,11 @@ impl RenderOnce for DiffViewer { .len() .max(2); let (id, on_open) = (self.id.clone(), self.on_open.clone()); + let (selected, on_line, hunk_action) = ( + self.selected.clone(), + self.on_line.clone(), + self.hunk_action.clone(), + ); let list = uniform_list( (self.id.clone(), "rows"), rows.len(), @@ -218,29 +294,72 @@ impl RenderOnce for DiffViewer { .text_color(colors.fg_subtle) .children(value.map(|value| value.to_string())) }; - let side = - |line: Option<&DiffLine>, shown: Option, cx: &App| -> AnyElement { - let (wash, _) = line - .map_or((Some(colors.hover.opacity(0.5)), colors.hover), |line| { - washes(line.kind, &colors) - }); - div() - .flex_1() - .min_w_0() - .flex() - .gap_2() - .overflow_hidden() - .when_some(wash, |side, wash| side.bg(wash)) - .child(number(shown)) - .children(line.map(|line| { - div().whitespace_nowrap().child(code(line, &colors, cx)) - })) - .into_any_element() - }; + // Changed lines toggle when the owner picks lines. + let pick = |element: gpui::Div, + own: ElementId, + place: Place, + line: &DiffLine, + cx: &App| + -> gpui::Stateful { + let picked = selected.contains(&place); + let element = element.when(picked, |element| element.bg(colors.selection)); + match (&on_line, line.kind) { + (Some(on_line), LineKind::Added | LineKind::Removed) => { + let on_line = on_line.clone(); + let sign = if line.kind == LineKind::Added { + "+" + } else { + "−" + }; + element + .id((id.clone(), format!("line-{}-{}", place.0, place.1))) + .role(Role::ListItem) + .aria_label(SharedString::from(format!("{sign} {}", line.text))) + .aria_selected(picked) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |event, window, cx| { + on_line(place, event.modifiers().shift, window, cx) + }) + } + _ => element.id(own), + } + }; + let side = |line: Option<&(Place, DiffLine)>, + shown: Option, + at: &str, + cx: &App| + -> AnyElement { + let (wash, _) = line.map_or( + (Some(colors.hover.opacity(0.5)), colors.hover), + |(_, line)| washes(line.kind, &colors), + ); + let own = ElementId::from(SharedString::from(at.to_string())); + let element = div() + .flex_1() + .min_w_0() + .flex() + .gap_2() + .overflow_hidden() + .when_some(wash, |side, wash| side.bg(wash)) + .child(number(shown)) + .children(line.map(|(_, line)| { + div().whitespace_nowrap().child(code(line, &colors, cx)) + })); + match line { + Some((place, line)) => { + pick(element, own, *place, line, cx).into_any_element() + } + None => element.id(own).into_any_element(), + } + }; range .map(|ix| { let row = div() - .id(ix) .w_full() .flex() .px_2() @@ -249,20 +368,31 @@ impl RenderOnce for DiffViewer { .whitespace_nowrap() .line_height(relative(LEADING)); match &rows[ix] { - Shown::Header(header) => row - .bg(colors.hover) - .text_color(colors.fg_muted) - .child(header.clone()) - .into_any_element(), - Shown::Line(line) => { + Shown::Header(stretch, header) => { + let action = hunk_action.clone().map(|(label, run)| { + let stretch = *stretch; + Button::new((id.clone(), format!("hunk-{stretch}")), label) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| run(stretch, window, cx)) + }); + row.id(ix) + .bg(colors.hover) + .items_center() + .text_color(colors.fg_muted) + .child(div().flex_1().min_w_0().child(header.clone())) + .children(action) + .into_any_element() + } + Shown::Line(place, line) => { let (wash, _) = washes(line.kind, &colors); let sign = match line.kind { LineKind::Same => " ", LineKind::Added => "+", LineKind::Removed => "−", }; - row.gap_2() - .when_some(wash, |row, wash| row.bg(wash)) + let row = row.gap_2().when_some(wash, |row, wash| row.bg(wash)); + pick(row, ElementId::from(ix), *place, line, cx) .child(number(line.old)) .child(number(line.new)) .child( @@ -278,10 +408,14 @@ impl RenderOnce for DiffViewer { .into_any_element() } Shown::Pair(left, right) => { - let (old, new) = side_numbers(left.as_ref(), right.as_ref()); - row.gap_1() - .child(side(left.as_ref(), old, cx)) - .child(side(right.as_ref(), new, cx)) + let (old, new) = side_numbers( + left.as_ref().map(|(_, line)| line), + right.as_ref().map(|(_, line)| line), + ); + row.id(ix) + .gap_1() + .child(side(left.as_ref(), old, &format!("left-{ix}"), cx)) + .child(side(right.as_ref(), new, &format!("right-{ix}"), cx)) .into_any_element() } Shown::Fold(stretch, count) => { @@ -304,10 +438,14 @@ impl RenderOnce for DiffViewer { }) .child(format!( "⋯ {}", - format::plural( - *count as u64, - "unchanged line", - "unchanged lines" + i18n::text( + cx, + if *count == 1 { + "git.diff.unchanged.one" + } else { + "git.diff.unchanged.other" + }, + &[("n", &count.to_string())], ) )) .into_any_element() @@ -355,3 +493,13 @@ impl RenderOnce for DiffViewer { ) } } + +#[cfg(test)] +mod tests { + #[test] + fn stretches_made_elsewhere_count_their_lines() { + let stretches = std::rc::Rc::new(super::diff("a\nb\nc\n", "a\nx\ny\nc\n", 1)); + let viewer = super::DiffViewer::from_stretches("counted", "a.txt", stretches); + assert_eq!(viewer.stat, (2, 1)); + } +} diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 2eb86b5..1a0ddfb 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -78,6 +78,10 @@ const EN: &[(&str, &str)] = &[ ("git.changes.unstage", "Unstage"), ("git.changes.stage_all", "Stage all"), ("git.changes.unstage_all", "Unstage all"), + ("git.diff.unified", "Unified"), + ("git.diff.split", "Split"), + ("git.diff.unchanged.one", "{n} unchanged line"), + ("git.diff.unchanged.other", "{n} unchanged lines"), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -154,6 +158,10 @@ const ZH_CN: &[(&str, &str)] = &[ ("git.changes.unstage", "取消暂存"), ("git.changes.stage_all", "全部暂存"), ("git.changes.unstage_all", "全部取消暂存"), + ("git.diff.unified", "行内"), + ("git.diff.split", "并排"), + ("git.diff.unchanged.one", "{n} 行未改动"), + ("git.diff.unchanged.other", "{n} 行未改动"), ]; /// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.