From 389a20471ab975eaa082ff4378159450fc38a9c0 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 4 Feb 2026 00:04:04 -0500 Subject: [PATCH] git: Add support for staging/unstaging/restoring to side-by-side diff LHS, and render hunk controls there (#48320) Release Notes: - N/A --- crates/editor/src/display_map.rs | 4 ++ crates/editor/src/editor.rs | 63 +++++++++++++++++++++++++++----- crates/editor/src/element.rs | 37 ++++++++++--------- crates/editor/src/split.rs | 58 ++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 29 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 0f83c0c518de998158b618b046f8ef49e84677cc..8cd74917481efadcc84c643c457e3602a7fe0161 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -306,6 +306,10 @@ impl Companion { } } + pub(crate) fn lhs_to_rhs_buffer(&self, lhs_buffer_id: BufferId) -> Option { + self.lhs_buffer_to_rhs_buffer.get(&lhs_buffer_id).copied() + } + pub(crate) fn add_buffer_mapping(&mut self, lhs_buffer: BufferId, rhs_buffer: BufferId) { self.lhs_buffer_to_rhs_buffer.insert(lhs_buffer, rhs_buffer); self.rhs_buffer_to_lhs_buffer.insert(rhs_buffer, lhs_buffer); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b0dae69e82bdfae79d58817e165aad80002044b0..354c4b896b3ca51c6d9321e17f277d333f1b399e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1176,6 +1176,7 @@ pub struct Editor { offset_content: bool, disable_expand_excerpt_buttons: bool, delegate_expand_excerpts: bool, + delegate_stage_and_restore: bool, show_line_numbers: Option, use_relative_line_numbers: Option, show_git_diff_gutter: Option, @@ -2374,6 +2375,7 @@ impl Editor { use_relative_line_numbers: None, disable_expand_excerpt_buttons: !full_mode, delegate_expand_excerpts: false, + delegate_stage_and_restore: false, show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, @@ -11418,12 +11420,25 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + if self.delegate_stage_and_restore { + let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); + if !hunks.is_empty() { + cx.emit(EditorEvent::RestoreRequested { hunks }); + } + return; + } + let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); + self.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.refresh() + }); + }); + } + + pub(crate) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) { let mut revert_changes = HashMap::default(); - let chunk_by = self - .snapshot(window, cx) - .hunks_for_ranges(ranges) - .into_iter() - .chunk_by(|hunk| hunk.buffer_id); + let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); for (buffer_id, hunks) in &chunk_by { let hunks = hunks.collect::>(); for hunk in &hunks { @@ -11431,10 +11446,21 @@ impl Editor { } self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); } - drop(chunk_by); if !revert_changes.is_empty() { - self.transact(window, cx, |editor, window, cx| { - editor.restore(revert_changes, window, cx); + self.buffer().update(cx, |multi_buffer, cx| { + for (buffer_id, changes) in revert_changes { + if let Some(buffer) = multi_buffer.buffer(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.edit( + changes + .into_iter() + .map(|(range, text)| (range, text.to_string())), + None, + cx, + ); + }); + } + } }); } } @@ -20344,6 +20370,14 @@ impl Editor { ranges: Vec>, cx: &mut Context, ) { + if self.delegate_stage_and_restore { + let snapshot = self.buffer.read(cx).snapshot(cx); + let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + if !hunks.is_empty() { + cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); + } + return; + } let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); cx.spawn(async move |this, cx| { task.await?; @@ -20439,7 +20473,7 @@ impl Editor { } } - fn do_stage_or_unstage( + pub(crate) fn do_stage_or_unstage( &self, stage: bool, buffer_id: BufferId, @@ -21111,6 +21145,10 @@ impl Editor { self.delegate_expand_excerpts = delegate; } + pub fn set_delegate_stage_and_restore(&mut self, delegate: bool) { + self.delegate_stage_and_restore = delegate; + } + pub fn set_on_local_selections_changed( &mut self, callback: Option) + 'static>>, @@ -27360,6 +27398,13 @@ pub enum EditorEvent { lines: u32, direction: ExpandExcerptDirection, }, + StageOrUnstageRequested { + stage: bool, + hunks: Vec, + }, + RestoreRequested { + hunks: Vec, + }, BufferEdited, Edited { transaction_id: clock::Lamport, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index beb2ccc6c36f670584ba78e326331e1b74d4adc7..d379319a5e6482b956c2452400c29e3d088659fb 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -10716,24 +10716,25 @@ impl Element for EditorElement { let mode = snapshot.mode.clone(); - let (diff_hunk_controls, diff_hunk_control_bounds) = if is_read_only { - (vec![], vec![]) - } else { - self.layout_diff_hunk_controls( - start_row..end_row, - &row_infos, - &text_hitbox, - newest_selection_head, - line_height, - right_margin, - scroll_pixel_position, - &display_hunks, - &highlighted_rows, - self.editor.clone(), - window, - cx, - ) - }; + let (diff_hunk_controls, diff_hunk_control_bounds) = + if is_read_only && !self.editor.read(cx).delegate_stage_and_restore { + (vec![], vec![]) + } else { + self.layout_diff_hunk_controls( + start_row..end_row, + &row_infos, + &text_hitbox, + newest_selection_head, + line_height, + right_margin, + scroll_pixel_position, + &display_hunks, + &highlighted_rows, + self.editor.clone(), + window, + cx, + ) + }; let position_map = Rc::new(PositionMap { size: bounds.size, diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index f6f720f348f60bcc7da43a2fd33defe63acbaf41..14ca7e7f652531536315a6d1d088ac3215979070 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -4,10 +4,11 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscription, WeakEntity}; +use itertools::Itertools; use language::{Buffer, Capability}; use multi_buffer::{ - Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, MultiBufferPoint, - MultiBufferSnapshot, PathKey, + Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, MultiBufferDiffHunk, + MultiBufferPoint, MultiBufferSnapshot, PathKey, }; use project::Project; use rope::Point; @@ -60,6 +61,34 @@ pub(crate) fn convert_rhs_rows_to_lhs( ) } +fn translate_lhs_hunks_to_rhs( + lhs_hunks: &[MultiBufferDiffHunk], + splittable: &SplittableEditor, + cx: &App, +) -> Vec { + let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx); + let Some(companion) = rhs_display_map.companion() else { + return vec![]; + }; + let companion = companion.read(cx); + let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx); + let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect(); + + let mut translated = Vec::new(); + for lhs_hunk in lhs_hunks { + let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(lhs_hunk.buffer_id) else { + continue; + }; + if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| { + rhs_hunk.buffer_id == rhs_buffer_id + && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range + }) { + translated.push(rhs_hunk.clone()); + } + } + translated +} + fn patches_for_range( excerpt_map: &HashMap, source_snapshot: &MultiBufferSnapshot, @@ -371,6 +400,7 @@ impl SplittableEditor { Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx); editor.set_number_deleted_lines(true, cx); editor.set_delegate_expand_excerpts(true); + editor.set_delegate_stage_and_restore(true); editor.set_show_vertical_scrollbar(false, cx); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor @@ -396,6 +426,30 @@ impl SplittableEditor { this.expand_excerpts(rhs_ids.into_iter(), *lines, *direction, cx); } } + EditorEvent::StageOrUnstageRequested { stage, hunks } => { + if this.lhs.is_some() { + let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); + if !translated.is_empty() { + let stage = *stage; + this.rhs_editor.update(cx, |editor, cx| { + let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id); + for (buffer_id, hunks) in &chunk_by { + editor.do_stage_or_unstage(stage, buffer_id, hunks, cx); + } + }); + } + } + } + EditorEvent::RestoreRequested { hunks } => { + if this.lhs.is_some() { + let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); + if !translated.is_empty() { + this.rhs_editor.update(cx, |editor, cx| { + editor.restore_diff_hunks(translated, cx); + }); + } + } + } EditorEvent::SelectionsChanged { .. } => { if let Some(lhs) = &mut this.lhs { lhs.has_latest_selection = true;