Allow to toggle git hunk diffs (#11080)

Kirill Bulatov created

Part of https://github.com/zed-industries/zed/issues/4523

Added two new actions with the default keybindings

```
"cmd-'": "editor::ToggleHunkDiff",
"cmd-\"": "editor::ExpandAllHunkDiffs",
```

that allow to browse git hunk diffs in Zed:


https://github.com/zed-industries/zed/assets/2690773/9a8a7d10-ed06-4960-b4ee-fe28fc5c4768


The hunks are dynamic and alter on user folds and modifications, or
toggle hidden, if the modifications were not adjacent to the expanded
hunk.


Release Notes:

- Added `editor::ToggleHunkDiff` (`cmd-'`) and
`editor::ExpandAllHunkDiffs` (`cmd-"`) actions to browse git hunk diffs
in Zed

Change summary

Cargo.lock                                 |    2 
assets/keymaps/default-linux.json          |    2 
assets/keymaps/default-macos.json          |    2 
assets/settings/default.json               |   15 
crates/collab/src/tests/editor_tests.rs    |  108 +
crates/editor/src/actions.rs               |    2 
crates/editor/src/display_map/block_map.rs |   47 
crates/editor/src/editor.rs                |  174 +
crates/editor/src/editor_tests.rs          | 1760 +++++++++++++++++++++++
crates/editor/src/element.rs               |  411 +++-
crates/editor/src/git.rs                   |   10 
crates/editor/src/hunk_diff.rs             |  623 ++++++++
crates/editor/src/scroll.rs                |   22 
crates/editor/src/scroll/autoscroll.rs     |   15 
crates/editor/src/test.rs                  |   90 +
crates/git/src/diff.rs                     |    5 
crates/go_to_line/Cargo.toml               |    1 
crates/go_to_line/src/go_to_line.rs        |    6 
crates/language/src/buffer.rs              |   13 
crates/language/src/language_settings.rs   |    2 
crates/multi_buffer/src/multi_buffer.rs    |   31 
crates/outline/Cargo.toml                  |    1 
crates/outline/src/outline.rs              |   10 
crates/project/src/project_settings.rs     |    2 
24 files changed, 3,110 insertions(+), 244 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -4467,6 +4467,7 @@ name = "go_to_line"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "collections",
  "editor",
  "gpui",
  "indoc",
@@ -6787,6 +6788,7 @@ dependencies = [
 name = "outline"
 version = "0.1.0"
 dependencies = [
+ "collections",
  "editor",
  "fuzzy",
  "gpui",

assets/keymaps/default-linux.json πŸ”—

@@ -138,6 +138,8 @@
       "ctrl-alt-space": "editor::ShowCharacterPalette",
       "ctrl-;": "editor::ToggleLineNumbers",
       "ctrl-k ctrl-r": "editor::RevertSelectedHunks",
+      "ctrl-'": "editor::ToggleHunkDiff",
+      "ctrl-\"": "editor::ExpandAllHunkDiffs",
       "ctrl-alt-g b": "editor::ToggleGitBlame"
     }
   },

assets/keymaps/default-macos.json πŸ”—

@@ -159,6 +159,8 @@
       "ctrl-cmd-space": "editor::ShowCharacterPalette",
       "cmd-;": "editor::ToggleLineNumbers",
       "cmd-alt-z": "editor::RevertSelectedHunks",
+      "cmd-'": "editor::ToggleHunkDiff",
+      "cmd-\"": "editor::ExpandAllHunkDiffs",
       "cmd-alt-g b": "editor::ToggleGitBlame"
     }
   },

assets/settings/default.json πŸ”—

@@ -299,7 +299,9 @@
   // The list of language servers to use (or disable) for all languages.
   //
   // This is typically customized on a per-language basis.
-  "language_servers": ["..."],
+  "language_servers": [
+    "..."
+  ],
   // When to automatically save edited buffers. This setting can
   // take four values.
   //
@@ -428,7 +430,9 @@
   "copilot": {
     // The set of glob patterns for which copilot should be disabled
     // in any matching file.
-    "disabled_globs": [".env"]
+    "disabled_globs": [
+      ".env"
+    ]
   },
   // Settings specific to journaling
   "journal": {
@@ -539,7 +543,12 @@
         // Default directories to search for virtual environments, relative
         // to the current working directory. We recommend overriding this
         // in your project's settings, rather than globally.
-        "directories": [".env", "env", ".venv", "venv"],
+        "directories": [
+          ".env",
+          "env",
+          ".venv",
+          "venv"
+        ],
         // Can also be 'csh', 'fish', and `nushell`
         "activate_script": "default"
       }

crates/collab/src/tests/editor_tests.rs πŸ”—

@@ -9,10 +9,15 @@ use editor::{
         ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Redo, Rename, RevertSelectedHunks,
         ToggleCodeActions, Undo,
     },
-    test::editor_test_context::{AssertionContextManager, EditorTestContext},
+    test::{
+        editor_hunks,
+        editor_test_context::{AssertionContextManager, EditorTestContext},
+        expanded_hunks, expanded_hunks_background_highlights,
+    },
     Editor,
 };
 use futures::StreamExt;
+use git::diff::DiffHunkStatus;
 use gpui::{BorrowAppContext, TestAppContext, VisualContext, VisualTestContext};
 use indoc::indoc;
 use language::{
@@ -1875,7 +1880,7 @@ async fn test_inlay_hint_refresh_is_forwarded(
 }
 
 #[gpui::test]
-async fn test_multiple_types_reverts(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+async fn test_multiple_hunk_types_revert(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;
@@ -1997,8 +2002,8 @@ struct Row10;"#};
     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)
+    // the client selects a range in the updated buffer, expands it to see the diff for each hunk in the selection
+    // the host does not see the diffs toggled
     editor_cx_b.set_selections_state(indoc! {r#"Β«Λ‡struct Row;
         struct Row0.1;
         struct Row0.2;
@@ -2010,11 +2015,106 @@ struct Row10;"#};
 
         struct RΒ»ow9;
         struct Row1220;"#});
+    editor_cx_b
+        .update_editor(|editor, cx| editor.toggle_hunk_diff(&editor::actions::ToggleHunkDiff, cx));
+    cx_a.executor().run_until_parked();
+    cx_b.executor().run_until_parked();
+    editor_cx_a.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                ("".to_string(), DiffHunkStatus::Added, 1..3),
+                ("struct Row2;\n".to_string(), DiffHunkStatus::Removed, 4..4),
+                ("struct Row5;\n".to_string(), DiffHunkStatus::Modified, 6..7),
+                ("struct Row8;\n".to_string(), DiffHunkStatus::Removed, 9..9),
+                (
+                    "struct Row10;".to_string(),
+                    DiffHunkStatus::Modified,
+                    10..10,
+                ),
+            ]
+        );
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
+    editor_cx_b.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![1..3, 8..9],
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                ("".to_string(), DiffHunkStatus::Added, 1..3),
+                ("struct Row2;\n".to_string(), DiffHunkStatus::Removed, 5..5),
+                ("struct Row5;\n".to_string(), DiffHunkStatus::Modified, 8..9),
+                (
+                    "struct Row8;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    12..12
+                ),
+                (
+                    "struct Row10;".to_string(),
+                    DiffHunkStatus::Modified,
+                    13..13,
+                ),
+            ]
+        );
+        assert_eq!(all_expanded_hunks, &all_hunks[..all_hunks.len() - 1]);
+    });
+
+    // the client reverts the hunks, removing the expanded diffs too
+    // both host and the client observe the reverted state (with one hunk left, not covered by client's selection)
     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.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "struct Row10;".to_string(),
+                DiffHunkStatus::Modified,
+                10..10,
+            )]
+        );
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
+    editor_cx_b.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "struct Row10;".to_string(),
+                DiffHunkStatus::Modified,
+                10..10,
+            )]
+        );
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
     editor_cx_a.assert_editor_state(indoc! {r#"struct Row;
         struct Row1;
         struct Row2;

crates/editor/src/actions.rs πŸ”—

@@ -253,6 +253,8 @@ gpui::actions!(
         TabPrev,
         ToggleGitBlame,
         ToggleGitBlameInline,
+        ToggleHunkDiff,
+        ExpandAllHunkDiffs,
         ToggleInlayHints,
         ToggleLineNumbers,
         ToggleSoftWrap,

crates/editor/src/display_map/block_map.rs πŸ”—

@@ -364,28 +364,33 @@ impl BlockMap {
                         (position.row(), TransformBlock::Custom(block.clone()))
                     }),
             );
-            blocks_in_edit.extend(
-                buffer
-                    .excerpt_boundaries_in_range((start_bound, end_bound))
-                    .map(|excerpt_boundary| {
-                        (
-                            wrap_snapshot
-                                .make_wrap_point(Point::new(excerpt_boundary.row, 0), Bias::Left)
-                                .row(),
-                            TransformBlock::ExcerptHeader {
-                                id: excerpt_boundary.id,
-                                buffer: excerpt_boundary.buffer,
-                                range: excerpt_boundary.range,
-                                height: if excerpt_boundary.starts_new_buffer {
-                                    self.buffer_header_height
-                                } else {
-                                    self.excerpt_header_height
+            if buffer.show_headers() {
+                blocks_in_edit.extend(
+                    buffer
+                        .excerpt_boundaries_in_range((start_bound, end_bound))
+                        .map(|excerpt_boundary| {
+                            (
+                                wrap_snapshot
+                                    .make_wrap_point(
+                                        Point::new(excerpt_boundary.row, 0),
+                                        Bias::Left,
+                                    )
+                                    .row(),
+                                TransformBlock::ExcerptHeader {
+                                    id: excerpt_boundary.id,
+                                    buffer: excerpt_boundary.buffer,
+                                    range: excerpt_boundary.range,
+                                    height: if excerpt_boundary.starts_new_buffer {
+                                        self.buffer_header_height
+                                    } else {
+                                        self.excerpt_header_height
+                                    },
+                                    starts_new_buffer: excerpt_boundary.starts_new_buffer,
                                 },
-                                starts_new_buffer: excerpt_boundary.starts_new_buffer,
-                            },
-                        )
-                    }),
-            );
+                            )
+                        }),
+                );
+            }
 
             // Place excerpt headers above custom blocks on the same row.
             blocks_in_edit.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| {

crates/editor/src/editor.rs πŸ”—

@@ -18,6 +18,7 @@ mod blink_manager;
 pub mod display_map;
 mod editor_settings;
 mod element;
+mod hunk_diff;
 mod inlay_hint_cache;
 
 mod debounced_delay;
@@ -71,6 +72,8 @@ use gpui::{
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
+use hunk_diff::ExpandedHunks;
+pub(crate) use hunk_diff::HunkToExpand;
 use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
 pub use inline_completion_provider::*;
 pub use items::MAX_TAB_TITLE_LEN;
@@ -230,6 +233,7 @@ impl InlayId {
     }
 }
 
+enum DiffRowHighlight {}
 enum DocumentHighlightRead {}
 enum DocumentHighlightWrite {}
 enum InputComposition {}
@@ -325,6 +329,7 @@ pub enum EditorMode {
 #[derive(Clone, Debug)]
 pub enum SoftWrap {
     None,
+    PreferLine,
     EditorWidth,
     Column(u32),
 }
@@ -458,6 +463,7 @@ pub struct Editor {
     active_inline_completion: Option<Inlay>,
     show_inline_completions: bool,
     inlay_hint_cache: InlayHintCache,
+    expanded_hunks: ExpandedHunks,
     next_inlay_id: usize,
     _subscriptions: Vec<Subscription>,
     pixel_position_of_newest_cursor: Option<gpui::Point<Pixels>>,
@@ -1410,7 +1416,7 @@ impl Editor {
         let blink_manager = cx.new_model(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx));
 
         let soft_wrap_mode_override =
-            (mode == EditorMode::SingleLine).then(|| language_settings::SoftWrap::None);
+            (mode == EditorMode::SingleLine).then(|| language_settings::SoftWrap::PreferLine);
 
         let mut project_subscriptions = Vec::new();
         if mode == EditorMode::Full {
@@ -1499,6 +1505,7 @@ impl Editor {
             inline_completion_provider: None,
             active_inline_completion: None,
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
+            expanded_hunks: ExpandedHunks::default(),
             gutter_hovered: false,
             pixel_position_of_newest_cursor: None,
             last_bounds: None,
@@ -2379,6 +2386,7 @@ impl Editor {
     }
 
     pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        self.clear_expanded_diff_hunks(cx);
         if self.dismiss_menus_and_popups(cx) {
             return;
         }
@@ -5000,48 +5008,8 @@ impl Editor {
         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);
-                    }
-                }
+            for hunk in hunks_for_selections(&multi_buffer_snapshot, selections) {
+                Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx);
             }
         });
         revert_changes
@@ -7674,7 +7642,7 @@ impl Editor {
     ) -> bool {
         let display_point = initial_point.to_display_point(snapshot);
         let mut hunks = hunks
-            .map(|hunk| diff_hunk_to_display(hunk, &snapshot))
+            .map(|hunk| diff_hunk_to_display(&hunk, &snapshot))
             .filter(|hunk| {
                 if is_wrapped {
                     true
@@ -8765,7 +8733,17 @@ impl Editor {
         auto_scroll: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        let mut ranges = ranges.into_iter().peekable();
+        let mut fold_ranges = Vec::new();
+        let mut buffers_affected = HashMap::default();
+        let multi_buffer = self.buffer().read(cx);
+        for range in ranges {
+            if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) {
+                buffers_affected.insert(buffer.read(cx).remote_id(), buffer);
+            };
+            fold_ranges.push(range);
+        }
+
+        let mut ranges = fold_ranges.into_iter().peekable();
         if ranges.peek().is_some() {
             self.display_map.update(cx, |map, cx| map.fold(ranges, cx));
 
@@ -8773,6 +8751,10 @@ impl Editor {
                 self.request_autoscroll(Autoscroll::fit(), cx);
             }
 
+            for buffer in buffers_affected.into_values() {
+                self.sync_expanded_diff_hunks(buffer, cx);
+            }
+
             cx.notify();
 
             if let Some(active_diagnostics) = self.active_diagnostics.take() {
@@ -8796,7 +8778,17 @@ impl Editor {
         auto_scroll: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        let mut ranges = ranges.into_iter().peekable();
+        let mut unfold_ranges = Vec::new();
+        let mut buffers_affected = HashMap::default();
+        let multi_buffer = self.buffer().read(cx);
+        for range in ranges {
+            if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) {
+                buffers_affected.insert(buffer.read(cx).remote_id(), buffer);
+            };
+            unfold_ranges.push(range);
+        }
+
+        let mut ranges = unfold_ranges.into_iter().peekable();
         if ranges.peek().is_some() {
             self.display_map
                 .update(cx, |map, cx| map.unfold(ranges, inclusive, cx));
@@ -8804,6 +8796,10 @@ impl Editor {
                 self.request_autoscroll(Autoscroll::fit(), cx);
             }
 
+            for buffer in buffers_affected.into_values() {
+                self.sync_expanded_diff_hunks(buffer, cx);
+            }
+
             cx.notify();
         }
     }
@@ -8925,6 +8921,7 @@ impl Editor {
             .unwrap_or_else(|| settings.soft_wrap);
         match mode {
             language_settings::SoftWrap::None => SoftWrap::None,
+            language_settings::SoftWrap::PreferLine => SoftWrap::PreferLine,
             language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth,
             language_settings::SoftWrap::PreferredLineLength => {
                 SoftWrap::Column(settings.preferred_line_length)
@@ -8969,8 +8966,10 @@ impl Editor {
             self.soft_wrap_mode_override.take();
         } else {
             let soft_wrap = match self.soft_wrap_mode(cx) {
-                SoftWrap::None => language_settings::SoftWrap::EditorWidth,
-                SoftWrap::EditorWidth | SoftWrap::Column(_) => language_settings::SoftWrap::None,
+                SoftWrap::None | SoftWrap::PreferLine => language_settings::SoftWrap::EditorWidth,
+                SoftWrap::EditorWidth | SoftWrap::Column(_) => {
+                    language_settings::SoftWrap::PreferLine
+                }
             };
             self.soft_wrap_mode_override = Some(soft_wrap);
         }
@@ -9266,13 +9265,19 @@ impl Editor {
         )
     }
 
-    // Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict.
-    // Rerturns a map of display rows that are highlighted and their corresponding highlight color.
-    pub fn highlighted_display_rows(&mut self, cx: &mut WindowContext) -> BTreeMap<u32, Hsla> {
+    /// Merges all anchor ranges for all context types ever set, picking the last highlight added in case of a row conflict.
+    /// Rerturns a map of display rows that are highlighted and their corresponding highlight color.
+    /// Allows to ignore certain kinds of highlights.
+    pub fn highlighted_display_rows(
+        &mut self,
+        exclude_highlights: HashSet<TypeId>,
+        cx: &mut WindowContext,
+    ) -> BTreeMap<u32, Hsla> {
         let snapshot = self.snapshot(cx);
         let mut used_highlight_orders = HashMap::default();
         self.highlighted_rows
             .iter()
+            .filter(|(type_id, _)| !exclude_highlights.contains(type_id))
             .flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
             .fold(
                 BTreeMap::<u32, Hsla>::new(),
@@ -9663,6 +9668,10 @@ impl Editor {
                 cx.emit(EditorEvent::DiffBaseChanged);
                 cx.notify();
             }
+            multi_buffer::Event::DiffUpdated { buffer } => {
+                self.sync_expanded_diff_hunks(buffer.clone(), cx);
+                cx.notify();
+            }
             multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed),
             multi_buffer::Event::DiagnosticsUpdated => {
                 self.refresh_active_diagnostics(cx);
@@ -10102,6 +10111,57 @@ impl Editor {
     }
 }
 
+fn hunks_for_selections(
+    multi_buffer_snapshot: &MultiBufferSnapshot,
+    selections: &[Selection<Anchor>],
+) -> Vec<DiffHunk<u32>> {
+    let mut hunks = Vec::with_capacity(selections.len());
+    let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =
+        HashMap::default();
+    let display_rows_for_selections = 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
+        }
+    });
+
+    for selected_multi_buffer_rows in display_rows_for_selections {
+        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;
+                }
+                hunks.push(hunk);
+            }
+        }
+    }
+
+    hunks
+}
+
 pub trait CollaborationHub {
     fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap<PeerId, Collaborator>;
     fn user_participant_indices<'a>(
@@ -10300,8 +10360,8 @@ impl EditorSnapshot {
             Some(GitGutterSetting::TrackedFiles)
         );
         let gutter_settings = EditorSettings::get_global(cx).gutter;
-
-        let line_gutter_width = if gutter_settings.line_numbers {
+        let gutter_lines_enabled = gutter_settings.line_numbers;
+        let line_gutter_width = if gutter_lines_enabled {
             // Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines.
             let min_width_for_number_on_gutter = em_width * 4.0;
             max_line_number_width.max(min_width_for_number_on_gutter)
@@ -10316,19 +10376,19 @@ impl EditorSnapshot {
         let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
         left_padding += if gutter_settings.code_actions {
             em_width * 3.0
-        } else if show_git_gutter && gutter_settings.line_numbers {
+        } else if show_git_gutter && gutter_lines_enabled {
             em_width * 2.0
-        } else if show_git_gutter || gutter_settings.line_numbers {
+        } else if show_git_gutter || gutter_lines_enabled {
             em_width
         } else {
             px(0.)
         };
 
-        let right_padding = if gutter_settings.folds && gutter_settings.line_numbers {
+        let right_padding = if gutter_settings.folds && gutter_lines_enabled {
             em_width * 4.0
         } else if gutter_settings.folds {
             em_width * 3.0
-        } else if gutter_settings.line_numbers {
+        } else if gutter_lines_enabled {
             em_width
         } else {
             px(0.)

crates/editor/src/editor_tests.rs πŸ”—

@@ -2,8 +2,9 @@ use super::*;
 use crate::{
     scroll::scroll_amount::ScrollAmount,
     test::{
-        assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext,
-        editor_test_context::EditorTestContext, select_ranges,
+        assert_text_with_selections, build_editor, editor_hunks,
+        editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext,
+        expanded_hunks, expanded_hunks_background_highlights, select_ranges,
     },
     JoinLines,
 };
@@ -9327,6 +9328,1761 @@ async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) {
         .unwrap();
 }
 
+#[gpui::test]
+async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod;
+
+        const A: u32 = 42;
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        use some::modified;
+
+        Λ‡
+        fn main() {
+            println!("hello there");
+
+            println!("around the");
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    let unexpanded_hunks = vec![
+        (
+            "use some::mod;\n".to_string(),
+            DiffHunkStatus::Modified,
+            0..1,
+        ),
+        (
+            "const A: u32 = 42;\n".to_string(),
+            DiffHunkStatus::Removed,
+            2..2,
+        ),
+        (
+            "    println!(\"hello\");\n".to_string(),
+            DiffHunkStatus::Modified,
+            4..5,
+        ),
+        ("".to_string(), DiffHunkStatus::Added, 6..7),
+    ];
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(all_hunks, unexpanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        for _ in 0..4 {
+            editor.go_to_hunk(&GoToHunk, cx);
+            editor.toggle_hunk_diff(&ToggleHunkDiff, cx);
+        }
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::modified;
+
+        Λ‡
+        fn main() {
+            println!("hello there");
+
+            println!("around the");
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![1..2, 7..8, 9..10],
+            "After expanding, all git additions should be highlighted for Modified (split into added and removed) and Added hunks"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                ("use some::mod;\n".to_string(), DiffHunkStatus::Modified, 1..2),
+                ("const A: u32 = 42;\n".to_string(), DiffHunkStatus::Removed, 4..4),
+                ("    println!(\"hello\");\n".to_string(), DiffHunkStatus::Modified, 7..8),
+                ("".to_string(), DiffHunkStatus::Added, 9..10),
+            ],
+            "After expanding, all hunks' display rows should have shifted by the amount of deleted lines added \
+            (from modified and removed hunks)"
+        );
+        assert_eq!(
+            all_hunks, all_expanded_hunks,
+            "Editor hunks should not change and all be expanded"
+        );
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.cancel(&Cancel, cx);
+
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+            "After cancelling in editor, no git highlights should be left"
+        );
+        assert_eq!(
+            all_expanded_hunks,
+            Vec::new(),
+            "After cancelling in editor, no hunks should be expanded"
+        );
+        assert_eq!(
+            all_hunks, unexpanded_hunks,
+            "After cancelling in editor, regular hunks' coordinates should get back to normal"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_toggled_diff_base_change(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+
+        fn main(Λ‡) {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main(Λ‡) {
+            //println!("hello");
+
+            println!("world");
+            //
+            //
+        }
+        "#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    0..0
+                ),
+                (
+                    "const B: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    3..3
+                ),
+                (
+                    "fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(),
+                    DiffHunkStatus::Modified,
+                    5..7
+                ),
+                ("".to_string(), DiffHunkStatus::Added, 9..11),
+            ]
+        );
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main(Λ‡) {
+            //println!("hello");
+
+            println!("world");
+            //
+            //
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![9..11, 13..15],
+            "After expanding, all git additions should be highlighted for Modified (split into added and removed) and Added hunks"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                ("use some::mod1;\n".to_string(), DiffHunkStatus::Removed, 1..1),
+                ("const B: u32 = 42;\n".to_string(), DiffHunkStatus::Removed, 5..5),
+                ("fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(), DiffHunkStatus::Modified, 9..11),
+                ("".to_string(), DiffHunkStatus::Added, 13..15),
+            ],
+            "After expanding, all hunks' display rows should have shifted by the amount of deleted lines added \
+            (from modified and removed hunks)"
+        );
+        assert_eq!(
+            all_hunks, all_expanded_hunks,
+            "Editor hunks should not change and all be expanded"
+        );
+    });
+
+    cx.set_diff_base(Some("new diff base!"));
+    executor.run_until_parked();
+
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+            "After diff base is changed, old git highlights should be removed"
+        );
+        assert_eq!(
+            all_expanded_hunks,
+            Vec::new(),
+            "After diff base is changed, old git hunk expansions should be removed"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "new diff base!".to_string(),
+                DiffHunkStatus::Modified,
+                0..snapshot.display_snapshot.max_point().row()
+            )],
+            "After diff base is changed, hunks should update"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_fold_unfold_diff(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+
+        fn main(Λ‡) {
+            println!("hello");
+
+            println!("world");
+        }
+
+        fn another() {
+            println!("another");
+        }
+
+        fn another2() {
+            println!("another2");
+        }
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        Β«use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main() {
+            //println!("hello");
+
+            println!("world");
+            //
+            //Λ‡Β»
+        }
+
+        fn another() {
+            println!("another");
+            println!("another");
+        }
+
+            println!("another2");
+        }
+        "#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    0..0
+                ),
+                (
+                    "const B: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    3..3
+                ),
+                (
+                    "fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(),
+                    DiffHunkStatus::Modified,
+                    5..7
+                ),
+                ("".to_string(), DiffHunkStatus::Added, 9..11),
+                ("".to_string(), DiffHunkStatus::Added, 15..16),
+                (
+                    "fn another2() {\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    18..18
+                ),
+            ]
+        );
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        Β«use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main() {
+            //println!("hello");
+
+            println!("world");
+            //
+            //Λ‡Β»
+        }
+
+        fn another() {
+            println!("another");
+            println!("another");
+        }
+
+            println!("another2");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![9..11, 13..15, 19..20]
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    1..1
+                ),
+                (
+                    "const B: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    5..5
+                ),
+                (
+                    "fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(),
+                    DiffHunkStatus::Modified,
+                    9..11
+                ),
+                ("".to_string(), DiffHunkStatus::Added, 13..15),
+                ("".to_string(), DiffHunkStatus::Added, 19..20),
+                (
+                    "fn another2() {\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    23..23
+                ),
+            ],
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| editor.fold_selected_ranges(&FoldSelectedRanges, cx));
+    cx.executor().run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        Β«use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main() {
+            //println!("hello");
+
+            println!("world");
+            //
+            //Λ‡Β»
+        }
+
+        fn another() {
+            println!("another");
+            println!("another");
+        }
+
+            println!("another2");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![5..6],
+            "Only one hunk is left not folded, its highlight should be visible"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    0..0
+                ),
+                (
+                    "const B: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    0..0
+                ),
+                (
+                    "fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(),
+                    DiffHunkStatus::Modified,
+                    0..0
+                ),
+                ("".to_string(), DiffHunkStatus::Added, 0..1),
+                ("".to_string(), DiffHunkStatus::Added, 5..6),
+                (
+                    "fn another2() {\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    9..9
+                ),
+            ],
+            "Hunk list should still return shifted folded hunks"
+        );
+        assert_eq!(
+            all_expanded_hunks,
+            vec![
+                ("".to_string(), DiffHunkStatus::Added, 5..6),
+                (
+                    "fn another2() {\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    9..9
+                ),
+            ],
+            "Only non-folded hunks should be left expanded"
+        );
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.select_all(&SelectAll, cx);
+        editor.unfold_lines(&UnfoldLines, cx);
+    });
+    cx.executor().run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        Β«use some::mod2;
+
+        const A: u32 = 42;
+        const C: u32 = 42;
+
+        fn main() {
+            //println!("hello");
+
+            println!("world");
+            //
+            //
+        }
+
+        fn another() {
+            println!("another");
+            println!("another");
+        }
+
+            println!("another2");
+        }
+        Λ‡Β»"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![9..11, 13..15, 19..20],
+            "After unfolding, all hunk diffs should be visible again"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    1..1
+                ),
+                (
+                    "const B: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    5..5
+                ),
+                (
+                    "fn main(Λ‡) {\n    println!(\"hello\");\n".to_string(),
+                    DiffHunkStatus::Modified,
+                    9..11
+                ),
+                ("".to_string(), DiffHunkStatus::Added, 13..15),
+                ("".to_string(), DiffHunkStatus::Added, 19..20),
+                (
+                    "fn another2() {\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    23..23
+                ),
+            ],
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+}
+
+#[gpui::test]
+async fn test_toggle_diff_expand_in_multi_buffer(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 modified_sample_text_1 = "aaaa\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 modified_sample_text_2 = "llll\nmmmm\n1n1n1n1n1\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}"
+    );
+    let modified_sample_text_3 =
+        "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n@@@@\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}";
+    let buffer_1 = cx.new_model(|cx| {
+        let mut buffer = Buffer::local(modified_sample_text_1.to_string(), cx);
+        buffer.set_diff_base(Some(sample_text_1.clone()), cx);
+        buffer
+    });
+    let buffer_2 = cx.new_model(|cx| {
+        let mut buffer = Buffer::local(modified_sample_text_2.to_string(), cx);
+        buffer.set_diff_base(Some(sample_text_2.clone()), cx);
+        buffer
+    });
+    let buffer_3 = cx.new_model(|cx| {
+        let mut buffer = Buffer::local(modified_sample_text_3.to_string(), cx);
+        buffer.set_diff_base(Some(sample_text_3.clone()), cx);
+        buffer
+    });
+
+    let multi_buffer = 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 fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/a",
+        json!({
+            "main.rs": modified_sample_text_1,
+            "other.rs": modified_sample_text_2,
+            "lib.rs": modified_sample_text_3,
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, ["/a".as_ref()], cx).await;
+    let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+    let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
+    let multi_buffer_editor =
+        cx.new_view(|cx| Editor::new(EditorMode::Full, multi_buffer, Some(project.clone()), cx));
+    cx.executor().run_until_parked();
+
+    let expected_all_hunks = vec![
+        ("bbbb\n".to_string(), DiffHunkStatus::Removed, 3..3),
+        ("nnnn\n".to_string(), DiffHunkStatus::Modified, 16..17),
+        ("".to_string(), DiffHunkStatus::Added, 31..32),
+    ];
+    let expected_all_hunks_shifted = vec![
+        ("bbbb\n".to_string(), DiffHunkStatus::Removed, 4..4),
+        ("nnnn\n".to_string(), DiffHunkStatus::Modified, 18..19),
+        ("".to_string(), DiffHunkStatus::Added, 33..34),
+    ];
+
+    multi_buffer_editor.update(cx, |editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(all_hunks, expected_all_hunks);
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
+
+    multi_buffer_editor.update(cx, |editor, cx| {
+        editor.select_all(&SelectAll, cx);
+        editor.toggle_hunk_diff(&ToggleHunkDiff, cx);
+    });
+    cx.executor().run_until_parked();
+    multi_buffer_editor.update(cx, |editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![18..19, 33..34],
+        );
+        assert_eq!(all_hunks, expected_all_hunks_shifted);
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    multi_buffer_editor.update(cx, |editor, cx| {
+        editor.toggle_hunk_diff(&ToggleHunkDiff, cx);
+    });
+    cx.executor().run_until_parked();
+    multi_buffer_editor.update(cx, |editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(all_hunks, expected_all_hunks);
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
+
+    multi_buffer_editor.update(cx, |editor, cx| {
+        editor.toggle_hunk_diff(&ToggleHunkDiff, cx);
+    });
+    cx.executor().run_until_parked();
+    multi_buffer_editor.update(cx, |editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![18..19, 33..34],
+        );
+        assert_eq!(all_hunks, expected_all_hunks_shifted);
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    multi_buffer_editor.update(cx, |editor, cx| {
+        editor.toggle_hunk_diff(&ToggleHunkDiff, cx);
+    });
+    cx.executor().run_until_parked();
+    multi_buffer_editor.update(cx, |editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+        );
+        assert_eq!(all_hunks, expected_all_hunks);
+        assert_eq!(all_expanded_hunks, Vec::new());
+    });
+}
+
+#[gpui::test]
+async fn test_edits_around_toggled_additions(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+    .unindent();
+    executor.run_until_parked();
+    cx.set_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 4..7)]
+        );
+    });
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 4..7)]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![4..7]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| editor.handle_input("const D: u32 = 42;\n", cx));
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        const D: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 4..8)]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![4..8],
+            "Edited hunk should have one more line added"
+        );
+        assert_eq!(
+            all_hunks, all_expanded_hunks,
+            "Expanded hunk should also grow with the addition"
+        );
+    });
+
+    cx.update_editor(|editor, cx| editor.handle_input("const E: u32 = 42;\n", cx));
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        const D: u32 = 42;
+        const E: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 4..9)]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![4..9],
+            "Edited hunk should have one more line added"
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_up(&MoveUp, cx);
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        const D: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 4..8)]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![4..8],
+            "Deleting a line should shrint the hunk"
+        );
+        assert_eq!(
+            all_hunks, all_expanded_hunks,
+            "Expanded hunk should also shrink with the addition"
+        );
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_up(&MoveUp, cx);
+        editor.delete_line(&DeleteLine, cx);
+        editor.move_up(&MoveUp, cx);
+        editor.delete_line(&DeleteLine, cx);
+        editor.move_up(&MoveUp, cx);
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![("".to_string(), DiffHunkStatus::Added, 5..6)]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![5..6]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.select_up_by_lines(&SelectUpByLines { lines: 5 }, cx);
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![
+                (
+                    "use some::mod1;\nuse some::mod2;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    0..0
+                ),
+                (
+                    "const A: u32 = 42;\n".to_string(),
+                    DiffHunkStatus::Removed,
+                    2..2
+                )
+            ]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+            "Should close all stale expanded addition hunks"
+        );
+        assert_eq!(
+            all_expanded_hunks,
+            vec![(
+                "const A: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Removed,
+                2..2
+            )],
+            "Should open hunks that were adjacent to the stale addition one"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_edits_around_toggled_deletions(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+    .unindent();
+    executor.run_until_parked();
+    cx.set_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        Λ‡const B: u32 = 42;
+        const C: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Removed,
+                3..3
+            )]
+        );
+    });
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        Λ‡const B: u32 = 42;
+        const C: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new()
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Removed,
+                4..4
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        Λ‡const C: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+            "Deleted hunks do not highlight current editor's background"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Removed,
+                5..5
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        Λ‡
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new()
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\nconst C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Removed,
+                6..6
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("replacement", cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        replacementˇ
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\nconst C: u32 = 42;\n\n".to_string(),
+                DiffHunkStatus::Modified,
+                7..8
+            )]
+        );
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![7..8],
+            "Modified expanded hunks should display additions and highlight their background"
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+}
+
+#[gpui::test]
+async fn test_edits_around_toggled_modifications(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+    .unindent();
+    executor.run_until_parked();
+    cx.set_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 43Λ‡
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                5..6
+            )]
+        );
+    });
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 43Λ‡
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..7],
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..7
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("\nnew_line\n", cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            const A: u32 = 42;
+            const B: u32 = 42;
+            const C: u32 = 43
+            new_line
+            Λ‡
+            const D: u32 = 42;
+
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..9],
+            "Modified hunk should grow highlighted lines on more text additions"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..9
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_up(&MoveUp, cx);
+        editor.move_up(&MoveUp, cx);
+        editor.move_up(&MoveUp, cx);
+        editor.delete_line(&DeleteLine, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            const A: u32 = 42;
+            Λ‡const C: u32 = 43
+            new_line
+
+            const D: u32 = 42;
+
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..9],
+            "Modified hunk should grow deleted lines on text deletions above"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const B: u32 = 42;\nconst C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..9
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_up(&MoveUp, cx);
+        editor.handle_input("v", cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            vˇconst A: u32 = 42;
+            const C: u32 = 43
+            new_line
+
+            const D: u32 = 42;
+
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..10],
+            "Modified hunk should grow deleted lines on text modifications above"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\nconst C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..10
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_down(&MoveDown, cx);
+        editor.move_down(&MoveDown, cx);
+        editor.delete_line(&DeleteLine, cx)
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            vconst A: u32 = 42;
+            const C: u32 = 43
+            Λ‡
+            const D: u32 = 42;
+
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..9],
+            "Modified hunk should grow shrink lines on modification lines removal"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\nconst C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..9
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.move_up(&MoveUp, cx);
+        editor.move_up(&MoveUp, cx);
+        editor.select_down_by_lines(&SelectDownByLines { lines: 4 }, cx);
+        editor.delete_line(&DeleteLine, cx)
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            Λ‡
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            Vec::new(),
+            "Modified hunk should turn into a removed one on all modified lines removal"
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const A: u32 = 42;\nconst B: u32 = 42;\nconst C: u32 = 42;\nconst D: u32 = 42;\n"
+                    .to_string(),
+                DiffHunkStatus::Removed,
+                7..7
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+}
+
+#[gpui::test]
+async fn test_multiple_expanded_hunks_merge(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 42;
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+    .unindent();
+    executor.run_until_parked();
+    cx.set_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 43Λ‡
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+        .unindent(),
+    );
+
+    cx.set_diff_base(Some(&diff_base));
+    executor.run_until_parked();
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                5..6
+            )]
+        );
+    });
+    cx.update_editor(|editor, cx| {
+        editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+        use some::mod1;
+        use some::mod2;
+
+        const A: u32 = 42;
+        const B: u32 = 42;
+        const C: u32 = 43Λ‡
+        const D: u32 = 42;
+
+
+        fn main() {
+            println!("hello");
+
+            println!("world");
+        }"#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let all_hunks = editor_hunks(editor, &snapshot, cx);
+        let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
+        assert_eq!(
+            expanded_hunks_background_highlights(editor, &snapshot),
+            vec![6..7],
+        );
+        assert_eq!(
+            all_hunks,
+            vec![(
+                "const C: u32 = 42;\n".to_string(),
+                DiffHunkStatus::Modified,
+                6..7
+            )]
+        );
+        assert_eq!(all_hunks, all_expanded_hunks);
+    });
+
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("\nnew_line\n", cx);
+    });
+    executor.run_until_parked();
+    cx.assert_editor_state(
+        &r#"
+            use some::mod1;
+            use some::mod2;
+
+            const A: u32 = 42;
+            const B: u32 = 42;
+            const C: u32 = 43
+            new_line
+            Λ‡
+            const D: u32 = 42;
+
+
+            fn main() {
+                println!("hello");
+
+                println!("world");
+            }"#
+        .unindent(),
+    );
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(row as u32, column as u32);
     point..point

crates/editor/src/element.rs πŸ”—

@@ -14,12 +14,12 @@ use crate::{
     scroll::scroll_amount::ScrollAmount,
     CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode,
     EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, GutterDimensions, HalfPageDown,
-    HalfPageUp, HoveredCursor, LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point,
-    SelectPhase, Selection, SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
+    HalfPageUp, HoveredCursor, HunkToExpand, LineDown, LineUp, OpenExcerpts, PageDown, PageUp,
+    Point, SelectPhase, Selection, SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
 };
 use anyhow::Result;
 use client::ParticipantIndex;
-use collections::{BTreeMap, HashMap};
+use collections::{BTreeMap, HashMap, HashSet};
 use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
 use gpui::{
     anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
@@ -312,6 +312,8 @@ impl EditorElement {
         register_action(view, cx, Editor::open_permalink_to_line);
         register_action(view, cx, Editor::toggle_git_blame);
         register_action(view, cx, Editor::toggle_git_blame_inline);
+        register_action(view, cx, Editor::toggle_hunk_diff);
+        register_action(view, cx, Editor::expand_all_hunk_diffs);
         register_action(view, cx, |editor, action, cx| {
             if let Some(task) = editor.format(action, cx) {
                 task.detach_and_log_err(cx);
@@ -411,6 +413,7 @@ impl EditorElement {
     fn mouse_left_down(
         editor: &mut Editor,
         event: &MouseDownEvent,
+        hovered_hunk: Option<&HunkToExpand>,
         position_map: &PositionMap,
         text_hitbox: &Hitbox,
         gutter_hitbox: &Hitbox,
@@ -425,6 +428,8 @@ impl EditorElement {
 
         if gutter_hitbox.is_hovered(cx) {
             click_count = 3; // Simulate triple-click when clicking the gutter to select lines
+        } else if let Some(hovered_hunk) = hovered_hunk {
+            editor.expand_diff_hunk(None, hovered_hunk, cx);
         } else if !text_hitbox.is_hovered(cx) {
             return;
         }
@@ -1162,13 +1167,16 @@ impl EditorElement {
         indicators
     }
 
-    //Folds contained in a hunk are ignored apart from shrinking visual size
-    //If a fold contains any hunks then that fold line is marked as modified
+    // Folds contained in a hunk are ignored apart from shrinking visual size
+    // If a fold contains any hunks then that fold line is marked as modified
     fn layout_git_gutters(
         &self,
+        line_height: Pixels,
+        gutter_hitbox: &Hitbox,
         display_rows: Range<u32>,
         snapshot: &EditorSnapshot,
-    ) -> Vec<DisplayDiffHunk> {
+        cx: &mut WindowContext,
+    ) -> Vec<(DisplayDiffHunk, Option<Hitbox>)> {
         let buffer_snapshot = &snapshot.buffer_snapshot;
 
         let buffer_start_row = DisplayPoint::new(display_rows.start, 0)
@@ -1178,10 +1186,55 @@ impl EditorElement {
             .to_point(snapshot)
             .row;
 
+        let expanded_hunk_display_rows = self.editor.update(cx, |editor, _| {
+            editor
+                .expanded_hunks
+                .hunks(false)
+                .map(|expanded_hunk| {
+                    let start_row = expanded_hunk
+                        .hunk_range
+                        .start
+                        .to_display_point(snapshot)
+                        .row();
+                    let end_row = expanded_hunk
+                        .hunk_range
+                        .end
+                        .to_display_point(snapshot)
+                        .row();
+                    (start_row, end_row)
+                })
+                .collect::<HashMap<_, _>>()
+        });
+
         buffer_snapshot
             .git_diff_hunks_in_range(buffer_start_row..buffer_end_row)
-            .map(|hunk| diff_hunk_to_display(hunk, snapshot))
+            .map(|hunk| diff_hunk_to_display(&hunk, snapshot))
             .dedup()
+            .map(|hunk| {
+                let hitbox = if let DisplayDiffHunk::Unfolded {
+                    display_row_range, ..
+                } = &hunk
+                {
+                    let was_expanded = expanded_hunk_display_rows
+                        .get(&display_row_range.start)
+                        .map(|expanded_end_row| expanded_end_row == &display_row_range.end)
+                        .unwrap_or(false);
+                    if was_expanded {
+                        None
+                    } else {
+                        let hunk_bounds = Self::diff_hunk_bounds(
+                            &snapshot,
+                            line_height,
+                            gutter_hitbox.bounds,
+                            &hunk,
+                        );
+                        Some(cx.insert_hitbox(hunk_bounds, true))
+                    }
+                } else {
+                    None
+                };
+                (hunk, hitbox)
+            })
             .collect()
     }
 
@@ -2187,39 +2240,30 @@ impl EditorElement {
                         cx.paint_quad(fill(Bounds { origin, size }, color));
                     };
 
-                let mut last_row = None;
-                let mut highlight_row_start = 0u32;
-                let mut highlight_row_end = 0u32;
-                for (&row, &color) in &layout.highlighted_rows {
-                    let paint = last_row.map_or(false, |(last_row, last_color)| {
-                        last_color != color || last_row + 1 < row
-                    });
-
-                    if paint {
-                        let paint_range_is_unfinished = highlight_row_end == 0;
-                        if paint_range_is_unfinished {
-                            highlight_row_end = row;
-                            last_row = None;
-                        }
-                        paint_highlight(highlight_row_start, highlight_row_end, color);
-                        highlight_row_start = 0;
-                        highlight_row_end = 0;
-                        if !paint_range_is_unfinished {
-                            highlight_row_start = row;
-                            last_row = Some((row, color));
-                        }
-                    } else {
-                        if last_row.is_none() {
-                            highlight_row_start = row;
-                        } else {
-                            highlight_row_end = row;
+                let mut current_paint: Option<(Hsla, Range<u32>)> = None;
+                for (&new_row, &new_color) in &layout.highlighted_rows {
+                    match &mut current_paint {
+                        Some((current_color, current_range)) => {
+                            let current_color = *current_color;
+                            let new_range_started =
+                                current_color != new_color || current_range.end + 1 != new_row;
+                            if new_range_started {
+                                paint_highlight(
+                                    current_range.start,
+                                    current_range.end,
+                                    current_color,
+                                );
+                                current_paint = Some((new_color, new_row..new_row));
+                                continue;
+                            } else {
+                                current_range.end += 1;
+                            }
                         }
-                        last_row = Some((row, color));
-                    }
+                        None => current_paint = Some((new_color, new_row..new_row)),
+                    };
                 }
-                if let Some((row, hsla)) = last_row {
-                    highlight_row_end = row;
-                    paint_highlight(highlight_row_start, highlight_row_end, hsla);
+                if let Some((color, range)) = current_paint {
+                    paint_highlight(range.start, range.end, color);
                 }
 
                 let scroll_left =
@@ -2265,14 +2309,18 @@ impl EditorElement {
         let scroll_top = scroll_position.y * line_height;
 
         cx.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox);
+        for (_, hunk_hitbox) in &layout.display_hunks {
+            if let Some(hunk_hitbox) = hunk_hitbox {
+                cx.set_cursor_style(CursorStyle::PointingHand, hunk_hitbox);
+            }
+        }
 
         let show_git_gutter = matches!(
             ProjectSettings::get_global(cx).git.git_gutter,
             Some(GitGutterSetting::TrackedFiles)
         );
-
         if show_git_gutter {
-            Self::paint_diff_hunks(layout, cx);
+            Self::paint_diff_hunks(layout.gutter_hitbox.bounds, layout, cx)
         }
 
         if layout.blamed_display_rows.is_some() {
@@ -2303,113 +2351,135 @@ impl EditorElement {
             if let Some(indicator) = layout.code_actions_indicator.as_mut() {
                 indicator.paint(cx);
             }
-        })
+        });
     }
 
-    fn paint_diff_hunks(layout: &EditorLayout, cx: &mut WindowContext) {
+    fn paint_diff_hunks(
+        gutter_bounds: Bounds<Pixels>,
+        layout: &EditorLayout,
+        cx: &mut WindowContext,
+    ) {
         if layout.display_hunks.is_empty() {
             return;
         }
 
         let line_height = layout.position_map.line_height;
-
-        let scroll_position = layout.position_map.snapshot.scroll_position();
-        let scroll_top = scroll_position.y * line_height;
-
         cx.paint_layer(layout.gutter_hitbox.bounds, |cx| {
-            for hunk in &layout.display_hunks {
-                let (display_row_range, status) = match hunk {
-                    //TODO: This rendering is entirely a horrible hack
-                    &DisplayDiffHunk::Folded { display_row: row } => {
-                        let start_y = row as f32 * line_height - scroll_top;
-                        let end_y = start_y + line_height;
-
-                        let width = 0.275 * line_height;
-                        let highlight_origin = layout.gutter_hitbox.origin + point(-width, start_y);
-                        let highlight_size = size(width * 2., end_y - start_y);
-                        let highlight_bounds = Bounds::new(highlight_origin, highlight_size);
-                        cx.paint_quad(quad(
-                            highlight_bounds,
-                            Corners::all(1. * line_height),
+            for (hunk, hitbox) in &layout.display_hunks {
+                let hunk_to_paint = match hunk {
+                    DisplayDiffHunk::Folded { .. } => {
+                        let hunk_bounds = Self::diff_hunk_bounds(
+                            &layout.position_map.snapshot,
+                            line_height,
+                            gutter_bounds,
+                            &hunk,
+                        );
+                        Some((
+                            hunk_bounds,
                             cx.theme().status().modified,
-                            Edges::default(),
-                            transparent_black(),
-                        ));
-
-                        continue;
+                            Corners::all(1. * line_height),
+                        ))
+                    }
+                    DisplayDiffHunk::Unfolded { status, .. } => {
+                        hitbox.as_ref().map(|hunk_hitbox| match status {
+                            DiffHunkStatus::Added => (
+                                hunk_hitbox.bounds,
+                                cx.theme().status().created,
+                                Corners::all(0.05 * line_height),
+                            ),
+                            DiffHunkStatus::Modified => (
+                                hunk_hitbox.bounds,
+                                cx.theme().status().modified,
+                                Corners::all(0.05 * line_height),
+                            ),
+                            DiffHunkStatus::Removed => (
+                                hunk_hitbox.bounds,
+                                cx.theme().status().deleted,
+                                Corners::all(1. * line_height),
+                            ),
+                        })
                     }
-
-                    DisplayDiffHunk::Unfolded {
-                        display_row_range,
-                        status,
-                    } => (display_row_range, status),
                 };
 
-                let color = match status {
-                    DiffHunkStatus::Added => cx.theme().status().created,
-                    DiffHunkStatus::Modified => cx.theme().status().modified,
+                if let Some((hunk_bounds, background_color, corner_radii)) = hunk_to_paint {
+                    cx.paint_quad(quad(
+                        hunk_bounds,
+                        corner_radii,
+                        background_color,
+                        Edges::default(),
+                        transparent_black(),
+                    ));
+                }
+            }
+        });
+    }
 
-                    //TODO: This rendering is entirely a horrible hack
-                    DiffHunkStatus::Removed => {
-                        let row = display_row_range.start;
+    fn diff_hunk_bounds(
+        snapshot: &EditorSnapshot,
+        line_height: Pixels,
+        bounds: Bounds<Pixels>,
+        hunk: &DisplayDiffHunk,
+    ) -> Bounds<Pixels> {
+        let scroll_position = snapshot.scroll_position();
+        let scroll_top = scroll_position.y * line_height;
 
-                        let offset = line_height / 2.;
-                        let start_y = row as f32 * line_height - offset - scroll_top;
-                        let end_y = start_y + line_height;
+        match hunk {
+            DisplayDiffHunk::Folded { display_row, .. } => {
+                let start_y = *display_row as f32 * line_height - scroll_top;
+                let end_y = start_y + line_height;
 
-                        let width = 0.275 * line_height;
-                        let highlight_origin = layout.gutter_hitbox.origin + point(-width, start_y);
-                        let highlight_size = size(width * 2., end_y - start_y);
-                        let highlight_bounds = Bounds::new(highlight_origin, highlight_size);
-                        cx.paint_quad(quad(
-                            highlight_bounds,
-                            Corners::all(1. * line_height),
-                            cx.theme().status().deleted,
-                            Edges::default(),
-                            transparent_black(),
-                        ));
+                let width = 0.275 * line_height;
+                let highlight_origin = bounds.origin + point(-width, start_y);
+                let highlight_size = size(width * 2., end_y - start_y);
+                Bounds::new(highlight_origin, highlight_size)
+            }
+            DisplayDiffHunk::Unfolded {
+                display_row_range,
+                status,
+                ..
+            } => match status {
+                DiffHunkStatus::Added | DiffHunkStatus::Modified => {
+                    let start_row = display_row_range.start;
+                    let end_row = display_row_range.end;
+                    // If we're in a multibuffer, row range span might include an
+                    // excerpt header, so if we were to draw the marker straight away,
+                    // the hunk might include the rows of that header.
+                    // Making the range inclusive doesn't quite cut it, as we rely on the exclusivity for the soft wrap.
+                    // Instead, we simply check whether the range we're dealing with includes
+                    // any excerpt headers and if so, we stop painting the diff hunk on the first row of that header.
+                    let end_row_in_current_excerpt = snapshot
+                        .blocks_in_range(start_row..end_row)
+                        .find_map(|(start_row, block)| {
+                            if matches!(block, TransformBlock::ExcerptHeader { .. }) {
+                                Some(start_row)
+                            } else {
+                                None
+                            }
+                        })
+                        .unwrap_or(end_row);
 
-                        continue;
-                    }
-                };
+                    let start_y = start_row as f32 * line_height - scroll_top;
+                    let end_y = end_row_in_current_excerpt as f32 * line_height - scroll_top;
 
-                let start_row = display_row_range.start;
-                let end_row = display_row_range.end;
-                // If we're in a multibuffer, row range span might include an
-                // excerpt header, so if we were to draw the marker straight away,
-                // the hunk might include the rows of that header.
-                // Making the range inclusive doesn't quite cut it, as we rely on the exclusivity for the soft wrap.
-                // Instead, we simply check whether the range we're dealing with includes
-                // any excerpt headers and if so, we stop painting the diff hunk on the first row of that header.
-                let end_row_in_current_excerpt = layout
-                    .position_map
-                    .snapshot
-                    .blocks_in_range(start_row..end_row)
-                    .find_map(|(start_row, block)| {
-                        if matches!(block, TransformBlock::ExcerptHeader { .. }) {
-                            Some(start_row)
-                        } else {
-                            None
-                        }
-                    })
-                    .unwrap_or(end_row);
+                    let width = 0.275 * line_height;
+                    let highlight_origin = bounds.origin + point(-width, start_y);
+                    let highlight_size = size(width * 2., end_y - start_y);
+                    Bounds::new(highlight_origin, highlight_size)
+                }
+                DiffHunkStatus::Removed => {
+                    let row = display_row_range.start;
 
-                let start_y = start_row as f32 * line_height - scroll_top;
-                let end_y = end_row_in_current_excerpt as f32 * line_height - scroll_top;
+                    let offset = line_height / 2.;
+                    let start_y = row as f32 * line_height - offset - scroll_top;
+                    let end_y = start_y + line_height;
 
-                let width = 0.275 * line_height;
-                let highlight_origin = layout.gutter_hitbox.origin + point(-width, start_y);
-                let highlight_size = size(width * 2., end_y - start_y);
-                let highlight_bounds = Bounds::new(highlight_origin, highlight_size);
-                cx.paint_quad(quad(
-                    highlight_bounds,
-                    Corners::all(0.05 * line_height),
-                    color,
-                    Edges::default(),
-                    transparent_black(),
-                ));
-            }
-        })
+                    let width = 0.35 * line_height;
+                    let highlight_origin = bounds.origin + point(-width, start_y);
+                    let highlight_size = size(width * 2., end_y - start_y);
+                    Bounds::new(highlight_origin, highlight_size)
+                }
+            },
+        }
     }
 
     fn paint_blamed_display_rows(&self, layout: &mut EditorLayout, cx: &mut WindowContext) {
@@ -3009,14 +3079,22 @@ impl EditorElement {
                             }
                         };
 
-                        let scroll_position = position_map.snapshot.scroll_position();
-                        let x = (scroll_position.x * max_glyph_width
+                        let current_scroll_position = position_map.snapshot.scroll_position();
+                        let x = (current_scroll_position.x * max_glyph_width
                             - (delta.x * scroll_sensitivity))
                             / max_glyph_width;
-                        let y = (scroll_position.y * line_height - (delta.y * scroll_sensitivity))
+                        let y = (current_scroll_position.y * line_height
+                            - (delta.y * scroll_sensitivity))
                             / line_height;
-                        let scroll_position =
+                        let mut scroll_position =
                             point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
+                        let forbid_vertical_scroll = editor.scroll_manager.forbid_vertical_scroll();
+                        if forbid_vertical_scroll {
+                            scroll_position.y = current_scroll_position.y;
+                            if scroll_position == current_scroll_position {
+                                return;
+                            }
+                        }
                         editor.scroll(scroll_position, axis, cx);
                         cx.stop_propagation();
                     });
@@ -3025,7 +3103,12 @@ impl EditorElement {
         });
     }
 
-    fn paint_mouse_listeners(&mut self, layout: &EditorLayout, cx: &mut WindowContext) {
+    fn paint_mouse_listeners(
+        &mut self,
+        layout: &EditorLayout,
+        hovered_hunk: Option<HunkToExpand>,
+        cx: &mut WindowContext,
+    ) {
         self.paint_scroll_wheel_listener(layout, cx);
 
         cx.on_mouse_event({
@@ -3041,6 +3124,7 @@ impl EditorElement {
                             Self::mouse_left_down(
                                 editor,
                                 event,
+                                hovered_hunk.as_ref(),
                                 &position_map,
                                 &text_hitbox,
                                 &gutter_hitbox,
@@ -3566,12 +3650,15 @@ impl Element for EditorElement {
                     let editor_width =
                         text_width - gutter_dimensions.margin - overscroll.width - em_width;
                     let wrap_width = match editor.soft_wrap_mode(cx) {
-                        SoftWrap::None => (MAX_LINE_LEN / 2) as f32 * em_advance,
-                        SoftWrap::EditorWidth => editor_width,
-                        SoftWrap::Column(column) => editor_width.min(column as f32 * em_advance),
+                        SoftWrap::None => None,
+                        SoftWrap::PreferLine => Some((MAX_LINE_LEN / 2) as f32 * em_advance),
+                        SoftWrap::EditorWidth => Some(editor_width),
+                        SoftWrap::Column(column) => {
+                            Some(editor_width.min(column as f32 * em_advance))
+                        }
                     };
 
-                    if editor.set_wrap_width(Some(wrap_width), cx) {
+                    if editor.set_wrap_width(wrap_width, cx) {
                         editor.snapshot(cx)
                     } else {
                         snapshot
@@ -3645,9 +3732,9 @@ impl Element for EditorElement {
                     )
                 };
 
-                let highlighted_rows = self
-                    .editor
-                    .update(cx, |editor, cx| editor.highlighted_display_rows(cx));
+                let highlighted_rows = self.editor.update(cx, |editor, cx| {
+                    editor.highlighted_display_rows(HashSet::default(), cx)
+                });
                 let highlighted_ranges = self.editor.read(cx).background_highlights_in_range(
                     start_anchor..end_anchor,
                     &snapshot.display_snapshot,
@@ -3678,7 +3765,13 @@ impl Element for EditorElement {
                     cx,
                 );
 
-                let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
+                let display_hunks = self.layout_git_gutters(
+                    line_height,
+                    &gutter_hitbox,
+                    start_row..end_row,
+                    &snapshot,
+                    cx,
+                );
 
                 let mut max_visible_line_width = Pixels::ZERO;
                 let line_layouts =
@@ -3988,14 +4081,41 @@ impl Element for EditorElement {
             line_height: Some(self.style.text.line_height),
             ..Default::default()
         };
+        let mouse_position = cx.mouse_position();
+        let hovered_hunk = layout
+            .display_hunks
+            .iter()
+            .find_map(|(hunk, hunk_hitbox)| match hunk {
+                DisplayDiffHunk::Folded { .. } => None,
+                DisplayDiffHunk::Unfolded {
+                    diff_base_byte_range,
+                    multi_buffer_range,
+                    status,
+                    ..
+                } => {
+                    if hunk_hitbox
+                        .as_ref()
+                        .map(|hitbox| hitbox.contains(&mouse_position))
+                        .unwrap_or(false)
+                    {
+                        Some(HunkToExpand {
+                            status: *status,
+                            multi_buffer_range: multi_buffer_range.clone(),
+                            diff_base_byte_range: diff_base_byte_range.clone(),
+                        })
+                    } else {
+                        None
+                    }
+                }
+            });
         cx.with_text_style(Some(text_style), |cx| {
             cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
-                self.paint_mouse_listeners(layout, cx);
-
+                self.paint_mouse_listeners(layout, hovered_hunk, cx);
                 self.paint_background(layout, cx);
                 if layout.gutter_hitbox.size.width > Pixels::ZERO {
-                    self.paint_gutter(layout, cx);
+                    self.paint_gutter(layout, cx)
                 }
+
                 self.paint_text(layout, cx);
 
                 if !layout.blocks.is_empty() {
@@ -4035,7 +4155,7 @@ pub struct EditorLayout {
     active_rows: BTreeMap<u32, bool>,
     highlighted_rows: BTreeMap<u32, Hsla>,
     line_numbers: Vec<Option<ShapedLine>>,
-    display_hunks: Vec<DisplayDiffHunk>,
+    display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
     blamed_display_rows: Option<Vec<AnyElement>>,
     inline_blame: Option<AnyElement>,
     folds: Vec<FoldLayout>,
@@ -4565,6 +4685,7 @@ mod tests {
     use language::language_settings;
     use log::info;
     use std::num::NonZeroU32;
+    use ui::Context;
     use util::test::sample_text;
 
     #[gpui::test]

crates/editor/src/git.rs πŸ”—

@@ -4,6 +4,7 @@ use std::ops::Range;
 
 use git::diff::{DiffHunk, DiffHunkStatus};
 use language::Point;
+use multi_buffer::Anchor;
 
 use crate::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
@@ -17,7 +18,9 @@ pub enum DisplayDiffHunk {
     },
 
     Unfolded {
+        diff_base_byte_range: Range<usize>,
         display_row_range: Range<u32>,
+        multi_buffer_range: Range<Anchor>,
         status: DiffHunkStatus,
     },
 }
@@ -45,7 +48,7 @@ impl DisplayDiffHunk {
     }
 }
 
-pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
+pub fn diff_hunk_to_display(hunk: &DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
     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(
@@ -81,11 +84,16 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
 
         let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
         let hunk_end_point = Point::new(hunk_end_row, 0);
+
+        let multi_buffer_start = snapshot.buffer_snapshot.anchor_after(hunk_start_point);
+        let multi_buffer_end = snapshot.buffer_snapshot.anchor_before(hunk_end_point);
         let end = hunk_end_point.to_display_point(snapshot).row();
 
         DisplayDiffHunk::Unfolded {
             display_row_range: start..end,
+            multi_buffer_range: multi_buffer_start..multi_buffer_end,
             status: hunk.status(),
+            diff_base_byte_range: hunk.diff_base_byte_range.clone(),
         }
     }
 }

crates/editor/src/hunk_diff.rs πŸ”—

@@ -0,0 +1,623 @@
+use std::ops::Range;
+
+use collections::{hash_map, HashMap, HashSet};
+use git::diff::{DiffHunk, DiffHunkStatus};
+use gpui::{AppContext, Hsla, Model, Task, View};
+use language::Buffer;
+use multi_buffer::{Anchor, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToPoint};
+use text::{BufferId, Point};
+use ui::{
+    div, ActiveTheme, Context as _, IntoElement, ParentElement, Styled, ViewContext, VisualContext,
+};
+use util::{debug_panic, RangeExt};
+
+use crate::{
+    git::{diff_hunk_to_display, DisplayDiffHunk},
+    hunks_for_selections, BlockDisposition, BlockId, BlockProperties, BlockStyle, DiffRowHighlight,
+    Editor, ExpandAllHunkDiffs, RangeToAnchorExt, ToDisplayPoint, ToggleHunkDiff,
+};
+
+#[derive(Debug, Clone)]
+pub(super) struct HunkToExpand {
+    pub multi_buffer_range: Range<Anchor>,
+    pub status: DiffHunkStatus,
+    pub diff_base_byte_range: Range<usize>,
+}
+
+#[derive(Debug, Default)]
+pub(super) struct ExpandedHunks {
+    hunks: Vec<ExpandedHunk>,
+    diff_base: HashMap<BufferId, DiffBaseBuffer>,
+    hunk_update_tasks: HashMap<Option<BufferId>, Task<()>>,
+}
+
+#[derive(Debug)]
+struct DiffBaseBuffer {
+    buffer: Model<Buffer>,
+    diff_base_version: usize,
+}
+
+impl ExpandedHunks {
+    pub fn hunks(&self, include_folded: bool) -> impl Iterator<Item = &ExpandedHunk> {
+        self.hunks
+            .iter()
+            .filter(move |hunk| include_folded || !hunk.folded)
+    }
+}
+
+#[derive(Debug, Clone)]
+pub(super) struct ExpandedHunk {
+    pub block: Option<BlockId>,
+    pub hunk_range: Range<Anchor>,
+    pub diff_base_byte_range: Range<usize>,
+    pub status: DiffHunkStatus,
+    pub folded: bool,
+}
+
+impl Editor {
+    pub fn toggle_hunk_diff(&mut self, _: &ToggleHunkDiff, cx: &mut ViewContext<Self>) {
+        let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
+        let selections = self.selections.disjoint_anchors();
+        self.toggle_hunks_expanded(
+            hunks_for_selections(&multi_buffer_snapshot, &selections),
+            cx,
+        );
+    }
+
+    pub fn expand_all_hunk_diffs(&mut self, _: &ExpandAllHunkDiffs, cx: &mut ViewContext<Self>) {
+        let snapshot = self.snapshot(cx);
+        let display_rows_with_expanded_hunks = self
+            .expanded_hunks
+            .hunks(false)
+            .map(|hunk| &hunk.hunk_range)
+            .map(|anchor_range| {
+                (
+                    anchor_range
+                        .start
+                        .to_display_point(&snapshot.display_snapshot)
+                        .row(),
+                    anchor_range
+                        .end
+                        .to_display_point(&snapshot.display_snapshot)
+                        .row(),
+                )
+            })
+            .collect::<HashMap<_, _>>();
+        let hunks = snapshot
+            .display_snapshot
+            .buffer_snapshot
+            .git_diff_hunks_in_range(0..u32::MAX)
+            .filter(|hunk| {
+                let hunk_display_row_range = Point::new(hunk.associated_range.start, 0)
+                    .to_display_point(&snapshot.display_snapshot)
+                    ..Point::new(hunk.associated_range.end, 0)
+                        .to_display_point(&snapshot.display_snapshot);
+                let row_range_end =
+                    display_rows_with_expanded_hunks.get(&hunk_display_row_range.start.row());
+                row_range_end.is_none() || row_range_end != Some(&hunk_display_row_range.end.row())
+            });
+        self.toggle_hunks_expanded(hunks.collect(), cx);
+    }
+
+    fn toggle_hunks_expanded(
+        &mut self,
+        hunks_to_toggle: Vec<DiffHunk<u32>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let previous_toggle_task = self.expanded_hunks.hunk_update_tasks.remove(&None);
+        let new_toggle_task = cx.spawn(move |editor, mut cx| async move {
+            if let Some(task) = previous_toggle_task {
+                task.await;
+            }
+
+            editor
+                .update(&mut cx, |editor, cx| {
+                    let snapshot = editor.snapshot(cx);
+                    let mut hunks_to_toggle = hunks_to_toggle.into_iter().fuse().peekable();
+                    let mut highlights_to_remove =
+                        Vec::with_capacity(editor.expanded_hunks.hunks.len());
+                    let mut blocks_to_remove = HashSet::default();
+                    let mut hunks_to_expand = Vec::new();
+                    editor.expanded_hunks.hunks.retain(|expanded_hunk| {
+                        if expanded_hunk.folded {
+                            return true;
+                        }
+                        let expanded_hunk_row_range = expanded_hunk
+                            .hunk_range
+                            .start
+                            .to_display_point(&snapshot)
+                            .row()
+                            ..expanded_hunk
+                                .hunk_range
+                                .end
+                                .to_display_point(&snapshot)
+                                .row();
+                        let mut retain = true;
+                        while let Some(hunk_to_toggle) = hunks_to_toggle.peek() {
+                            match diff_hunk_to_display(hunk_to_toggle, &snapshot) {
+                                DisplayDiffHunk::Folded { .. } => {
+                                    hunks_to_toggle.next();
+                                    continue;
+                                }
+                                DisplayDiffHunk::Unfolded {
+                                    diff_base_byte_range,
+                                    display_row_range,
+                                    multi_buffer_range,
+                                    status,
+                                } => {
+                                    let hunk_to_toggle_row_range = display_row_range;
+                                    if hunk_to_toggle_row_range.start > expanded_hunk_row_range.end
+                                    {
+                                        break;
+                                    } else if expanded_hunk_row_range == hunk_to_toggle_row_range {
+                                        highlights_to_remove.push(expanded_hunk.hunk_range.clone());
+                                        blocks_to_remove.extend(expanded_hunk.block);
+                                        hunks_to_toggle.next();
+                                        retain = false;
+                                        break;
+                                    } else {
+                                        hunks_to_expand.push(HunkToExpand {
+                                            status,
+                                            multi_buffer_range,
+                                            diff_base_byte_range,
+                                        });
+                                        hunks_to_toggle.next();
+                                        continue;
+                                    }
+                                }
+                            }
+                        }
+
+                        retain
+                    });
+                    for remaining_hunk in hunks_to_toggle {
+                        let remaining_hunk_point_range =
+                            Point::new(remaining_hunk.associated_range.start, 0)
+                                ..Point::new(remaining_hunk.associated_range.end, 0);
+                        hunks_to_expand.push(HunkToExpand {
+                            status: remaining_hunk.status(),
+                            multi_buffer_range: remaining_hunk_point_range
+                                .to_anchors(&snapshot.buffer_snapshot),
+                            diff_base_byte_range: remaining_hunk.diff_base_byte_range.clone(),
+                        });
+                    }
+
+                    for removed_rows in highlights_to_remove {
+                        editor.highlight_rows::<DiffRowHighlight>(removed_rows, None, cx);
+                    }
+                    editor.remove_blocks(blocks_to_remove, None, cx);
+                    for hunk in hunks_to_expand {
+                        editor.expand_diff_hunk(None, &hunk, cx);
+                    }
+                    cx.notify();
+                })
+                .ok();
+        });
+
+        self.expanded_hunks
+            .hunk_update_tasks
+            .insert(None, cx.background_executor().spawn(new_toggle_task));
+    }
+
+    pub(super) fn expand_diff_hunk(
+        &mut self,
+        diff_base_buffer: Option<Model<Buffer>>,
+        hunk: &HunkToExpand,
+        cx: &mut ViewContext<'_, Editor>,
+    ) -> Option<()> {
+        let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
+        let multi_buffer_row_range = hunk
+            .multi_buffer_range
+            .start
+            .to_point(&multi_buffer_snapshot)
+            ..hunk.multi_buffer_range.end.to_point(&multi_buffer_snapshot);
+        let hunk_start = hunk.multi_buffer_range.start;
+        let hunk_end = hunk.multi_buffer_range.end;
+
+        let buffer = self.buffer().clone();
+        let (diff_base_buffer, deleted_text_range, deleted_text_lines) =
+            buffer.update(cx, |buffer, cx| {
+                let snapshot = buffer.snapshot(cx);
+                let hunk = buffer_diff_hunk(&snapshot, multi_buffer_row_range.clone())?;
+                let mut buffer_ranges = buffer.range_to_buffer_ranges(multi_buffer_row_range, cx);
+                if buffer_ranges.len() == 1 {
+                    let (buffer, _, _) = buffer_ranges.pop()?;
+                    let diff_base_buffer = diff_base_buffer
+                        .or_else(|| self.current_diff_base_buffer(&buffer, cx))
+                        .or_else(|| create_diff_base_buffer(&buffer, cx));
+                    let buffer = buffer.read(cx);
+                    let deleted_text_lines = buffer.diff_base().and_then(|diff_base| {
+                        Some(
+                            diff_base
+                                .get(hunk.diff_base_byte_range.clone())?
+                                .lines()
+                                .count(),
+                        )
+                    });
+                    Some((
+                        diff_base_buffer?,
+                        hunk.diff_base_byte_range,
+                        deleted_text_lines,
+                    ))
+                } else {
+                    None
+                }
+            })?;
+
+        let block_insert_index = match self.expanded_hunks.hunks.binary_search_by(|probe| {
+            probe
+                .hunk_range
+                .start
+                .cmp(&hunk_start, &multi_buffer_snapshot)
+        }) {
+            Ok(_already_present) => return None,
+            Err(ix) => ix,
+        };
+
+        let block = match hunk.status {
+            DiffHunkStatus::Removed => self.add_deleted_lines(
+                deleted_text_lines,
+                hunk_start,
+                diff_base_buffer,
+                deleted_text_range,
+                cx,
+            ),
+            DiffHunkStatus::Added => {
+                self.highlight_rows::<DiffRowHighlight>(
+                    hunk_start..hunk_end,
+                    Some(added_hunk_color(cx)),
+                    cx,
+                );
+                None
+            }
+            DiffHunkStatus::Modified => {
+                self.highlight_rows::<DiffRowHighlight>(
+                    hunk_start..hunk_end,
+                    Some(added_hunk_color(cx)),
+                    cx,
+                );
+                self.add_deleted_lines(
+                    deleted_text_lines,
+                    hunk_start,
+                    diff_base_buffer,
+                    deleted_text_range,
+                    cx,
+                )
+            }
+        };
+        self.expanded_hunks.hunks.insert(
+            block_insert_index,
+            ExpandedHunk {
+                block,
+                hunk_range: hunk_start..hunk_end,
+                status: hunk.status,
+                folded: false,
+                diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+            },
+        );
+
+        Some(())
+    }
+
+    fn add_deleted_lines(
+        &mut self,
+        deleted_text_lines: Option<usize>,
+        hunk_start: Anchor,
+        diff_base_buffer: Model<Buffer>,
+        deleted_text_range: Range<usize>,
+        cx: &mut ViewContext<'_, Self>,
+    ) -> Option<BlockId> {
+        if let Some(deleted_text_lines) = deleted_text_lines {
+            self.insert_deleted_text_block(
+                hunk_start,
+                diff_base_buffer,
+                deleted_text_range,
+                deleted_text_lines as u8,
+                cx,
+            )
+        } else {
+            debug_panic!("Found no deleted text for removed hunk on position {hunk_start:?}");
+            None
+        }
+    }
+
+    fn insert_deleted_text_block(
+        &mut self,
+        position: Anchor,
+        diff_base_buffer: Model<Buffer>,
+        deleted_text_range: Range<usize>,
+        deleted_text_height: u8,
+        cx: &mut ViewContext<'_, Self>,
+    ) -> Option<BlockId> {
+        let deleted_hunk_color = deleted_hunk_color(cx);
+        let (editor_height, editor_with_deleted_text) =
+            editor_with_deleted_text(diff_base_buffer, deleted_text_range, deleted_hunk_color, cx);
+        let parent_gutter_width = self.gutter_width;
+        let mut new_block_ids = self.insert_blocks(
+            Some(BlockProperties {
+                position,
+                height: editor_height.max(deleted_text_height),
+                style: BlockStyle::Flex,
+                render: Box::new(move |_| {
+                    div()
+                        .bg(deleted_hunk_color)
+                        .size_full()
+                        .pl(parent_gutter_width)
+                        .child(editor_with_deleted_text.clone())
+                        .into_any_element()
+                }),
+                disposition: BlockDisposition::Above,
+            }),
+            None,
+            cx,
+        );
+        if new_block_ids.len() == 1 {
+            new_block_ids.pop()
+        } else {
+            debug_panic!(
+                "Inserted one editor block but did not receive exactly one block id: {new_block_ids:?}"
+            );
+            None
+        }
+    }
+
+    pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut ViewContext<'_, Editor>) {
+        self.expanded_hunks.hunk_update_tasks.clear();
+        let to_remove = self
+            .expanded_hunks
+            .hunks
+            .drain(..)
+            .filter_map(|expanded_hunk| expanded_hunk.block)
+            .collect();
+        self.clear_row_highlights::<DiffRowHighlight>();
+        self.remove_blocks(to_remove, None, cx);
+    }
+
+    pub(super) fn sync_expanded_diff_hunks(
+        &mut self,
+        buffer: Model<Buffer>,
+        cx: &mut ViewContext<'_, Self>,
+    ) {
+        let buffer_id = buffer.read(cx).remote_id();
+        let buffer_diff_base_version = buffer.read(cx).diff_base_version();
+        self.expanded_hunks
+            .hunk_update_tasks
+            .remove(&Some(buffer_id));
+        let diff_base_buffer = self.current_diff_base_buffer(&buffer, cx);
+        let new_sync_task = cx.spawn(move |editor, mut cx| async move {
+            let diff_base_buffer_unchanged = diff_base_buffer.is_some();
+            let Ok(diff_base_buffer) =
+                cx.update(|cx| diff_base_buffer.or_else(|| create_diff_base_buffer(&buffer, cx)))
+            else {
+                return;
+            };
+            editor
+                .update(&mut cx, |editor, cx| {
+                    if let Some(diff_base_buffer) = &diff_base_buffer {
+                        editor.expanded_hunks.diff_base.insert(
+                            buffer_id,
+                            DiffBaseBuffer {
+                                buffer: diff_base_buffer.clone(),
+                                diff_base_version: buffer_diff_base_version,
+                            },
+                        );
+                    }
+
+                    let snapshot = editor.snapshot(cx);
+                    let buffer_snapshot = buffer.read(cx).snapshot();
+                    let mut recalculated_hunks = buffer_snapshot
+                        .git_diff_hunks_in_row_range(0..u32::MAX)
+                        .fuse()
+                        .peekable();
+                    let mut highlights_to_remove =
+                        Vec::with_capacity(editor.expanded_hunks.hunks.len());
+                    let mut blocks_to_remove = HashSet::default();
+                    let mut hunks_to_reexpand =
+                        Vec::with_capacity(editor.expanded_hunks.hunks.len());
+                    editor.expanded_hunks.hunks.retain_mut(|expanded_hunk| {
+                        if expanded_hunk.hunk_range.start.buffer_id != Some(buffer_id) {
+                            return true;
+                        };
+
+                        let mut retain = false;
+                        if diff_base_buffer_unchanged {
+                            let expanded_hunk_display_range = expanded_hunk
+                                .hunk_range
+                                .start
+                                .to_display_point(&snapshot)
+                                .row()
+                                ..expanded_hunk
+                                    .hunk_range
+                                    .end
+                                    .to_display_point(&snapshot)
+                                    .row();
+                            while let Some(buffer_hunk) = recalculated_hunks.peek() {
+                                match diff_hunk_to_display(buffer_hunk, &snapshot) {
+                                    DisplayDiffHunk::Folded { display_row } => {
+                                        recalculated_hunks.next();
+                                        if !expanded_hunk.folded
+                                            && expanded_hunk_display_range
+                                                .to_inclusive()
+                                                .contains(&display_row)
+                                        {
+                                            retain = true;
+                                            expanded_hunk.folded = true;
+                                            highlights_to_remove
+                                                .push(expanded_hunk.hunk_range.clone());
+                                            if let Some(block) = expanded_hunk.block.take() {
+                                                blocks_to_remove.insert(block);
+                                            }
+                                            break;
+                                        } else {
+                                            continue;
+                                        }
+                                    }
+                                    DisplayDiffHunk::Unfolded {
+                                        diff_base_byte_range,
+                                        display_row_range,
+                                        multi_buffer_range,
+                                        status,
+                                    } => {
+                                        let hunk_display_range = display_row_range;
+                                        if expanded_hunk_display_range.start
+                                            > hunk_display_range.end
+                                        {
+                                            recalculated_hunks.next();
+                                            continue;
+                                        } else if expanded_hunk_display_range.end
+                                            < hunk_display_range.start
+                                        {
+                                            break;
+                                        } else {
+                                            if !expanded_hunk.folded
+                                                && expanded_hunk_display_range == hunk_display_range
+                                                && expanded_hunk.status == buffer_hunk.status()
+                                                && expanded_hunk.diff_base_byte_range
+                                                    == buffer_hunk.diff_base_byte_range
+                                            {
+                                                recalculated_hunks.next();
+                                                retain = true;
+                                            } else {
+                                                hunks_to_reexpand.push(HunkToExpand {
+                                                    status,
+                                                    multi_buffer_range,
+                                                    diff_base_byte_range,
+                                                });
+                                            }
+                                            break;
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                        if !retain {
+                            blocks_to_remove.extend(expanded_hunk.block);
+                            highlights_to_remove.push(expanded_hunk.hunk_range.clone());
+                        }
+                        retain
+                    });
+
+                    for removed_rows in highlights_to_remove {
+                        editor.highlight_rows::<DiffRowHighlight>(removed_rows, None, cx);
+                    }
+                    editor.remove_blocks(blocks_to_remove, None, cx);
+
+                    if let Some(diff_base_buffer) = &diff_base_buffer {
+                        for hunk in hunks_to_reexpand {
+                            editor.expand_diff_hunk(Some(diff_base_buffer.clone()), &hunk, cx);
+                        }
+                    }
+                })
+                .ok();
+        });
+
+        self.expanded_hunks.hunk_update_tasks.insert(
+            Some(buffer_id),
+            cx.background_executor().spawn(new_sync_task),
+        );
+    }
+
+    fn current_diff_base_buffer(
+        &mut self,
+        buffer: &Model<Buffer>,
+        cx: &mut AppContext,
+    ) -> Option<Model<Buffer>> {
+        buffer.update(cx, |buffer, _| {
+            match self.expanded_hunks.diff_base.entry(buffer.remote_id()) {
+                hash_map::Entry::Occupied(o) => {
+                    if o.get().diff_base_version != buffer.diff_base_version() {
+                        o.remove();
+                        None
+                    } else {
+                        Some(o.get().buffer.clone())
+                    }
+                }
+                hash_map::Entry::Vacant(_) => None,
+            }
+        })
+    }
+}
+
+fn create_diff_base_buffer(buffer: &Model<Buffer>, cx: &mut AppContext) -> Option<Model<Buffer>> {
+    buffer
+        .update(cx, |buffer, _| {
+            let language = buffer.language().cloned();
+            let diff_base = buffer.diff_base().map(|s| s.to_owned());
+            Some((diff_base?, language))
+        })
+        .map(|(diff_base, language)| {
+            cx.new_model(|cx| {
+                let buffer = Buffer::local(diff_base, cx);
+                match language {
+                    Some(language) => buffer.with_language(language, cx),
+                    None => buffer,
+                }
+            })
+        })
+}
+
+fn added_hunk_color(cx: &AppContext) -> Hsla {
+    let mut created_color = cx.theme().status().git().created;
+    created_color.fade_out(0.7);
+    created_color
+}
+
+fn deleted_hunk_color(cx: &AppContext) -> Hsla {
+    let mut deleted_color = cx.theme().status().git().deleted;
+    deleted_color.fade_out(0.7);
+    deleted_color
+}
+
+fn editor_with_deleted_text(
+    diff_base_buffer: Model<Buffer>,
+    deleted_text_range: Range<usize>,
+    deleted_color: Hsla,
+    cx: &mut ViewContext<'_, Editor>,
+) -> (u8, View<Editor>) {
+    let editor = cx.new_view(|cx| {
+        let multi_buffer =
+            cx.new_model(|_| MultiBuffer::without_headers(0, language::Capability::ReadOnly));
+        multi_buffer.update(cx, |multi_buffer, cx| {
+            multi_buffer.push_excerpts(
+                diff_base_buffer,
+                Some(ExcerptRange {
+                    context: deleted_text_range,
+                    primary: None,
+                }),
+                cx,
+            );
+        });
+
+        let mut editor = Editor::for_multibuffer(multi_buffer, None, cx);
+        editor.soft_wrap_mode_override = Some(language::language_settings::SoftWrap::None);
+        editor.show_wrap_guides = Some(false);
+        editor.show_gutter = false;
+        editor.scroll_manager.set_forbid_vertical_scroll(true);
+        editor.set_read_only(true);
+
+        let editor_snapshot = editor.snapshot(cx);
+        let start = editor_snapshot.buffer_snapshot.anchor_before(0);
+        let end = editor_snapshot
+            .buffer_snapshot
+            .anchor_after(editor.buffer.read(cx).len(cx));
+
+        editor.highlight_rows::<DiffRowHighlight>(start..end, Some(deleted_color), cx);
+        editor
+    });
+
+    let editor_height = editor.update(cx, |editor, cx| editor.max_point(cx).row() as u8);
+    (editor_height, editor)
+}
+
+fn buffer_diff_hunk(
+    buffer_snapshot: &MultiBufferSnapshot,
+    row_range: Range<Point>,
+) -> Option<DiffHunk<u32>> {
+    let mut hunks = buffer_snapshot.git_diff_hunks_in_range(row_range.start.row..row_range.end.row);
+    let hunk = hunks.next()?;
+    let second_hunk = hunks.next();
+    if second_hunk.is_none() {
+        return Some(hunk);
+    }
+    None
+}

crates/editor/src/scroll.rs πŸ”—

@@ -137,6 +137,7 @@ pub struct ScrollManager {
     hide_scrollbar_task: Option<Task<()>>,
     dragging_scrollbar: bool,
     visible_line_count: Option<f32>,
+    forbid_vertical_scroll: bool,
 }
 
 impl ScrollManager {
@@ -151,6 +152,7 @@ impl ScrollManager {
             dragging_scrollbar: false,
             last_autoscroll: None,
             visible_line_count: None,
+            forbid_vertical_scroll: false,
         }
     }
 
@@ -185,6 +187,9 @@ impl ScrollManager {
         workspace_id: Option<WorkspaceId>,
         cx: &mut ViewContext<Editor>,
     ) {
+        if self.forbid_vertical_scroll {
+            return;
+        }
         let (new_anchor, top_row) = if scroll_position.y <= 0. {
             (
                 ScrollAnchor {
@@ -224,6 +229,9 @@ impl ScrollManager {
         workspace_id: Option<WorkspaceId>,
         cx: &mut ViewContext<Editor>,
     ) {
+        if self.forbid_vertical_scroll {
+            return;
+        }
         self.anchor = anchor;
         cx.emit(EditorEvent::ScrollPositionChanged { local, autoscroll });
         self.show_scrollbar(cx);
@@ -298,6 +306,14 @@ impl ScrollManager {
             false
         }
     }
+
+    pub fn set_forbid_vertical_scroll(&mut self, forbid: bool) {
+        self.forbid_vertical_scroll = forbid;
+    }
+
+    pub fn forbid_vertical_scroll(&self) -> bool {
+        self.forbid_vertical_scroll
+    }
 }
 
 impl Editor {
@@ -334,6 +350,9 @@ impl Editor {
         scroll_delta: gpui::Point<f32>,
         cx: &mut ViewContext<Self>,
     ) {
+        if self.scroll_manager.forbid_vertical_scroll {
+            return;
+        }
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let position = self.scroll_manager.anchor.scroll_position(&display_map) + scroll_delta;
         self.set_scroll_position_taking_display_map(position, true, false, display_map, cx);
@@ -344,6 +363,9 @@ impl Editor {
         scroll_position: gpui::Point<f32>,
         cx: &mut ViewContext<Self>,
     ) {
+        if self.scroll_manager.forbid_vertical_scroll {
+            return;
+        }
         self.set_scroll_position_internal(scroll_position, true, false, cx);
     }
 

crates/editor/src/scroll/autoscroll.rs πŸ”—

@@ -1,9 +1,12 @@
-use std::{cmp, f32};
+use std::{any::TypeId, cmp, f32};
 
+use collections::HashSet;
 use gpui::{px, Bounds, Pixels, ViewContext};
 use language::Point;
 
-use crate::{display_map::ToDisplayPoint, Editor, EditorMode, LineWithInvisibles};
+use crate::{
+    display_map::ToDisplayPoint, DiffRowHighlight, Editor, EditorMode, LineWithInvisibles,
+};
 
 #[derive(PartialEq, Eq, Clone, Copy)]
 pub enum Autoscroll {
@@ -103,7 +106,13 @@ impl Editor {
 
         let mut target_top;
         let mut target_bottom;
-        if let Some(first_highlighted_row) = &self.highlighted_display_rows(cx).first_entry() {
+        if let Some(first_highlighted_row) = &self
+            .highlighted_display_rows(
+                HashSet::from_iter(Some(TypeId::of::<DiffRowHighlight>())),
+                cx,
+            )
+            .first_entry()
+        {
             target_top = *first_highlighted_row.key() as f32;
             target_bottom = target_top + 1.;
         } else {

crates/editor/src/test.rs πŸ”—

@@ -75,3 +75,93 @@ pub(crate) fn build_editor_with_project(
 ) -> Editor {
     Editor::new(EditorMode::Full, buffer, Some(project), cx)
 }
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn editor_hunks(
+    editor: &Editor,
+    snapshot: &DisplaySnapshot,
+    cx: &mut ViewContext<'_, Editor>,
+) -> Vec<(String, git::diff::DiffHunkStatus, core::ops::Range<u32>)> {
+    use text::Point;
+
+    snapshot
+        .buffer_snapshot
+        .git_diff_hunks_in_range(0..u32::MAX)
+        .map(|hunk| {
+            let display_range = Point::new(hunk.associated_range.start, 0)
+                .to_display_point(snapshot)
+                .row()
+                ..Point::new(hunk.associated_range.end, 0)
+                    .to_display_point(snapshot)
+                    .row();
+            let (_, buffer, _) = editor
+                .buffer()
+                .read(cx)
+                .excerpt_containing(Point::new(hunk.associated_range.start, 0), cx)
+                .expect("no excerpt for expanded buffer's hunk start");
+            let diff_base = &buffer
+                .read(cx)
+                .diff_base()
+                .expect("should have a diff base for expanded hunk")
+                [hunk.diff_base_byte_range.clone()];
+            (diff_base.to_owned(), hunk.status(), display_range)
+        })
+        .collect()
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn expanded_hunks(
+    editor: &Editor,
+    snapshot: &DisplaySnapshot,
+    cx: &mut ViewContext<'_, Editor>,
+) -> Vec<(String, git::diff::DiffHunkStatus, core::ops::Range<u32>)> {
+    editor
+        .expanded_hunks
+        .hunks(false)
+        .map(|expanded_hunk| {
+            let hunk_display_range = expanded_hunk
+                .hunk_range
+                .start
+                .to_display_point(snapshot)
+                .row()
+                ..expanded_hunk
+                    .hunk_range
+                    .end
+                    .to_display_point(snapshot)
+                    .row();
+            let (_, buffer, _) = editor
+                .buffer()
+                .read(cx)
+                .excerpt_containing(expanded_hunk.hunk_range.start, cx)
+                .expect("no excerpt for expanded buffer's hunk start");
+            let diff_base = &buffer
+                .read(cx)
+                .diff_base()
+                .expect("should have a diff base for expanded hunk")
+                [expanded_hunk.diff_base_byte_range.clone()];
+            (
+                diff_base.to_owned(),
+                expanded_hunk.status,
+                hunk_display_range,
+            )
+        })
+        .collect()
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn expanded_hunks_background_highlights(
+    editor: &Editor,
+    snapshot: &DisplaySnapshot,
+) -> Vec<core::ops::Range<u32>> {
+    use itertools::Itertools;
+
+    editor
+        .highlighted_rows::<crate::DiffRowHighlight>()
+        .into_iter()
+        .flatten()
+        .map(|(range, _)| {
+            range.start.to_display_point(snapshot).row()..range.end.to_display_point(snapshot).row()
+        })
+        .unique()
+        .collect()
+}

crates/git/src/diff.rs πŸ”—

@@ -5,7 +5,7 @@ use text::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, Point};
 pub use git2 as libgit;
 use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum DiffHunkStatus {
     Added,
     Modified,
@@ -173,7 +173,8 @@ impl BufferDiff {
         })
     }
 
-    pub fn clear(&mut self, buffer: &text::BufferSnapshot) {
+    #[cfg(test)]
+    fn clear(&mut self, buffer: &text::BufferSnapshot) {
         self.last_buffer_version = Some(buffer.version().clone());
         self.tree = SumTree::new();
     }

crates/go_to_line/Cargo.toml πŸ”—

@@ -14,6 +14,7 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
+collections.workspace = true
 editor.workspace = true
 gpui.workspace = true
 menu.workspace = true

crates/go_to_line/src/go_to_line.rs πŸ”—

@@ -221,6 +221,7 @@ impl Render for GoToLine {
 mod tests {
     use std::sync::Arc;
 
+    use collections::HashSet;
     use gpui::{TestAppContext, VisualTestContext};
     use indoc::indoc;
     use project::{FakeFs, Project};
@@ -348,7 +349,10 @@ mod tests {
 
     fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
         editor.update(cx, |editor, cx| {
-            editor.highlighted_display_rows(cx).into_keys().collect()
+            editor
+                .highlighted_display_rows(HashSet::default(), cx)
+                .into_keys()
+                .collect()
         })
     }
 

crates/language/src/buffer.rs πŸ”—

@@ -109,6 +109,7 @@ pub struct Buffer {
     deferred_ops: OperationQueue<Operation>,
     capability: Capability,
     has_conflict: bool,
+    diff_base_version: usize,
 }
 
 /// An immutable, cheaply cloneable representation of a fixed
@@ -304,6 +305,8 @@ pub enum Event {
     Reloaded,
     /// The buffer's diff_base changed.
     DiffBaseChanged,
+    /// Buffer's excerpts for a certain diff base were recalculated.
+    DiffUpdated,
     /// The buffer's language was changed.
     LanguageChanged,
     /// The buffer's syntax trees were updated.
@@ -643,6 +646,7 @@ impl Buffer {
             was_dirty_before_starting_transaction: None,
             text: buffer,
             diff_base,
+            diff_base_version: 0,
             git_diff: git::diff::BufferDiff::new(),
             file,
             capability,
@@ -872,6 +876,7 @@ 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.diff_base_version += 1;
         if let Some(recalc_task) = self.git_diff_recalc(cx) {
             cx.spawn(|buffer, mut cx| async move {
                 recalc_task.await;
@@ -885,6 +890,11 @@ impl Buffer {
         }
     }
 
+    /// Returns a number, unique per diff base set to the buffer.
+    pub fn diff_base_version(&self) -> usize {
+        self.diff_base_version
+    }
+
     /// Recomputes the Git diff status.
     pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<()>> {
         let diff_base = self.diff_base.clone()?; // TODO: Make this an Arc
@@ -898,9 +908,10 @@ impl Buffer {
 
         Some(cx.spawn(|this, mut cx| async move {
             let buffer_diff = diff.await;
-            this.update(&mut cx, |this, _| {
+            this.update(&mut cx, |this, cx| {
                 this.git_diff = buffer_diff;
                 this.git_diff_update_count += 1;
+                cx.emit(Event::DiffUpdated);
             })
             .ok();
         }))

crates/language/src/language_settings.rs πŸ”—

@@ -335,6 +335,8 @@ pub struct FeaturesContent {
 pub enum SoftWrap {
     /// Do not soft wrap.
     None,
+    /// Prefer a single line generally, unless an overly long line is encountered.
+    PreferLine,
     /// Soft wrap lines that overflow the editor
     EditorWidth,
     /// Soft wrap lines at the preferred line length

crates/multi_buffer/src/multi_buffer.rs πŸ”—

@@ -87,6 +87,9 @@ pub enum Event {
     },
     Reloaded,
     DiffBaseChanged,
+    DiffUpdated {
+        buffer: Model<Buffer>,
+    },
     LanguageChanged,
     CapabilityChanged,
     Reparsed,
@@ -156,6 +159,7 @@ pub struct MultiBufferSnapshot {
     edit_count: usize,
     is_dirty: bool,
     has_conflict: bool,
+    show_headers: bool,
 }
 
 /// A boundary between [`Excerpt`]s in a [`MultiBuffer`]
@@ -269,6 +273,28 @@ struct ExcerptBytes<'a> {
 
 impl MultiBuffer {
     pub fn new(replica_id: ReplicaId, capability: Capability) -> Self {
+        Self {
+            snapshot: RefCell::new(MultiBufferSnapshot {
+                show_headers: true,
+                ..MultiBufferSnapshot::default()
+            }),
+            buffers: RefCell::default(),
+            subscriptions: Topic::default(),
+            singleton: false,
+            capability,
+            replica_id,
+            title: None,
+            history: History {
+                next_transaction_id: clock::Lamport::default(),
+                undo_stack: Vec::new(),
+                redo_stack: Vec::new(),
+                transaction_depth: 0,
+                group_interval: Duration::from_millis(300),
+            },
+        }
+    }
+
+    pub fn without_headers(replica_id: ReplicaId, capability: Capability) -> Self {
         Self {
             snapshot: Default::default(),
             buffers: Default::default(),
@@ -1466,6 +1492,7 @@ impl MultiBuffer {
             language::Event::FileHandleChanged => Event::FileHandleChanged,
             language::Event::Reloaded => Event::Reloaded,
             language::Event::DiffBaseChanged => Event::DiffBaseChanged,
+            language::Event::DiffUpdated => Event::DiffUpdated { buffer },
             language::Event::LanguageChanged => Event::LanguageChanged,
             language::Event::Reparsed => Event::Reparsed,
             language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated,
@@ -3588,6 +3615,10 @@ impl MultiBufferSnapshot {
                     })
             })
     }
+
+    pub fn show_headers(&self) -> bool {
+        self.show_headers
+    }
 }
 
 #[cfg(any(test, feature = "test-support"))]

crates/outline/Cargo.toml πŸ”—

@@ -13,6 +13,7 @@ path = "src/outline.rs"
 doctest = false
 
 [dependencies]
+collections.workspace = true
 editor.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true

crates/outline/src/outline.rs πŸ”—

@@ -98,6 +98,8 @@ struct OutlineViewDelegate {
     last_query: String,
 }
 
+enum OutlineRowHighlights {}
+
 impl OutlineViewDelegate {
     fn new(
         outline_view: WeakView<OutlineView>,
@@ -150,8 +152,6 @@ impl OutlineViewDelegate {
     }
 }
 
-enum OutlineRowHighlights {}
-
 impl PickerDelegate for OutlineViewDelegate {
     type ListItem = ListItem;
 
@@ -316,6 +316,7 @@ impl PickerDelegate for OutlineViewDelegate {
 
 #[cfg(test)]
 mod tests {
+    use collections::HashSet;
     use gpui::{TestAppContext, VisualTestContext};
     use indoc::indoc;
     use language::{Language, LanguageConfig, LanguageMatcher};
@@ -482,7 +483,10 @@ mod tests {
 
     fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
         editor.update(cx, |editor, cx| {
-            editor.highlighted_display_rows(cx).into_keys().collect()
+            editor
+                .highlighted_display_rows(HashSet::default(), cx)
+                .into_keys()
+                .collect()
         })
     }
 

crates/project/src/project_settings.rs πŸ”—

@@ -32,7 +32,7 @@ pub struct GitSettings {
     /// Whether or not to show git blame data inline in
     /// the currently focused line.
     ///
-    /// Default: off
+    /// Default: on
     pub inline_blame: Option<InlineBlameSettings>,
 }