editor: Fix clipboard line numbers when copying from multibuffer (#46743)

Kavi Bidlack created

Release Notes:

- When copying and pasting from a multibuffer view (e.g. into the agent
panel), the line numbers from the selection corresponded to the line
numbers in the multibuffer view (incorrect), instead of the actual line
numbers from the file itself.
- I also handled the case in which the selection spans multiple excerpts
(different files in the multibuffer) by just returning None for that
case, but maybe it should instead be split into two selections?

Left is before (bugged, should be lines 8:16, instead it is 38:46),
right is after (fixed).
<div style="display:flex; gap:12px;">
<img
src="https://github.com/user-attachments/assets/b1bfce1d-8b6a-41c0-ac7e-51f7bd7b284e"
       width="48%" />
<img
src="https://github.com/user-attachments/assets/2a4c33a0-a969-4a3e-9aa5-d2c2fefba3b2"
       width="48%" />
</div>

Change summary

crates/editor/src/editor.rs       | 10 +++++
crates/editor/src/editor_tests.rs | 59 +++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+), 1 deletion(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1644,7 +1644,15 @@ impl ClipboardSelection {
             project.absolute_path(&project_path, cx)
         });
 
-        let line_range = file_path.as_ref().map(|_| range.start.row..=range.end.row);
+        let line_range = file_path.as_ref().and_then(|_| {
+            let (_, start_point, start_excerpt_id) = buffer.point_to_buffer_point(range.start)?;
+            let (_, end_point, end_excerpt_id) = buffer.point_to_buffer_point(range.end)?;
+            if start_excerpt_id == end_excerpt_id {
+                Some(start_point.row..=end_point.row)
+            } else {
+                None
+            }
+        });
 
         Self {
             len,

crates/editor/src/editor_tests.rs 🔗

@@ -7631,6 +7631,65 @@ async fn test_copy_trim_line_mode(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_clipboard_line_numbers_from_multibuffer(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_file(
+        path!("/file.txt"),
+        "first line\nsecond line\nthird line\nfourth line\nfifth line\n".into(),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/file.txt").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::new(ReadWrite);
+        multibuffer.push_excerpts(
+            buffer.clone(),
+            [ExcerptRange::new(Point::new(2, 0)..Point::new(5, 0))],
+            cx,
+        );
+        multibuffer
+    });
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), multibuffer, window, cx)
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        assert_eq!(editor.text(cx), "third line\nfourth line\nfifth line\n");
+
+        editor.select_all(&SelectAll, window, cx);
+        editor.copy(&Copy, window, cx);
+    });
+
+    let clipboard_selections: Option<Vec<ClipboardSelection>> = cx
+        .read_from_clipboard()
+        .and_then(|item| item.entries().first().cloned())
+        .and_then(|entry| match entry {
+            gpui::ClipboardEntry::String(text) => text.metadata_json(),
+            _ => None,
+        });
+
+    let selections = clipboard_selections.expect("should have clipboard selections");
+    assert_eq!(selections.len(), 1);
+    let selection = &selections[0];
+    assert_eq!(
+        selection.line_range,
+        Some(2..=5),
+        "line range should be from original file (rows 2-5), not multibuffer rows (0-2)"
+    );
+}
+
 #[gpui::test]
 async fn test_paste_multiline(cx: &mut TestAppContext) {
     init_test(cx, |_| {});