git: Add support for staging/unstaging/restoring to side-by-side diff LHS, and render hunk controls there (#48320)

Cole Miller created

Release Notes:

- N/A

Change summary

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(-)

Detailed changes

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<BufferId> {
+        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);

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<bool>,
     use_relative_line_numbers: Option<bool>,
     show_git_diff_gutter: Option<bool>,
@@ -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<Editor>,
     ) {
+        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<MultiBufferDiffHunk>, 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::<Vec<_>>();
             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<Range<Anchor>>,
         cx: &mut Context<Self>,
     ) {
+        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<Box<dyn Fn(Point, &mut Window, &mut Context<Self>) + 'static>>,
@@ -27360,6 +27398,13 @@ pub enum EditorEvent {
         lines: u32,
         direction: ExpandExcerptDirection,
     },
+    StageOrUnstageRequested {
+        stage: bool,
+        hunks: Vec<MultiBufferDiffHunk>,
+    },
+    RestoreRequested {
+        hunks: Vec<MultiBufferDiffHunk>,
+    },
     BufferEdited,
     Edited {
         transaction_id: clock::Lamport,

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,

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<MultiBufferDiffHunk> {
+    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<MultiBufferDiffHunk> = 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<F>(
     excerpt_map: &HashMap<ExcerptId, ExcerptId>,
     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;