Add `editor::RevertSelectedHunks` to revert git diff hunks in the editor (#9068)

Kirill Bulatov created

https://github.com/zed-industries/zed/assets/2690773/653b5658-e3f3-4aee-9a9d-0f2153b4141b

Release Notes:

- Added `editor::RevertSelectedHunks` (`cmd-alt-z` by default) for
reverting git hunks from the editor

Change summary

assets/keymaps/default-linux.json             |   3 
assets/keymaps/default-macos.json             |   3 
crates/collab/src/tests/editor_tests.rs       | 168 +++++
crates/editor/src/actions.rs                  |   1 
crates/editor/src/editor.rs                   | 101 +++
crates/editor/src/editor_tests.rs             | 596 +++++++++++++++++++++
crates/editor/src/element.rs                  |   7 
crates/editor/src/git.rs                      |  18 
crates/editor/src/test/editor_test_context.rs |   2 
crates/git/src/diff.rs                        |  81 ++
crates/language/src/buffer.rs                 |  13 
crates/multi_buffer/src/multi_buffer.rs       |  81 +-
crates/project/src/project.rs                 |   1 
13 files changed, 1,003 insertions(+), 72 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -118,7 +118,8 @@
           "stop_at_soft_wraps": true
         }
       ],
-      "ctrl-;": "editor::ToggleLineNumbers"
+      "ctrl-;": "editor::ToggleLineNumbers",
+      "ctrl-alt-z": "editor::RevertSelectedHunks"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -153,7 +153,8 @@
         }
       ],
       "ctrl-cmd-space": "editor::ShowCharacterPalette",
-      "cmd-;": "editor::ToggleLineNumbers"
+      "cmd-;": "editor::ToggleLineNumbers",
+      "cmd-alt-z": "editor::RevertSelectedHunks"
     }
   },
   {

crates/collab/src/tests/editor_tests.rs 🔗

@@ -5,7 +5,8 @@ use crate::{
 use call::ActiveCall;
 use editor::{
     actions::{
-        ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, ToggleCodeActions, Undo,
+        ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, RevertSelectedHunks,
+        ToggleCodeActions, Undo,
     },
     test::editor_test_context::{AssertionContextManager, EditorTestContext},
     Editor,
@@ -1814,6 +1815,171 @@ async fn test_inlay_hint_refresh_is_forwarded(
     });
 }
 
+#[gpui::test]
+async fn test_multiple_types_reverts(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a.language_registry().add(rust_lang());
+    client_b.language_registry().add(rust_lang());
+
+    let base_text = indoc! {r#"struct Row;
+struct Row1;
+struct Row2;
+
+struct Row4;
+struct Row5;
+struct Row6;
+
+struct Row8;
+struct Row9;
+struct Row10;"#};
+
+    client_a
+        .fs()
+        .insert_tree(
+            "/a",
+            json!({
+                "main.rs": base_text,
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
+    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+
+    let editor_a = workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let editor_b = workspace_b
+        .update(cx_b, |workspace, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let mut editor_cx_a = EditorTestContext {
+        cx: cx_a.clone(),
+        window: cx_a.handle(),
+        editor: editor_a,
+        assertion_cx: AssertionContextManager::new(),
+    };
+    let mut editor_cx_b = EditorTestContext {
+        cx: cx_b.clone(),
+        window: cx_b.handle(),
+        editor: editor_b,
+        assertion_cx: AssertionContextManager::new(),
+    };
+
+    // host edits the file, that differs from the base text, producing diff hunks
+    editor_cx_a.set_state(indoc! {r#"struct Row;
+        struct Row0.1;
+        struct Row0.2;
+        struct Row1;
+
+        struct Row4;
+        struct Row5444;
+        struct Row6;
+
+        struct Row9;
+        struct Row1220;ˇ"#});
+    editor_cx_a.update_editor(|editor, cx| {
+        editor
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .update(cx, |buffer, cx| {
+                buffer.set_diff_base(Some(base_text.to_string()), cx);
+            });
+    });
+    editor_cx_b.update_editor(|editor, cx| {
+        editor
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .update(cx, |buffer, cx| {
+                buffer.set_diff_base(Some(base_text.to_string()), cx);
+            });
+    });
+    cx_a.executor().run_until_parked();
+    cx_b.executor().run_until_parked();
+
+    // client, selects a range in the updated buffer, and reverts it
+    // both host and the client observe the reverted state (with one hunk left, not covered by client's selection)
+    editor_cx_b.set_selections_state(indoc! {r#"«ˇstruct Row;
+        struct Row0.1;
+        struct Row0.2;
+        struct Row1;
+
+        struct Row4;
+        struct Row5444;
+        struct Row6;
+
+        struct R»ow9;
+        struct Row1220;"#});
+    editor_cx_b.update_editor(|editor, cx| {
+        editor.revert_selected_hunks(&RevertSelectedHunks, cx);
+    });
+    cx_a.executor().run_until_parked();
+    cx_b.executor().run_until_parked();
+    editor_cx_a.assert_editor_state(indoc! {r#"struct Row;
+        struct Row1;
+        struct Row2;
+
+        struct Row4;
+        struct Row5;
+        struct Row6;
+
+        struct Row8;
+        struct Row9;
+        struct Row1220;ˇ"#});
+    editor_cx_b.assert_editor_state(indoc! {r#"«ˇstruct Row;
+        struct Row1;
+        struct Row2;
+
+        struct Row4;
+        struct Row5;
+        struct Row6;
+
+        struct Row8;
+        struct R»ow9;
+        struct Row1220;"#});
+}
+
 fn extract_hint_labels(editor: &Editor) -> Vec<String> {
     let mut labels = Vec::new();
     for hint in editor.inlay_hint_cache().hints() {

crates/editor/src/editor.rs 🔗

@@ -36,7 +36,7 @@ mod selections_collection;
 mod editor_tests;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
-use ::git::diff::DiffHunk;
+use ::git::diff::{DiffHunk, DiffHunkStatus};
 pub(crate) use actions::*;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Context as _, Result};
@@ -4908,6 +4908,105 @@ impl Editor {
         })
     }
 
+    pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext<Self>) {
+        let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx);
+        if !revert_changes.is_empty() {
+            self.transact(cx, |editor, cx| {
+                editor.buffer().update(cx, |multi_buffer, cx| {
+                    for (buffer_id, buffer_revert_ranges) in revert_changes {
+                        if let Some(buffer) = multi_buffer.buffer(buffer_id) {
+                            buffer.update(cx, |buffer, cx| {
+                                buffer.edit(buffer_revert_ranges, None, cx);
+                            });
+                        }
+                    }
+                });
+                editor.change_selections(None, cx, |selections| selections.refresh());
+            });
+        }
+    }
+
+    fn gather_revert_changes(
+        &mut self,
+        selections: &[Selection<Anchor>],
+        cx: &mut ViewContext<'_, Editor>,
+    ) -> HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>> {
+        let mut revert_changes = HashMap::default();
+        self.buffer.update(cx, |multi_buffer, cx| {
+            let multi_buffer_snapshot = multi_buffer.snapshot(cx);
+            let selected_multi_buffer_rows = selections.iter().map(|selection| {
+                let head = selection.head();
+                let tail = selection.tail();
+                let start = tail.to_point(&multi_buffer_snapshot).row;
+                let end = head.to_point(&multi_buffer_snapshot).row;
+                if start > end {
+                    end..start
+                } else {
+                    start..end
+                }
+            });
+
+            let mut processed_buffer_rows =
+                HashMap::<BufferId, HashSet<Range<text::Anchor>>>::default();
+            for selected_multi_buffer_rows in selected_multi_buffer_rows {
+                let query_rows =
+                    selected_multi_buffer_rows.start..selected_multi_buffer_rows.end + 1;
+                for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) {
+                    // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it
+                    // when the caret is just above or just below the deleted hunk.
+                    let allow_adjacent = hunk.status() == DiffHunkStatus::Removed;
+                    let related_to_selection = if allow_adjacent {
+                        hunk.associated_range.overlaps(&query_rows)
+                            || hunk.associated_range.start == query_rows.end
+                            || hunk.associated_range.end == query_rows.start
+                    } else {
+                        // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected)
+                        // `hunk.associated_range` is exclusive (e.g. [2..3] means 2nd row is selected)
+                        hunk.associated_range.overlaps(&selected_multi_buffer_rows)
+                            || selected_multi_buffer_rows.end == hunk.associated_range.start
+                    };
+                    if related_to_selection {
+                        if !processed_buffer_rows
+                            .entry(hunk.buffer_id)
+                            .or_default()
+                            .insert(hunk.buffer_range.start..hunk.buffer_range.end)
+                        {
+                            continue;
+                        }
+                        Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx);
+                    }
+                }
+            }
+        });
+        revert_changes
+    }
+
+    fn prepare_revert_change(
+        revert_changes: &mut HashMap<BufferId, Vec<(Range<text::Anchor>, Arc<str>)>>,
+        multi_buffer: &MultiBuffer,
+        hunk: &DiffHunk<u32>,
+        cx: &mut AppContext,
+    ) -> Option<()> {
+        let buffer = multi_buffer.buffer(hunk.buffer_id)?;
+        let buffer = buffer.read(cx);
+        let original_text = buffer.diff_base()?.get(hunk.diff_base_byte_range.clone())?;
+        let buffer_snapshot = buffer.snapshot();
+        let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default();
+        if let Err(i) = buffer_revert_changes.binary_search_by(|probe| {
+            probe
+                .0
+                .start
+                .cmp(&hunk.buffer_range.start, &buffer_snapshot)
+                .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot))
+                .then(probe.1.as_ref().cmp(original_text))
+        }) {
+            buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), Arc::from(original_text)));
+            Some(())
+        } else {
+            None
+        }
+    }
+
     pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext<Self>) {
         self.manipulate_lines(cx, |lines| lines.reverse())
     }

crates/editor/src/editor_tests.rs 🔗

@@ -8743,6 +8743,560 @@ async fn test_find_all_references(cx: &mut gpui::TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_addition_reverts(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
+    let base_text = indoc! {r#"struct Row;
+struct Row1;
+struct Row2;
+
+struct Row4;
+struct Row5;
+struct Row6;
+
+struct Row8;
+struct Row9;
+struct Row10;"#};
+
+    // When addition hunks are not adjacent to carets, no hunk revert is performed
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row1.1;
+                   struct Row1.2;
+                   struct Row2;ˇ
+
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+
+                   struct Row8;
+                   ˇstruct Row9;
+                   struct Row9.1;
+                   struct Row9.2;
+                   struct Row9.3;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row1.1;
+                   struct Row1.2;
+                   struct Row2;ˇ
+
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+
+                   struct Row8;
+                   ˇstruct Row9;
+                   struct Row9.1;
+                   struct Row9.2;
+                   struct Row9.3;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+    // Same for selections
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row2;
+                   struct Row2.1;
+                   struct Row2.2;
+                   «ˇ
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row9.1;
+                   struct Row9.2;
+                   struct Row9.3;
+                   struct Row8;
+                   struct Row9;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Added, DiffHunkStatus::Added],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row2;
+                   struct Row2.1;
+                   struct Row2.2;
+                   «ˇ
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row9.1;
+                   struct Row9.2;
+                   struct Row9.3;
+                   struct Row8;
+                   struct Row9;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+
+    // When carets and selections intersect the addition hunks, those are reverted.
+    // Adjacent carets got merged.
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   ˇ// something on the top
+                   struct Row1;
+                   struct Row2;
+                   struct Roˇw3.1;
+                   struct Row2.2;
+                   struct Row2.3;ˇ
+
+                   struct Row4;
+                   struct ˇRow5.1;
+                   struct Row5.2;
+                   struct «Rowˇ»5.3;
+                   struct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row9.1;
+                   struct «Rowˇ»9.2;
+                   struct «ˇRow»9.3;
+                   struct Row8;
+                   struct Row9;
+                   «ˇ// something on bottom»
+                   struct Row10;"#},
+        vec![
+            DiffHunkStatus::Added,
+            DiffHunkStatus::Added,
+            DiffHunkStatus::Added,
+            DiffHunkStatus::Added,
+            DiffHunkStatus::Added,
+        ],
+        indoc! {r#"struct Row;
+                   ˇstruct Row1;
+                   struct Row2;
+                   ˇ
+                   struct Row4;
+                   ˇstruct Row5;
+                   struct Row6;
+                   ˇ
+                   ˇstruct Row8;
+                   struct Row9;
+                   ˇstruct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_modification_reverts(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
+    let base_text = indoc! {r#"struct Row;
+struct Row1;
+struct Row2;
+
+struct Row4;
+struct Row5;
+struct Row6;
+
+struct Row8;
+struct Row9;
+struct Row10;"#};
+
+    // Modification hunks behave the same as the addition ones.
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row33;
+                   ˇ
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row99;
+                   struct Row9;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row33;
+                   ˇ
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row99;
+                   struct Row9;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row33;
+                   «ˇ
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row99;
+                   struct Row9;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Modified, DiffHunkStatus::Modified],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row33;
+                   «ˇ
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row99;
+                   struct Row9;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+
+    assert_hunk_revert(
+        indoc! {r#"ˇstruct Row1.1;
+                   struct Row1;
+                   «ˇstr»uct Row22;
+
+                   struct ˇRow44;
+                   struct Row5;
+                   struct «Rˇ»ow66;ˇ
+
+                   «struˇ»ct Row88;
+                   struct Row9;
+                   struct Row1011;ˇ"#},
+        vec![
+            DiffHunkStatus::Modified,
+            DiffHunkStatus::Modified,
+            DiffHunkStatus::Modified,
+            DiffHunkStatus::Modified,
+            DiffHunkStatus::Modified,
+            DiffHunkStatus::Modified,
+        ],
+        indoc! {r#"struct Row;
+                   ˇstruct Row1;
+                   struct Row2;
+                   ˇ
+                   struct Row4;
+                   ˇstruct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row8;
+                   ˇstruct Row9;
+                   struct Row10;ˇ"#},
+        base_text,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_deletion_reverts(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
+    let base_text = indoc! {r#"struct Row;
+struct Row1;
+struct Row2;
+
+struct Row4;
+struct Row5;
+struct Row6;
+
+struct Row8;
+struct Row9;
+struct Row10;"#};
+
+    // Deletion hunks trigger with carets on ajacent rows, so carets and selections have to stay farther to avoid the revert
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row2;
+
+                   ˇstruct Row4;
+                   struct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row8;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        indoc! {r#"struct Row;
+                   struct Row2;
+
+                   ˇstruct Row4;
+                   struct Row5;
+                   struct Row6;
+                   ˇ
+                   struct Row8;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row2;
+
+                   «ˇstruct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row8;
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        indoc! {r#"struct Row;
+                   struct Row2;
+
+                   «ˇstruct Row4;
+                   struct» Row5;
+                   «struct Row6;
+                   ˇ»
+                   struct Row8;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+
+    // Deletion hunks are ephemeral, so it's impossible to place the caret into them — Zed triggers reverts for lines, adjacent to carets and selections.
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   ˇstruct Row2;
+
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+
+                   struct Row8;ˇ
+                   struct Row10;"#},
+        vec![DiffHunkStatus::Removed, DiffHunkStatus::Removed],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   ˇstruct Row2;
+
+                   struct Row4;
+                   struct Row5;
+                   struct Row6;
+
+                   struct Row8;ˇ
+                   struct Row9;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+    assert_hunk_revert(
+        indoc! {r#"struct Row;
+                   struct Row2«ˇ;
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+
+                   struct Row8;ˇ»
+                   struct Row10;"#},
+        vec![
+            DiffHunkStatus::Removed,
+            DiffHunkStatus::Removed,
+            DiffHunkStatus::Removed,
+        ],
+        indoc! {r#"struct Row;
+                   struct Row1;
+                   struct Row2«ˇ;
+
+                   struct Row4;
+                   struct» Row5;
+                   «struct Row6;
+
+                   struct Row8;ˇ»
+                   struct Row9;
+                   struct Row10;"#},
+        base_text,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let cols = 4;
+    let rows = 10;
+    let sample_text_1 = sample_text(rows, cols, 'a');
+    assert_eq!(
+        sample_text_1,
+        "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"
+    );
+    let sample_text_2 = sample_text(rows, cols, 'l');
+    assert_eq!(
+        sample_text_2,
+        "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"
+    );
+    let sample_text_3 = sample_text(rows, cols, 'v');
+    assert_eq!(
+        sample_text_3,
+        "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"
+    );
+
+    fn diff_every_buffer_row(
+        buffer: &Model<Buffer>,
+        sample_text: String,
+        cols: usize,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        // revert first character in each row, creating one large diff hunk per buffer
+        let is_first_char = |offset: usize| offset % cols == 0;
+        buffer.update(cx, |buffer, cx| {
+            buffer.set_text(
+                sample_text
+                    .chars()
+                    .enumerate()
+                    .map(|(offset, c)| if is_first_char(offset) { 'X' } else { c })
+                    .collect::<String>(),
+                cx,
+            );
+            buffer.set_diff_base(Some(sample_text), cx);
+        });
+        cx.executor().run_until_parked();
+    }
+
+    let buffer_1 = cx.new_model(|cx| {
+        Buffer::new(
+            0,
+            BufferId::new(cx.entity_id().as_u64()).unwrap(),
+            sample_text_1.clone(),
+        )
+    });
+    diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
+
+    let buffer_2 = cx.new_model(|cx| {
+        Buffer::new(
+            1,
+            BufferId::new(cx.entity_id().as_u64() + 1).unwrap(),
+            sample_text_2.clone(),
+        )
+    });
+    diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
+
+    let buffer_3 = cx.new_model(|cx| {
+        Buffer::new(
+            2,
+            BufferId::new(cx.entity_id().as_u64() + 2).unwrap(),
+            sample_text_3.clone(),
+        )
+    });
+    diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
+
+    let multibuffer = cx.new_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0, ReadWrite);
+        multibuffer.push_excerpts(
+            buffer_1.clone(),
+            [
+                ExcerptRange {
+                    context: Point::new(0, 0)..Point::new(3, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(5, 0)..Point::new(7, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(9, 0)..Point::new(10, 4),
+                    primary: None,
+                },
+            ],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            buffer_2.clone(),
+            [
+                ExcerptRange {
+                    context: Point::new(0, 0)..Point::new(3, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(5, 0)..Point::new(7, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(9, 0)..Point::new(10, 4),
+                    primary: None,
+                },
+            ],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            buffer_3.clone(),
+            [
+                ExcerptRange {
+                    context: Point::new(0, 0)..Point::new(3, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(5, 0)..Point::new(7, 0),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(9, 0)..Point::new(10, 4),
+                    primary: None,
+                },
+            ],
+            cx,
+        );
+        multibuffer
+    });
+
+    let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx));
+    editor.update(cx, |editor, cx| {
+        assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n");
+        editor.select_all(&SelectAll, cx);
+        editor.revert_selected_hunks(&RevertSelectedHunks, cx);
+    });
+    cx.executor().run_until_parked();
+    // When all ranges are selected, all buffer hunks are reverted.
+    editor.update(cx, |editor, cx| {
+        assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n");
+    });
+    buffer_1.update(cx, |buffer, _| {
+        assert_eq!(buffer.text(), sample_text_1);
+    });
+    buffer_2.update(cx, |buffer, _| {
+        assert_eq!(buffer.text(), sample_text_2);
+    });
+    buffer_3.update(cx, |buffer, _| {
+        assert_eq!(buffer.text(), sample_text_3);
+    });
+
+    diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx);
+    diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx);
+    diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx);
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0)));
+        });
+        editor.revert_selected_hunks(&RevertSelectedHunks, cx);
+    });
+    // Now, when all ranges selected belong to buffer_1, the revert should succeed,
+    // but not affect buffer_2 and its related excerpts.
+    editor.update(cx, |editor, cx| {
+        assert_eq!(
+            editor.text(cx),
+            "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n"
+        );
+    });
+    buffer_1.update(cx, |buffer, _| {
+        assert_eq!(buffer.text(), sample_text_1);
+    });
+    buffer_2.update(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX"
+        );
+    });
+    buffer_3.update(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X"
+        );
+    });
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(row as u32, column as u32);
     point..point
@@ -8913,3 +9467,45 @@ pub(crate) fn rust_lang() -> Arc<Language> {
         Some(tree_sitter_rust::language()),
     ))
 }
+
+#[track_caller]
+fn assert_hunk_revert(
+    not_reverted_text_with_selections: &str,
+    expected_not_reverted_hunk_statuses: Vec<DiffHunkStatus>,
+    expected_reverted_text_with_selections: &str,
+    base_text: &str,
+    cx: &mut EditorLspTestContext,
+) {
+    cx.set_state(not_reverted_text_with_selections);
+    cx.update_editor(|editor, cx| {
+        editor
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .update(cx, |buffer, cx| {
+                buffer.set_diff_base(Some(base_text.to_string()), cx);
+            });
+    });
+    cx.executor().run_until_parked();
+
+    let reverted_hunk_statuses = cx.update_editor(|editor, cx| {
+        let snapshot = editor
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .read(cx)
+            .snapshot();
+        let reverted_hunk_statuses = snapshot
+            .git_diff_hunks_in_row_range(0..u32::MAX)
+            .map(|hunk| hunk.status())
+            .collect::<Vec<_>>();
+
+        editor.revert_selected_hunks(&RevertSelectedHunks, cx);
+        reverted_hunk_statuses
+    });
+    cx.executor().run_until_parked();
+    cx.assert_editor_state(expected_reverted_text_with_selections);
+    assert_eq!(reverted_hunk_statuses, expected_not_reverted_hunk_statuses);
+}

crates/editor/src/element.rs 🔗

@@ -339,6 +339,7 @@ impl EditorElement {
         register_action(view, cx, Editor::unique_lines_case_insensitive);
         register_action(view, cx, Editor::unique_lines_case_sensitive);
         register_action(view, cx, Editor::accept_partial_copilot_suggestion);
+        register_action(view, cx, Editor::revert_selected_hunks);
     }
 
     fn register_key_listeners(
@@ -1452,12 +1453,12 @@ impl EditorElement {
                     .buffer_snapshot
                     .git_diff_hunks_in_range(0..(max_row.floor() as u32))
                 {
-                    let start_display = Point::new(hunk.buffer_range.start, 0)
+                    let start_display = Point::new(hunk.associated_range.start, 0)
                         .to_display_point(&layout.position_map.snapshot.display_snapshot);
-                    let end_display = Point::new(hunk.buffer_range.end, 0)
+                    let end_display = Point::new(hunk.associated_range.end, 0)
                         .to_display_point(&layout.position_map.snapshot.display_snapshot);
                     let start_y = y_for_row(start_display.row() as f32);
-                    let mut end_y = if hunk.buffer_range.start == hunk.buffer_range.end {
+                    let mut end_y = if hunk.associated_range.start == hunk.associated_range.end {
                         y_for_row((end_display.row() + 1) as f32)
                     } else {
                         y_for_row((end_display.row()) as f32)

crates/editor/src/git.rs 🔗

@@ -46,20 +46,20 @@ impl DisplayDiffHunk {
 }
 
 pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
-    let hunk_start_point = Point::new(hunk.buffer_range.start, 0);
-    let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
+    let hunk_start_point = Point::new(hunk.associated_range.start, 0);
+    let hunk_start_point_sub = Point::new(hunk.associated_range.start.saturating_sub(1), 0);
     let hunk_end_point_sub = Point::new(
-        hunk.buffer_range
+        hunk.associated_range
             .end
             .saturating_sub(1)
-            .max(hunk.buffer_range.start),
+            .max(hunk.associated_range.start),
         0,
     );
 
     let is_removal = hunk.status() == DiffHunkStatus::Removed;
 
-    let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(2), 0);
-    let folds_end = Point::new(hunk.buffer_range.end + 2, 0);
+    let folds_start = Point::new(hunk.associated_range.start.saturating_sub(2), 0);
+    let folds_end = Point::new(hunk.associated_range.end + 2, 0);
     let folds_range = folds_start..folds_end;
 
     let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| {
@@ -79,7 +79,7 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
     } else {
         let start = hunk_start_point.to_display_point(snapshot).row();
 
-        let hunk_end_row = hunk.buffer_range.end.max(hunk.buffer_range.start);
+        let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
         let hunk_end_point = Point::new(hunk_end_row, 0);
         let end = hunk_end_point.to_display_point(snapshot).row();
 
@@ -264,7 +264,7 @@ mod tests {
         assert_eq!(
             snapshot
                 .git_diff_hunks_in_range(0..12)
-                .map(|hunk| (hunk.status(), hunk.buffer_range))
+                .map(|hunk| (hunk.status(), hunk.associated_range))
                 .collect::<Vec<_>>(),
             &expected,
         );
@@ -272,7 +272,7 @@ mod tests {
         assert_eq!(
             snapshot
                 .git_diff_hunks_in_range_rev(0..12)
-                .map(|hunk| (hunk.status(), hunk.buffer_range))
+                .map(|hunk| (hunk.status(), hunk.associated_range))
                 .collect::<Vec<_>>(),
             expected
                 .iter()

crates/editor/src/test/editor_test_context.rs 🔗

@@ -274,7 +274,7 @@ impl EditorTestContext {
         let buffer_text = self.buffer_text();
 
         if buffer_text != unmarked_text {
-            panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}Raw unmarked text\n{unmarked_text}");
+            panic!("Unmarked text doesn't match buffer text\nBuffer text: {buffer_text:?}\nUnmarked text: {unmarked_text:?}\nRaw buffer text\n{buffer_text}\nRaw unmarked text\n{unmarked_text}");
         }
 
         self.assert_selections(expected_selections, marked_text.to_string())

crates/git/src/diff.rs 🔗

@@ -1,6 +1,6 @@
 use std::{iter, ops::Range};
 use sum_tree::SumTree;
-use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
+use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point};
 
 pub use git2 as libgit;
 use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
@@ -12,17 +12,53 @@ pub enum DiffHunkStatus {
     Removed,
 }
 
+/// A diff hunk, representing a range of consequent lines in a singleton buffer, associated with a generic range.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct DiffHunk<T> {
-    pub buffer_range: Range<T>,
+    /// E.g. a range in multibuffer, that has an excerpt added, singleton buffer for which has this diff hunk.
+    /// Consider a singleton buffer with 10 lines, all of them are modified — so a corresponding diff hunk would have a range 0..10.
+    /// And a multibuffer with the excerpt of lines 2-6 from the singleton buffer.
+    /// If the multibuffer is searched for diff hunks, the associated range would be multibuffer rows, corresponding to rows 2..6 from the singleton buffer.
+    /// But the hunk range would be 0..10, same for any other excerpts from the same singleton buffer.
+    pub associated_range: Range<T>,
+    /// Singleton buffer ID this hunk belongs to.
+    pub buffer_id: BufferId,
+    /// A consequent range of lines in the singleton buffer, that were changed and produced this diff hunk.
+    pub buffer_range: Range<Anchor>,
+    /// Original singleton buffer text before the change, that was instead of the `buffer_range`.
     pub diff_base_byte_range: Range<usize>,
 }
 
+impl<T> DiffHunk<T> {
+    fn buffer_range_empty(&self) -> bool {
+        if self.buffer_range.start == self.buffer_range.end {
+            return true;
+        }
+
+        // buffer diff hunks are per line, so if we arrive to the same line with different bias, it's the same hunk
+        let Anchor {
+            timestamp: timestamp_start,
+            offset: offset_start,
+            buffer_id: buffer_id_start,
+            bias: _,
+        } = self.buffer_range.start;
+        let Anchor {
+            timestamp: timestamp_end,
+            offset: offset_end,
+            buffer_id: buffer_id_end,
+            bias: _,
+        } = self.buffer_range.end;
+        timestamp_start == timestamp_end
+            && offset_start == offset_end
+            && buffer_id_start == buffer_id_end
+    }
+}
+
 impl DiffHunk<u32> {
     pub fn status(&self) -> DiffHunkStatus {
         if self.diff_base_byte_range.is_empty() {
             DiffHunkStatus::Added
-        } else if self.buffer_range.is_empty() {
+        } else if self.buffer_range_empty() {
             DiffHunkStatus::Removed
         } else {
             DiffHunkStatus::Modified
@@ -35,7 +71,7 @@ impl sum_tree::Item for DiffHunk<Anchor> {
 
     fn summary(&self) -> Self::Summary {
         DiffHunkSummary {
-            buffer_range: self.buffer_range.clone(),
+            buffer_range: self.associated_range.clone(),
         }
     }
 }
@@ -57,7 +93,7 @@ impl sum_tree::Summary for DiffHunkSummary {
     }
 }
 
-#[derive(Clone)]
+#[derive(Debug, Clone)]
 pub struct BufferDiff {
     last_buffer_version: Option<clock::Global>,
     tree: SumTree<DiffHunk<Anchor>>,
@@ -103,8 +139,11 @@ impl BufferDiff {
         })
         .flat_map(move |hunk| {
             [
-                (&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
-                (&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
+                (
+                    &hunk.associated_range.start,
+                    hunk.diff_base_byte_range.start,
+                ),
+                (&hunk.associated_range.end, hunk.diff_base_byte_range.end),
             ]
             .into_iter()
         });
@@ -112,17 +151,17 @@ impl BufferDiff {
         let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
         iter::from_fn(move || {
             let (start_point, start_base) = summaries.next()?;
-            let (end_point, end_base) = summaries.next()?;
+            let (mut end_point, end_base) = summaries.next()?;
 
-            let end_row = if end_point.column > 0 {
-                end_point.row + 1
-            } else {
-                end_point.row
-            };
+            if end_point.column > 0 {
+                end_point.row += 1;
+            }
 
             Some(DiffHunk {
-                buffer_range: start_point.row..end_row,
+                associated_range: start_point.row..end_point.row,
                 diff_base_byte_range: start_base..end_base,
+                buffer_range: buffer.anchor_before(start_point)..buffer.anchor_after(end_point),
+                buffer_id: buffer.remote_id(),
             })
         })
     }
@@ -142,7 +181,7 @@ impl BufferDiff {
             cursor.prev(buffer);
 
             let hunk = cursor.item()?;
-            let range = hunk.buffer_range.to_point(buffer);
+            let range = hunk.associated_range.to_point(buffer);
             let end_row = if range.end.column > 0 {
                 range.end.row + 1
             } else {
@@ -150,8 +189,10 @@ impl BufferDiff {
             };
 
             Some(DiffHunk {
-                buffer_range: range.start.row..end_row,
+                associated_range: range.start.row..end_row,
                 diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                buffer_range: hunk.buffer_range.clone(),
+                buffer_id: hunk.buffer_id,
             })
         })
     }
@@ -269,8 +310,10 @@ impl BufferDiff {
         let end = Point::new(buffer_row_range.end, 0);
         let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
         DiffHunk {
+            associated_range: buffer_range.clone(),
             buffer_range,
             diff_base_byte_range,
+            buffer_id: buffer.remote_id(),
         }
     }
 }
@@ -289,12 +332,12 @@ pub fn assert_hunks<Iter>(
     let actual_hunks = diff_hunks
         .map(|hunk| {
             (
-                hunk.buffer_range.clone(),
+                hunk.associated_range.clone(),
                 &diff_base[hunk.diff_base_byte_range],
                 buffer
                     .text_for_range(
-                        Point::new(hunk.buffer_range.start, 0)
-                            ..Point::new(hunk.buffer_range.end, 0),
+                        Point::new(hunk.associated_range.start, 0)
+                            ..Point::new(hunk.associated_range.end, 0),
                     )
                     .collect::<String>(),
             )

crates/language/src/buffer.rs 🔗

@@ -930,8 +930,17 @@ impl Buffer {
     /// against the buffer text.
     pub fn set_diff_base(&mut self, diff_base: Option<String>, cx: &mut ModelContext<Self>) {
         self.diff_base = diff_base;
-        self.git_diff_recalc(cx);
-        cx.emit(Event::DiffBaseChanged);
+        if let Some(recalc_task) = self.git_diff_recalc(cx) {
+            cx.spawn(|buffer, mut cx| async move {
+                recalc_task.await;
+                buffer
+                    .update(&mut cx, |_, cx| {
+                        cx.emit(Event::DiffBaseChanged);
+                    })
+                    .ok();
+            })
+            .detach();
+        }
     }
 
     /// Recomputes the Git diff status.

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -3186,19 +3186,21 @@ impl MultiBufferSnapshot {
                 .map(move |hunk| {
                     let start = multibuffer_start.row
                         + hunk
-                            .buffer_range
+                            .associated_range
                             .start
                             .saturating_sub(excerpt_start_point.row);
                     let end = multibuffer_start.row
                         + hunk
-                            .buffer_range
+                            .associated_range
                             .end
                             .min(excerpt_end_point.row + 1)
                             .saturating_sub(excerpt_start_point.row);
 
                     DiffHunk {
-                        buffer_range: start..end,
+                        associated_range: start..end,
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                        buffer_range: hunk.buffer_range.clone(),
+                        buffer_id: hunk.buffer_id,
                     }
                 });
 
@@ -3215,52 +3217,65 @@ impl MultiBufferSnapshot {
     ) -> impl Iterator<Item = DiffHunk<u32>> + '_ {
         let mut cursor = self.excerpts.cursor::<Point>();
 
-        cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &());
+        cursor.seek(&Point::new(row_range.start, 0), Bias::Left, &());
 
         std::iter::from_fn(move || {
             let excerpt = cursor.item()?;
             let multibuffer_start = *cursor.start();
             let multibuffer_end = multibuffer_start + excerpt.text_summary.lines;
-            if multibuffer_start.row >= row_range.end {
-                return None;
-            }
-
             let mut buffer_start = excerpt.range.context.start;
             let mut buffer_end = excerpt.range.context.end;
-            let excerpt_start_point = buffer_start.to_point(&excerpt.buffer);
-            let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines;
 
-            if row_range.start > multibuffer_start.row {
-                let buffer_start_point =
-                    excerpt_start_point + Point::new(row_range.start - multibuffer_start.row, 0);
-                buffer_start = excerpt.buffer.anchor_before(buffer_start_point);
-            }
+            let excerpt_rows = match multibuffer_start.row.cmp(&row_range.end) {
+                cmp::Ordering::Less => {
+                    let excerpt_start_point = buffer_start.to_point(&excerpt.buffer);
+                    let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines;
 
-            if row_range.end < multibuffer_end.row {
-                let buffer_end_point =
-                    excerpt_start_point + Point::new(row_range.end - multibuffer_start.row, 0);
-                buffer_end = excerpt.buffer.anchor_before(buffer_end_point);
-            }
+                    if row_range.start > multibuffer_start.row {
+                        let buffer_start_point = excerpt_start_point
+                            + Point::new(row_range.start - multibuffer_start.row, 0);
+                        buffer_start = excerpt.buffer.anchor_before(buffer_start_point);
+                    }
+
+                    if row_range.end < multibuffer_end.row {
+                        let buffer_end_point = excerpt_start_point
+                            + Point::new(row_range.end - multibuffer_start.row, 0);
+                        buffer_end = excerpt.buffer.anchor_before(buffer_end_point);
+                    }
+                    excerpt_start_point.row..excerpt_end_point.row
+                }
+                cmp::Ordering::Equal if row_range.end == 0 => {
+                    buffer_end = buffer_start;
+                    0..0
+                }
+                cmp::Ordering::Greater | cmp::Ordering::Equal => return None,
+            };
 
             let buffer_hunks = excerpt
                 .buffer
                 .git_diff_hunks_intersecting_range(buffer_start..buffer_end)
                 .map(move |hunk| {
-                    let start = multibuffer_start.row
-                        + hunk
-                            .buffer_range
-                            .start
-                            .saturating_sub(excerpt_start_point.row);
-                    let end = multibuffer_start.row
-                        + hunk
-                            .buffer_range
-                            .end
-                            .min(excerpt_end_point.row + 1)
-                            .saturating_sub(excerpt_start_point.row);
-
+                    let buffer_range = if excerpt_rows.start == 0 && excerpt_rows.end == 0 {
+                        0..1
+                    } else {
+                        let start = multibuffer_start.row
+                            + hunk
+                                .associated_range
+                                .start
+                                .saturating_sub(excerpt_rows.start);
+                        let end = multibuffer_start.row
+                            + hunk
+                                .associated_range
+                                .end
+                                .min(excerpt_rows.end + 1)
+                                .saturating_sub(excerpt_rows.start);
+                        start..end
+                    };
                     DiffHunk {
-                        buffer_range: start..end,
+                        associated_range: buffer_range,
                         diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                        buffer_range: hunk.buffer_range.clone(),
+                        buffer_id: hunk.buffer_id,
                     }
                 });
 

crates/project/src/project.rs 🔗

@@ -4427,7 +4427,6 @@ impl Project {
                                     project_transaction.0.extend(new.0);
                                 }
 
-                                // TODO kb here too:
                                 if let Some(command) = action.lsp_action.command {
                                     project.update(&mut cx, |this, _| {
                                         this.last_workspace_edits_by_language_server