git_ui: Fix multibuffer coordinate conversion in clipboard diff (#51985)

Om Chillure created

## Context

Fixes the visual selection update in `TextDiffView::open` to properly
convert between buffer-local and multibuffer coordinates using
`buffer_point_to_anchor`. Previously, raw multibuffer Points were used
directly for line expansion, which produced incorrect regions when
expanded deleted diff hunks shifted multibuffer row numbers.

This provides a single unified code path for both singleton and
non-singleton multibuffers, as suggested in [#51457 review
feedback](https://github.com/zed-industries/zed/pull/51457#issuecomment-4091134303).

Follow-up to #51457.

## How to Review

Small PR - all changes are in `crates/git_ui/src/text_diff_view.rs`.
Focus on:

- `open()`: The visual selection update block now uses
`buffer_point_to_anchor` + `to_point` instead of raw multibuffer
coordinate math
- No more assumption that multibuffer Points == buffer-local Points
- Existing tests validate both singleton and non-singleton multibuffer
paths

## Self-Review Checklist
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed incorrect diff region when using "Diff Clipboard with Selection"
with expanded diff hunks in the editor.

Change summary

crates/git_ui/src/text_diff_view.rs | 235 ++++++++++++++++++++++++++++--
1 file changed, 219 insertions(+), 16 deletions(-)

Detailed changes

crates/git_ui/src/text_diff_view.rs 🔗

@@ -47,11 +47,24 @@ impl TextDiffView {
         let source_editor = diff_data.editor.clone();
 
         let selection_data = source_editor.update(cx, |editor, cx| {
-            let multibuffer = editor.buffer().read(cx);
-            let source_buffer = multibuffer.as_singleton()?;
+            let multibuffer = editor.buffer();
             let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
-            let buffer_snapshot = source_buffer.read(cx);
             let first_selection = selections.first()?;
+
+            let (source_buffer, buffer_start, start_excerpt) = multibuffer
+                .read(cx)
+                .point_to_buffer_point(first_selection.start, cx)?;
+            let buffer_end = multibuffer
+                .read(cx)
+                .point_to_buffer_point(first_selection.end, cx)
+                .and_then(|(buf, pt, end_excerpt)| {
+                    (buf.read(cx).remote_id() == source_buffer.read(cx).remote_id()
+                        && end_excerpt == start_excerpt)
+                        .then_some(pt)
+                })
+                .unwrap_or(buffer_start);
+
+            let buffer_snapshot = source_buffer.read(cx);
             let max_point = buffer_snapshot.max_point();
 
             if first_selection.is_empty() {
@@ -59,15 +72,12 @@ impl TextDiffView {
                 return Some((source_buffer, full_range));
             }
 
-            let start = first_selection.start;
-            let end = first_selection.end;
-            let expanded_start = Point::new(start.row, 0);
-
-            let expanded_end = if end.column > 0 {
-                let next_row = end.row + 1;
+            let expanded_start = Point::new(buffer_start.row, 0);
+            let expanded_end = if buffer_end.column > 0 {
+                let next_row = buffer_end.row + 1;
                 cmp::min(max_point, Point::new(next_row, 0))
             } else {
-                end
+                buffer_end
             };
             Some((source_buffer, expanded_start..expanded_end))
         });
@@ -78,11 +88,24 @@ impl TextDiffView {
         };
 
         source_editor.update(cx, |source_editor, cx| {
-            source_editor.change_selections(Default::default(), window, cx, |s| {
-                s.select_ranges(vec![
-                    expanded_selection_range.start..expanded_selection_range.end,
-                ]);
-            })
+            let multibuffer = source_editor.buffer();
+            let mb_range = {
+                let mb = multibuffer.read(cx);
+                let start_anchor =
+                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
+                let end_anchor =
+                    mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
+                start_anchor.zip(end_anchor).map(|(s, e)| {
+                    let snapshot = mb.snapshot(cx);
+                    s.to_point(&snapshot)..e.to_point(&snapshot)
+                })
+            };
+
+            if let Some(range) = mb_range {
+                source_editor.change_selections(Default::default(), window, cx, |s| {
+                    s.select_ranges(vec![range]);
+                });
+            }
         });
 
         let source_buffer_snapshot = source_buffer.read(cx).snapshot();
@@ -439,8 +462,9 @@ impl Render for TextDiffView {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use editor::{MultiBufferOffset, test::editor_test_context::assert_state_with_diff};
+    use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
     use gpui::{TestAppContext, VisualContext};
+    use language::Point;
     use project::{FakeFs, Project};
     use serde_json::json;
     use settings::SettingsStore;
@@ -643,6 +667,185 @@ mod tests {
         .await;
     }
 
+    #[gpui::test]
+    async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "a.txt": "alpha\nbeta\ngamma",
+                "b.txt": "one\ntwo\nthree"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+        let buffer_a = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/a.txt"), cx)
+            })
+            .await
+            .unwrap();
+        let buffer_b = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/b.txt"), cx)
+            })
+            .await
+            .unwrap();
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let editor = cx.new_window_entity(|window, cx| {
+            let multibuffer = cx.new(|cx| {
+                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
+                mb.set_excerpts_for_path(
+                    PathKey::sorted(0),
+                    buffer_a.clone(),
+                    [Point::new(0, 0)..Point::new(2, 5)],
+                    0,
+                    cx,
+                );
+                mb.set_excerpts_for_path(
+                    PathKey::sorted(1),
+                    buffer_b.clone(),
+                    [Point::new(0, 0)..Point::new(2, 5)],
+                    0,
+                    cx,
+                );
+                mb
+            });
+
+            let mut editor =
+                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
+            // Select "beta" inside the first excerpt
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
+            });
+            editor
+        });
+
+        let diff_view = workspace
+            .update_in(cx, |workspace, window, cx| {
+                TextDiffView::open(
+                    &DiffClipboardWithSelectionData {
+                        clipboard_text: "REPLACED".to_string(),
+                        editor,
+                    },
+                    workspace,
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+            .await
+            .unwrap();
+
+        cx.executor().run_until_parked();
+
+        diff_view.read_with(cx, |diff_view, _cx| {
+            assert!(
+                diff_view.title.contains("Clipboard"),
+                "diff view should have opened with a clipboard diff title, got: {}",
+                diff_view.title
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "a.txt": "alpha\nbeta\ngamma",
+                "b.txt": "one\ntwo\nthree"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+
+        let buffer_a = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/a.txt"), cx)
+            })
+            .await
+            .unwrap();
+        let buffer_b = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/b.txt"), cx)
+            })
+            .await
+            .unwrap();
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let editor = cx.new_window_entity(|window, cx| {
+            let multibuffer = cx.new(|cx| {
+                let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
+                mb.set_excerpts_for_path(
+                    PathKey::sorted(0),
+                    buffer_a.clone(),
+                    [Point::new(0, 0)..Point::new(2, 5)],
+                    0,
+                    cx,
+                );
+                mb.set_excerpts_for_path(
+                    PathKey::sorted(1),
+                    buffer_b.clone(),
+                    [Point::new(0, 0)..Point::new(2, 5)],
+                    0,
+                    cx,
+                );
+                mb
+            });
+
+            let mut editor =
+                Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
+            // Cursor inside the first excerpt (no selection)
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
+            });
+            editor
+        });
+
+        let diff_view = workspace
+            .update_in(cx, |workspace, window, cx| {
+                TextDiffView::open(
+                    &DiffClipboardWithSelectionData {
+                        clipboard_text: "REPLACED".to_string(),
+                        editor,
+                    },
+                    workspace,
+                    window,
+                    cx,
+                )
+            })
+            .unwrap()
+            .await
+            .unwrap();
+
+        cx.executor().run_until_parked();
+
+        // Empty selection should diff the full underlying buffer
+        diff_view.read_with(cx, |diff_view, _cx| {
+            assert!(
+                diff_view.title.contains("Clipboard"),
+                "diff view should have opened with a clipboard diff title, got: {}",
+                diff_view.title
+            );
+        });
+    }
+
     async fn base_test(
         project_root: &str,
         file_path: &str,