Rework mouse handling of git hunks diff (#14727)

Kirill Bulatov , Nate Butler , and Conrad Irwin created

Closes https://github.com/zed-industries/zed/issues/12404

![Screenshot 2024-07-18 at 14 02
31](https://github.com/user-attachments/assets/a8addd22-0ed9-4f4b-852a-f347314c27ce)

![Screenshot 2024-07-18 at 14 02
43](https://github.com/user-attachments/assets/0daaed10-b9f3-4d4b-b8d7-189aa7e013b9)

Video:


https://github.com/user-attachments/assets/58e62527-da75-4017-a43e-a37803bd7b49


* now shows a context menu on left click instead of expanding the hunk
diff
* hunk diffs can be toggled with a single cmd-click still
* adds a X mark into gutter for every hunk expanded
* makes `editor::ToggleDiffHunk` to work inside the deleted hunk editors

Additionally, changes the way editor context menus behave when the
editor is scrolled — right click and diff hunks context menu now will
stick to the place it was invoked at, instead of staying onscreen at the
same pixel positions.

Release Notes:

- Improved the way git hunks diff can be toggled with mouse
([#12404](https://github.com/zed-industries/zed/issues/12404))

---------

Co-authored-by: Nate Butler <nate@zed.dev>
Co-authored-by: Conrad Irwin <conrad@zed.dev>

Change summary

crates/diagnostics/src/grouped_diagnostics.rs |   2 
crates/editor/src/editor.rs                   | 154 +++++++--
crates/editor/src/element.rs                  | 333 ++++++++++++++------
crates/editor/src/hunk_diff.rs                | 231 +++++++++++++-
crates/editor/src/mouse_context_menu.rs       |  74 ++++
crates/ui/src/components/context_menu.rs      |   5 
6 files changed, 629 insertions(+), 170 deletions(-)

Detailed changes

crates/diagnostics/src/grouped_diagnostics.rs 🔗

@@ -319,7 +319,7 @@ impl GroupedDiagnosticsEditor {
                 || server_to_update.map_or(false, |to_update| *server_id != to_update)
         });
 
-        // TODO kb change selections as in the old panel, to the next primary diagnostics
+        // TODO change selections as in the old panel, to the next primary diagnostics
         // TODO make [shift-]f8 to work, jump to the next block group
         let _was_empty = self.path_states.is_empty();
         let path_ix = match self.path_states.binary_search_by(|probe| {

crates/editor/src/editor.rs 🔗

@@ -78,7 +78,7 @@ 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;
+pub(crate) use hunk_diff::HoveredHunk;
 use indent_guides::ActiveIndentGuidesState;
 use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
 pub use inline_completion_provider::*;
@@ -2873,7 +2873,10 @@ impl Editor {
     }
 
     pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        self.clear_expanded_diff_hunks(cx);
+        if self.clear_clicked_diff_hunks(cx) {
+            cx.notify();
+            return;
+        }
         if self.dismiss_menus_and_popups(true, cx) {
             return;
         }
@@ -2908,6 +2911,10 @@ impl Editor {
             return true;
         }
 
+        if self.mouse_context_menu.take().is_some() {
+            return true;
+        }
+
         if self.discard_inline_completion(should_report_inline_completion_event, cx) {
             return true;
         }
@@ -5125,6 +5132,23 @@ impl Editor {
             }))
     }
 
+    fn render_close_hunk_diff_button(
+        &self,
+        hunk: HoveredHunk,
+        row: DisplayRow,
+        cx: &mut ViewContext<Self>,
+    ) -> IconButton {
+        IconButton::new(
+            ("close_hunk_diff_indicator", row.0 as usize),
+            ui::IconName::Close,
+        )
+        .shape(ui::IconButtonShape::Square)
+        .icon_size(IconSize::XSmall)
+        .icon_color(Color::Muted)
+        .tooltip(|cx| Tooltip::for_action("Close hunk diff", &ToggleHunkDiff, cx))
+        .on_click(cx.listener(move |editor, _e, cx| editor.toggle_hovered_hunk(&hunk, cx)))
+    }
+
     pub fn context_menu_visible(&self) -> bool {
         self.context_menu
             .read()
@@ -5879,22 +5903,7 @@ impl Editor {
         let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx);
         if !revert_changes.is_empty() {
             self.transact(cx, |editor, cx| {
-                editor.buffer().update(cx, |multi_buffer, cx| {
-                    for (buffer_id, changes) in revert_changes {
-                        if let Some(buffer) = multi_buffer.buffer(buffer_id) {
-                            buffer.update(cx, |buffer, cx| {
-                                buffer.edit(
-                                    changes.into_iter().map(|(range, text)| {
-                                        (range, text.to_string().map(Arc::<str>::from))
-                                    }),
-                                    None,
-                                    cx,
-                                );
-                            });
-                        }
-                    }
-                });
-                editor.change_selections(None, cx, |selections| selections.refresh());
+                editor.revert(revert_changes, cx);
             });
         }
     }
@@ -5924,22 +5933,20 @@ impl Editor {
         cx: &mut ViewContext<'_, Editor>,
     ) -> HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>> {
         let mut revert_changes = HashMap::default();
-        self.buffer.update(cx, |multi_buffer, cx| {
-            let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-            for hunk in hunks_for_selections(&multi_buffer_snapshot, selections) {
-                Self::prepare_revert_change(&mut revert_changes, &multi_buffer, &hunk, cx);
-            }
-        });
+        let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
+        for hunk in hunks_for_selections(&multi_buffer_snapshot, selections) {
+            Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx);
+        }
         revert_changes
     }
 
-    fn prepare_revert_change(
+    pub fn prepare_revert_change(
         revert_changes: &mut HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>>,
-        multi_buffer: &MultiBuffer,
+        multi_buffer: &Model<MultiBuffer>,
         hunk: &DiffHunk<MultiBufferRow>,
-        cx: &mut AppContext,
+        cx: &AppContext,
     ) -> Option<()> {
-        let buffer = multi_buffer.buffer(hunk.buffer_id)?;
+        let buffer = multi_buffer.read(cx).buffer(hunk.buffer_id)?;
         let buffer = buffer.read(cx);
         let original_text = buffer.diff_base()?.slice(hunk.diff_base_byte_range.clone());
         let buffer_snapshot = buffer.snapshot();
@@ -11737,15 +11744,81 @@ impl Editor {
     pub fn file_header_size(&self) -> u8 {
         self.file_header_size
     }
+
+    pub fn revert(
+        &mut self,
+        revert_changes: HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.buffer().update(cx, |multi_buffer, cx| {
+            for (buffer_id, changes) in revert_changes {
+                if let Some(buffer) = multi_buffer.buffer(buffer_id) {
+                    buffer.update(cx, |buffer, cx| {
+                        buffer.edit(
+                            changes.into_iter().map(|(range, text)| {
+                                (range, text.to_string().map(Arc::<str>::from))
+                            }),
+                            None,
+                            cx,
+                        );
+                    });
+                }
+            }
+        });
+        self.change_selections(None, cx, |selections| selections.refresh());
+    }
+
+    pub fn to_pixel_point(
+        &mut self,
+        source: multi_buffer::Anchor,
+        editor_snapshot: &EditorSnapshot,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<gpui::Point<Pixels>> {
+        let text_layout_details = self.text_layout_details(cx);
+        let line_height = text_layout_details
+            .editor_style
+            .text
+            .line_height_in_pixels(cx.rem_size());
+        let source_point = source.to_display_point(editor_snapshot);
+        let first_visible_line = text_layout_details
+            .scroll_anchor
+            .anchor
+            .to_display_point(editor_snapshot);
+        if first_visible_line > source_point {
+            return None;
+        }
+        let source_x = editor_snapshot.x_for_display_point(source_point, &text_layout_details);
+        let source_y = line_height
+            * ((source_point.row() - first_visible_line.row()).0 as f32
+                - text_layout_details.scroll_anchor.offset.y);
+        Some(gpui::Point::new(source_x, source_y))
+    }
+
+    pub fn display_to_pixel_point(
+        &mut self,
+        source: DisplayPoint,
+        editor_snapshot: &EditorSnapshot,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<gpui::Point<Pixels>> {
+        let line_height = self.style()?.text.line_height_in_pixels(cx.rem_size());
+        let text_layout_details = self.text_layout_details(cx);
+        let first_visible_line = text_layout_details
+            .scroll_anchor
+            .anchor
+            .to_display_point(editor_snapshot);
+        if first_visible_line > source {
+            return None;
+        }
+        let source_x = editor_snapshot.x_for_display_point(source, &text_layout_details);
+        let source_y = line_height * (source.row() - first_visible_line.row()).0 as f32;
+        Some(gpui::Point::new(source_x, source_y))
+    }
 }
 
 fn hunks_for_selections(
     multi_buffer_snapshot: &MultiBufferSnapshot,
     selections: &[Selection<Anchor>],
 ) -> Vec<DiffHunk<MultiBufferRow>> {
-    let mut hunks = Vec::with_capacity(selections.len());
-    let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =
-        HashMap::default();
     let buffer_rows_for_selections = selections.iter().map(|selection| {
         let head = selection.head();
         let tail = selection.tail();
@@ -11758,7 +11831,17 @@ fn hunks_for_selections(
         }
     });
 
-    for selected_multi_buffer_rows in buffer_rows_for_selections {
+    hunks_for_rows(buffer_rows_for_selections, multi_buffer_snapshot)
+}
+
+pub fn hunks_for_rows(
+    rows: impl Iterator<Item = Range<MultiBufferRow>>,
+    multi_buffer_snapshot: &MultiBufferSnapshot,
+) -> Vec<DiffHunk<MultiBufferRow>> {
+    let mut hunks = Vec::new();
+    let mut processed_buffer_rows: HashMap<BufferId, HashSet<Range<text::Anchor>>> =
+        HashMap::default();
+    for selected_multi_buffer_rows in rows {
         let query_rows =
             selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row();
         for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) {
@@ -12968,8 +13051,13 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator<Item = &str> +
         })
 }
 
-pub trait RangeToAnchorExt {
+pub trait RangeToAnchorExt: Sized {
     fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor>;
+
+    fn to_display_points(self, snapshot: &EditorSnapshot) -> Range<DisplayPoint> {
+        let anchor_range = self.to_anchors(&snapshot.buffer_snapshot);
+        anchor_range.start.to_display_point(&snapshot)..anchor_range.end.to_display_point(&snapshot)
+    }
 }
 
 impl<T: ToOffset> RangeToAnchorExt for Range<T> {

crates/editor/src/element.rs 🔗

@@ -1,4 +1,7 @@
 use crate::editor_settings::ScrollBeyondLastLine;
+use crate::hunk_diff::ExpandedHunk;
+use crate::mouse_context_menu::MenuPosition;
+use crate::RangeToAnchorExt;
 use crate::TransformBlockId;
 use crate::{
     blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip},
@@ -21,13 +24,14 @@ use crate::{
     scroll::scroll_amount::ScrollAmount,
     CodeActionsMenu, CursorShape, DisplayPoint, DisplayRow, DocumentHighlightRead,
     DocumentHighlightWrite, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle,
-    ExpandExcerpts, GutterDimensions, HalfPageDown, HalfPageUp, HoveredCursor, HunkToExpand,
+    ExpandExcerpts, GutterDimensions, HalfPageDown, HalfPageUp, HoveredCursor, HoveredHunk,
     LineDown, LineUp, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase,
     Selection, SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
 };
 use client::ParticipantIndex;
 use collections::{BTreeMap, HashMap};
 use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
+use gpui::Subscription;
 use gpui::{
     anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
     transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
@@ -63,6 +67,7 @@ use sum_tree::Bias;
 use theme::{ActiveTheme, PlayerColor};
 use ui::prelude::*;
 use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
+use util::RangeExt;
 use util::ResultExt;
 use workspace::{item::Item, Workspace};
 
@@ -442,7 +447,7 @@ impl EditorElement {
     fn mouse_left_down(
         editor: &mut Editor,
         event: &MouseDownEvent,
-        hovered_hunk: Option<&HunkToExpand>,
+        hovered_hunk: Option<HoveredHunk>,
         position_map: &PositionMap,
         text_hitbox: &Hitbox,
         gutter_hitbox: &Hitbox,
@@ -456,7 +461,28 @@ impl EditorElement {
         let mut modifiers = event.modifiers;
 
         if let Some(hovered_hunk) = hovered_hunk {
-            editor.expand_diff_hunk(None, hovered_hunk, cx);
+            if modifiers.control || modifiers.platform {
+                editor.toggle_hovered_hunk(&hovered_hunk, cx);
+            } else {
+                let display_range = hovered_hunk
+                    .multi_buffer_range
+                    .clone()
+                    .to_display_points(&position_map.snapshot);
+                let hunk_bounds = Self::diff_hunk_bounds(
+                    &position_map.snapshot,
+                    position_map.line_height,
+                    gutter_hitbox.bounds,
+                    &DisplayDiffHunk::Unfolded {
+                        diff_base_byte_range: hovered_hunk.diff_base_byte_range.clone(),
+                        display_row_range: display_range.start.row()..display_range.end.row(),
+                        multi_buffer_range: hovered_hunk.multi_buffer_range.clone(),
+                        status: hovered_hunk.status,
+                    },
+                );
+                if hunk_bounds.contains(&event.position) {
+                    editor.open_hunk_context_menu(hovered_hunk, event.position, cx);
+                }
+            }
             cx.notify();
             return;
         } else if gutter_hitbox.is_hovered(cx) {
@@ -1245,47 +1271,18 @@ impl EditorElement {
                 .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<_, _>>()
-        });
-
         let git_gutter_setting = ProjectSettings::get_global(cx)
             .git
             .git_gutter
             .unwrap_or_default();
-        buffer_snapshot
+        let display_hunks = buffer_snapshot
             .git_diff_hunks_in_range(buffer_start_row..buffer_end_row)
             .map(|hunk| diff_hunk_to_display(&hunk, snapshot))
             .dedup()
             .map(|hunk| match git_gutter_setting {
                 GitGutterSetting::TrackedFiles => {
-                    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 hitbox = match hunk {
+                        DisplayDiffHunk::Unfolded { .. } => {
                             let hunk_bounds = Self::diff_hunk_bounds(
                                 &snapshot,
                                 line_height,
@@ -1294,14 +1291,14 @@ impl EditorElement {
                             );
                             Some(cx.insert_hitbox(hunk_bounds, true))
                         }
-                    } else {
-                        None
+                        DisplayDiffHunk::Folded { .. } => None,
                     };
                     (hunk, hitbox)
                 }
                 GitGutterSetting::Hide => (hunk, None),
             })
-            .collect()
+            .collect();
+        display_hunks
     }
 
     #[allow(clippy::too_many_arguments)]
@@ -1369,9 +1366,7 @@ impl EditorElement {
         };
 
         let absolute_offset = point(start_x, start_y);
-        let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
-
-        element.prepaint_as_root(absolute_offset, available_space, cx);
+        element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx);
 
         Some(element)
     }
@@ -2472,8 +2467,7 @@ impl EditorElement {
             return false;
         };
 
-        let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
-        let context_menu_size = context_menu.layout_as_root(available_space, cx);
+        let context_menu_size = context_menu.layout_as_root(AvailableSpace::min_size(), cx);
 
         let (x, y) = match position {
             crate::ContextMenuOrigin::EditorPoint(point) => {
@@ -2510,19 +2504,72 @@ impl EditorElement {
         true
     }
 
-    fn layout_mouse_context_menu(&self, cx: &mut WindowContext) -> Option<AnyElement> {
-        let mouse_context_menu = self.editor.read(cx).mouse_context_menu.as_ref()?;
-        let mut element = deferred(
-            anchored()
-                .position(mouse_context_menu.position)
-                .child(mouse_context_menu.context_menu.clone())
-                .anchor(AnchorCorner::TopLeft)
-                .snap_to_window(),
-        )
-        .with_priority(1)
-        .into_any();
+    fn layout_mouse_context_menu(
+        &self,
+        editor_snapshot: &EditorSnapshot,
+        visible_range: Range<DisplayRow>,
+        cx: &mut WindowContext,
+    ) -> Option<AnyElement> {
+        let position = self.editor.update(cx, |editor, cx| {
+            let visible_start_point = editor.display_to_pixel_point(
+                DisplayPoint::new(visible_range.start, 0),
+                editor_snapshot,
+                cx,
+            )?;
+            let visible_end_point = editor.display_to_pixel_point(
+                DisplayPoint::new(visible_range.end, 0),
+                editor_snapshot,
+                cx,
+            )?;
+
+            let mouse_context_menu = editor.mouse_context_menu.as_ref()?;
+            let (source_display_point, position) = match mouse_context_menu.position {
+                MenuPosition::PinnedToScreen(point) => (None, point),
+                MenuPosition::PinnedToEditor {
+                    source,
+                    offset_x,
+                    offset_y,
+                } => {
+                    let source_display_point = source.to_display_point(editor_snapshot);
+                    let mut source_point = editor.to_pixel_point(source, editor_snapshot, cx)?;
+                    source_point.x += offset_x;
+                    source_point.y += offset_y;
+                    (Some(source_display_point), source_point)
+                }
+            };
+
+            let source_included = source_display_point.map_or(true, |source_display_point| {
+                visible_range
+                    .to_inclusive()
+                    .contains(&source_display_point.row())
+            });
+            let position_included =
+                visible_start_point.y <= position.y && position.y <= visible_end_point.y;
+            if !source_included && !position_included {
+                None
+            } else {
+                Some(position)
+            }
+        })?;
+
+        let mut element = self.editor.update(cx, |editor, _| {
+            let mouse_context_menu = editor.mouse_context_menu.as_ref()?;
+            let context_menu = mouse_context_menu.context_menu.clone();
+
+            Some(
+                deferred(
+                    anchored()
+                        .position(position)
+                        .child(context_menu)
+                        .anchor(AnchorCorner::TopLeft)
+                        .snap_to_window(),
+                )
+                .with_priority(1)
+                .into_any(),
+            )
+        })?;
 
-        element.prepaint_as_root(gpui::Point::default(), AvailableSpace::min_size(), cx);
+        element.prepaint_as_root(position, AvailableSpace::min_size(), cx);
         Some(element)
     }
 
@@ -2569,8 +2616,6 @@ impl EditorElement {
             return;
         };
 
-        let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
-
         // This is safe because we check on layout whether the required row is available
         let hovered_row_layout =
             &line_layouts[position.row().minus(visible_display_row_range.start) as usize];
@@ -2584,7 +2629,7 @@ impl EditorElement {
         let mut overall_height = Pixels::ZERO;
         let mut measured_hover_popovers = Vec::new();
         for mut hover_popover in hover_popovers {
-            let size = hover_popover.layout_as_root(available_space, cx);
+            let size = hover_popover.layout_as_root(AvailableSpace::min_size(), cx);
             let horizontal_offset =
                 (text_hitbox.upper_right().x - (hovered_point.x + size.width)).min(Pixels::ZERO);
 
@@ -2953,7 +2998,7 @@ impl EditorElement {
         }
     }
 
-    fn paint_diff_hunks(layout: &EditorLayout, cx: &mut WindowContext) {
+    fn paint_diff_hunks(layout: &mut EditorLayout, cx: &mut WindowContext) {
         if layout.display_hunks.is_empty() {
             return;
         }
@@ -3018,7 +3063,7 @@ impl EditorElement {
     fn diff_hunk_bounds(
         snapshot: &EditorSnapshot,
         line_height: Pixels,
-        bounds: Bounds<Pixels>,
+        gutter_bounds: Bounds<Pixels>,
         hunk: &DisplayDiffHunk,
     ) -> Bounds<Pixels> {
         let scroll_position = snapshot.scroll_position();
@@ -3030,7 +3075,7 @@ impl EditorElement {
                 let end_y = start_y + line_height;
 
                 let width = 0.275 * line_height;
-                let highlight_origin = bounds.origin + point(px(0.), start_y);
+                let highlight_origin = gutter_bounds.origin + point(px(0.), start_y);
                 let highlight_size = size(width, end_y - start_y);
                 Bounds::new(highlight_origin, highlight_size)
             }
@@ -3063,7 +3108,7 @@ impl EditorElement {
                     let end_y = end_row_in_current_excerpt.as_f32() * line_height - scroll_top;
 
                     let width = 0.275 * line_height;
-                    let highlight_origin = bounds.origin + point(px(0.), start_y);
+                    let highlight_origin = gutter_bounds.origin + point(px(0.), start_y);
                     let highlight_size = size(width, end_y - start_y);
                     Bounds::new(highlight_origin, highlight_size)
                 }
@@ -3075,7 +3120,7 @@ impl EditorElement {
                     let end_y = start_y + line_height;
 
                     let width = 0.35 * line_height;
-                    let highlight_origin = bounds.origin + point(px(0.), start_y);
+                    let highlight_origin = gutter_bounds.origin + point(px(0.), start_y);
                     let highlight_size = size(width, end_y - start_y);
                     Bounds::new(highlight_origin, highlight_size)
                 }
@@ -3091,8 +3136,11 @@ impl EditorElement {
                 }
             });
 
-            for test_indicators in layout.test_indicators.iter_mut() {
-                test_indicators.paint(cx);
+            for test_indicator in layout.test_indicators.iter_mut() {
+                test_indicator.paint(cx);
+            }
+            for close_indicator in layout.close_indicators.iter_mut() {
+                close_indicator.paint(cx);
             }
 
             if let Some(indicator) = layout.code_actions_indicator.as_mut() {
@@ -3101,7 +3149,7 @@ impl EditorElement {
         });
     }
 
-    fn paint_gutter_highlights(&self, layout: &EditorLayout, cx: &mut WindowContext) {
+    fn paint_gutter_highlights(&self, layout: &mut EditorLayout, cx: &mut WindowContext) {
         for (_, hunk_hitbox) in &layout.display_hunks {
             if let Some(hunk_hitbox) = hunk_hitbox {
                 cx.set_cursor_style(CursorStyle::PointingHand, hunk_hitbox);
@@ -3757,7 +3805,7 @@ impl EditorElement {
     fn paint_mouse_listeners(
         &mut self,
         layout: &EditorLayout,
-        hovered_hunk: Option<HunkToExpand>,
+        hovered_hunk: Option<HoveredHunk>,
         cx: &mut WindowContext,
     ) {
         self.paint_scroll_wheel_listener(layout, cx);
@@ -3775,7 +3823,7 @@ impl EditorElement {
                             Self::mouse_left_down(
                                 editor,
                                 event,
-                                hovered_hunk.as_ref(),
+                                hovered_hunk.clone(),
                                 &position_map,
                                 &text_hitbox,
                                 &gutter_hitbox,
@@ -3881,6 +3929,43 @@ impl EditorElement {
             + 1;
         self.column_pixels(digit_count, cx)
     }
+
+    fn layout_hunk_diff_close_indicators(
+        &self,
+        expanded_hunks_by_rows: HashMap<DisplayRow, ExpandedHunk>,
+        line_height: Pixels,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        gutter_dimensions: &GutterDimensions,
+        gutter_hitbox: &Hitbox,
+        cx: &mut WindowContext,
+    ) -> Vec<AnyElement> {
+        self.editor.update(cx, |editor, cx| {
+            expanded_hunks_by_rows
+                .into_iter()
+                .map(|(display_row, hunk)| {
+                    let button = editor.render_close_hunk_diff_button(
+                        HoveredHunk {
+                            multi_buffer_range: hunk.hunk_range,
+                            status: hunk.status,
+                            diff_base_byte_range: hunk.diff_base_byte_range,
+                        },
+                        display_row,
+                        cx,
+                    );
+
+                    prepaint_gutter_button(
+                        button,
+                        display_row,
+                        line_height,
+                        gutter_dimensions,
+                        scroll_pixel_position,
+                        gutter_hitbox,
+                        cx,
+                    )
+                })
+                .collect()
+        })
+    }
 }
 
 fn prepaint_gutter_button(
@@ -4037,19 +4122,24 @@ fn deploy_blame_entry_context_menu(
     position: gpui::Point<Pixels>,
     cx: &mut WindowContext<'_>,
 ) {
-    let context_menu = ContextMenu::build(cx, move |this, _| {
+    let context_menu = ContextMenu::build(cx, move |menu, _| {
         let sha = format!("{}", blame_entry.sha);
-        this.entry("Copy commit SHA", None, move |cx| {
-            cx.write_to_clipboard(ClipboardItem::new(sha.clone()));
-        })
-        .when_some(
-            details.and_then(|details| details.permalink.clone()),
-            |this, url| this.entry("Open permalink", None, move |cx| cx.open_url(url.as_str())),
-        )
+        menu.on_blur_subscription(Subscription::new(|| {}))
+            .entry("Copy commit SHA", None, move |cx| {
+                cx.write_to_clipboard(ClipboardItem::new(sha.clone()));
+            })
+            .when_some(
+                details.and_then(|details| details.permalink.clone()),
+                |this, url| this.entry("Open permalink", None, move |cx| cx.open_url(url.as_str())),
+            )
     });
 
     editor.update(cx, move |editor, cx| {
-        editor.mouse_context_menu = Some(MouseContextMenu::new(position, context_menu, cx));
+        editor.mouse_context_menu = Some(MouseContextMenu::pinned_to_screen(
+            position,
+            context_menu,
+            cx,
+        ));
         cx.notify();
     });
 }
@@ -5087,6 +5177,22 @@ impl Element for EditorElement {
 
                     let gutter_settings = EditorSettings::get_global(cx).gutter;
 
+                    let expanded_add_hunks_by_rows = self.editor.update(cx, |editor, _| {
+                        editor
+                            .expanded_hunks
+                            .hunks(false)
+                            .filter(|hunk| hunk.status == DiffHunkStatus::Added)
+                            .map(|expanded_hunk| {
+                                let start_row = expanded_hunk
+                                    .hunk_range
+                                    .start
+                                    .to_display_point(&snapshot)
+                                    .row();
+                                (start_row, expanded_hunk.clone())
+                            })
+                            .collect::<HashMap<_, _>>()
+                    });
+
                     let mut _context_menu_visible = false;
                     let mut code_actions_indicator = None;
                     if let Some(newest_selection_head) = newest_selection_head {
@@ -5110,25 +5216,34 @@ impl Element for EditorElement {
                             if show_code_actions {
                                 let newest_selection_point =
                                     newest_selection_head.to_point(&snapshot.display_snapshot);
-                                let buffer = snapshot.buffer_snapshot.buffer_line_for_row(
-                                    MultiBufferRow(newest_selection_point.row),
-                                );
-                                if let Some((buffer, range)) = buffer {
-                                    let buffer_id = buffer.remote_id();
-                                    let row = range.start.row;
-                                    let has_test_indicator =
-                                        self.editor.read(cx).tasks.contains_key(&(buffer_id, row));
-
-                                    if !has_test_indicator {
-                                        code_actions_indicator = self
-                                            .layout_code_actions_indicator(
-                                                line_height,
-                                                newest_selection_head,
-                                                scroll_pixel_position,
-                                                &gutter_dimensions,
-                                                &gutter_hitbox,
-                                                cx,
-                                            );
+                                let newest_selection_display_row =
+                                    newest_selection_point.to_display_point(&snapshot).row();
+                                if !expanded_add_hunks_by_rows
+                                    .contains_key(&newest_selection_display_row)
+                                {
+                                    let buffer = snapshot.buffer_snapshot.buffer_line_for_row(
+                                        MultiBufferRow(newest_selection_point.row),
+                                    );
+                                    if let Some((buffer, range)) = buffer {
+                                        let buffer_id = buffer.remote_id();
+                                        let row = range.start.row;
+                                        let has_test_indicator = self
+                                            .editor
+                                            .read(cx)
+                                            .tasks
+                                            .contains_key(&(buffer_id, row));
+
+                                        if !has_test_indicator {
+                                            code_actions_indicator = self
+                                                .layout_code_actions_indicator(
+                                                    line_height,
+                                                    newest_selection_head,
+                                                    scroll_pixel_position,
+                                                    &gutter_dimensions,
+                                                    &gutter_hitbox,
+                                                    cx,
+                                                );
+                                        }
                                     }
                                 }
                             }
@@ -5145,9 +5260,18 @@ impl Element for EditorElement {
                             cx,
                         )
                     } else {
-                        vec![]
+                        Vec::new()
                     };
 
+                    let close_indicators = self.layout_hunk_diff_close_indicators(
+                        expanded_add_hunks_by_rows,
+                        line_height,
+                        scroll_pixel_position,
+                        &gutter_dimensions,
+                        &gutter_hitbox,
+                        cx,
+                    );
+
                     self.layout_signature_help(
                         &hitbox,
                         content_origin,
@@ -5175,7 +5299,8 @@ impl Element for EditorElement {
                         );
                     }
 
-                    let mouse_context_menu = self.layout_mouse_context_menu(cx);
+                    let mouse_context_menu =
+                        self.layout_mouse_context_menu(&snapshot, start_row..end_row, cx);
 
                     cx.with_element_namespace("gutter_fold_toggles", |cx| {
                         self.prepaint_gutter_fold_toggles(
@@ -5240,6 +5365,7 @@ impl Element for EditorElement {
                         text_hitbox,
                         gutter_hitbox,
                         gutter_dimensions,
+                        display_hunks,
                         content_origin,
                         scrollbar_layout,
                         active_rows,
@@ -5249,7 +5375,6 @@ impl Element for EditorElement {
                         redacted_ranges,
                         line_elements,
                         line_numbers,
-                        display_hunks,
                         blamed_display_rows,
                         inline_blame,
                         blocks,
@@ -5258,6 +5383,7 @@ impl Element for EditorElement {
                         selections,
                         mouse_context_menu,
                         test_indicators,
+                        close_indicators,
                         code_actions_indicator,
                         gutter_fold_toggles,
                         crease_trailers,
@@ -5310,7 +5436,7 @@ impl Element for EditorElement {
                         .map(|hitbox| hitbox.contains(&mouse_position))
                         .unwrap_or(false)
                     {
-                        Some(HunkToExpand {
+                        Some(HoveredHunk {
                             status: *status,
                             multi_buffer_range: multi_buffer_range.clone(),
                             diff_base_byte_range: diff_base_byte_range.clone(),
@@ -5390,6 +5516,7 @@ pub struct EditorLayout {
     selections: Vec<(PlayerColor, Vec<SelectionLayout>)>,
     code_actions_indicator: Option<AnyElement>,
     test_indicators: Vec<AnyElement>,
+    close_indicators: Vec<AnyElement>,
     gutter_fold_toggles: Vec<Option<AnyElement>>,
     crease_trailers: Vec<Option<CreaseTrailerLayout>>,
     mouse_context_menu: Option<AnyElement>,
@@ -5705,11 +5832,7 @@ impl CursorLayout {
                 .child(cursor_name.string.clone())
                 .into_any_element();
 
-            name_element.prepaint_as_root(
-                name_origin,
-                size(AvailableSpace::MinContent, AvailableSpace::MinContent),
-                cx,
-            );
+            name_element.prepaint_as_root(name_origin, AvailableSpace::min_size(), cx);
 
             self.cursor_name = Some(name_element);
         }

crates/editor/src/hunk_diff.rs 🔗

@@ -5,28 +5,31 @@ use std::{
 
 use collections::{hash_map, HashMap, HashSet};
 use git::diff::{DiffHunk, DiffHunkStatus};
-use gpui::{AppContext, Hsla, Model, Task, View};
+use gpui::{Action, AppContext, Hsla, Model, MouseButton, Subscription, Task, View};
 use language::Buffer;
 use multi_buffer::{
-    Anchor, ExcerptRange, MultiBuffer, MultiBufferRow, MultiBufferSnapshot, ToPoint,
+    Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferRow, MultiBufferSnapshot, ToPoint,
 };
 use settings::SettingsStore;
 use text::{BufferId, Point};
 use ui::{
-    div, ActiveTheme, Context as _, IntoElement, ParentElement, Styled, ViewContext, VisualContext,
+    h_flex, v_flex, ActiveTheme, Context as _, ContextMenu, InteractiveElement, IntoElement,
+    ParentElement, Pixels, Styled, ViewContext, VisualContext,
 };
 use util::{debug_panic, RangeExt};
 
 use crate::{
     editor_settings::CurrentLineHighlight,
     git::{diff_hunk_to_display, DisplayDiffHunk},
-    hunk_status, hunks_for_selections, BlockDisposition, BlockId, BlockProperties, BlockStyle,
-    DiffRowHighlight, Editor, EditorSnapshot, ExpandAllHunkDiffs, RangeToAnchorExt,
-    RevertSelectedHunks, ToDisplayPoint, ToggleHunkDiff,
+    hunk_status, hunks_for_selections,
+    mouse_context_menu::MouseContextMenu,
+    BlockDisposition, BlockId, BlockProperties, BlockStyle, DiffRowHighlight, Editor,
+    EditorSnapshot, ExpandAllHunkDiffs, RangeToAnchorExt, RevertSelectedHunks, ToDisplayPoint,
+    ToggleHunkDiff,
 };
 
 #[derive(Debug, Clone)]
-pub(super) struct HunkToExpand {
+pub(super) struct HoveredHunk {
     pub multi_buffer_range: Range<Anchor>,
     pub status: DiffHunkStatus,
     pub diff_base_byte_range: Range<usize>,
@@ -63,6 +66,123 @@ pub(super) struct ExpandedHunk {
 }
 
 impl Editor {
+    pub(super) fn open_hunk_context_menu(
+        &mut self,
+        hovered_hunk: HoveredHunk,
+        clicked_point: gpui::Point<Pixels>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        let focus_handle = self.focus_handle.clone();
+        let expanded = self
+            .expanded_hunks
+            .hunks(false)
+            .any(|expanded_hunk| expanded_hunk.hunk_range == hovered_hunk.multi_buffer_range);
+        let editor_handle = cx.view().clone();
+        let editor_snapshot = self.snapshot(cx);
+        let start_point = self
+            .to_pixel_point(hovered_hunk.multi_buffer_range.start, &editor_snapshot, cx)
+            .unwrap_or(clicked_point);
+        let end_point = self
+            .to_pixel_point(hovered_hunk.multi_buffer_range.start, &editor_snapshot, cx)
+            .unwrap_or(clicked_point);
+        let norm =
+            |a: gpui::Point<Pixels>, b: gpui::Point<Pixels>| (a.x - b.x).abs() + (a.y - b.y).abs();
+        let closest_source = if norm(start_point, clicked_point) < norm(end_point, clicked_point) {
+            hovered_hunk.multi_buffer_range.start
+        } else {
+            hovered_hunk.multi_buffer_range.end
+        };
+
+        self.mouse_context_menu = MouseContextMenu::pinned_to_editor(
+            self,
+            closest_source,
+            clicked_point,
+            ContextMenu::build(cx, move |menu, _| {
+                menu.on_blur_subscription(Subscription::new(|| {}))
+                    .context(focus_handle)
+                    .entry(
+                        if expanded {
+                            "Collapse Hunk"
+                        } else {
+                            "Expand Hunk"
+                        },
+                        Some(ToggleHunkDiff.boxed_clone()),
+                        {
+                            let editor = editor_handle.clone();
+                            let hunk = hovered_hunk.clone();
+                            move |cx| {
+                                editor.update(cx, |editor, cx| {
+                                    editor.toggle_hovered_hunk(&hunk, cx);
+                                });
+                            }
+                        },
+                    )
+                    .entry("Revert Hunk", Some(RevertSelectedHunks.boxed_clone()), {
+                        let editor = editor_handle.clone();
+                        let hunk = hovered_hunk.clone();
+                        move |cx| {
+                            let multi_buffer = editor.read(cx).buffer().clone();
+                            let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+                            let mut revert_changes = HashMap::default();
+                            if let Some(hunk) =
+                                crate::hunk_diff::to_diff_hunk(&hunk, &multi_buffer_snapshot)
+                            {
+                                Editor::prepare_revert_change(
+                                    &mut revert_changes,
+                                    &multi_buffer,
+                                    &hunk,
+                                    cx,
+                                );
+                            }
+                            if !revert_changes.is_empty() {
+                                editor.update(cx, |editor, cx| editor.revert(revert_changes, cx));
+                            }
+                        }
+                    })
+                    .entry("Revert File", None, {
+                        let editor = editor_handle.clone();
+                        move |cx| {
+                            let mut revert_changes = HashMap::default();
+                            let multi_buffer = editor.read(cx).buffer().clone();
+                            let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+                            for hunk in crate::hunks_for_rows(
+                                Some(MultiBufferRow(0)..multi_buffer_snapshot.max_buffer_row())
+                                    .into_iter(),
+                                &multi_buffer_snapshot,
+                            ) {
+                                Editor::prepare_revert_change(
+                                    &mut revert_changes,
+                                    &multi_buffer,
+                                    &hunk,
+                                    cx,
+                                );
+                            }
+                            if !revert_changes.is_empty() {
+                                editor.update(cx, |editor, cx| {
+                                    editor.transact(cx, |editor, cx| {
+                                        editor.revert(revert_changes, cx);
+                                    });
+                                });
+                            }
+                        }
+                    })
+            }),
+            cx,
+        )
+    }
+
+    pub(super) fn toggle_hovered_hunk(
+        &mut self,
+        hovered_hunk: &HoveredHunk,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        let editor_snapshot = self.snapshot(cx);
+        if let Some(diff_hunk) = to_diff_hunk(hovered_hunk, &editor_snapshot.buffer_snapshot) {
+            self.toggle_hunks_expanded(vec![diff_hunk], cx);
+            self.change_selections(None, cx, |selections| selections.refresh());
+        }
+    }
+
     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();
@@ -164,7 +284,7 @@ impl Editor {
                                         retain = false;
                                         break;
                                     } else {
-                                        hunks_to_expand.push(HunkToExpand {
+                                        hunks_to_expand.push(HoveredHunk {
                                             status,
                                             multi_buffer_range,
                                             diff_base_byte_range,
@@ -182,7 +302,7 @@ impl Editor {
                         let remaining_hunk_point_range =
                             Point::new(remaining_hunk.associated_range.start.0, 0)
                                 ..Point::new(remaining_hunk.associated_range.end.0, 0);
-                        hunks_to_expand.push(HunkToExpand {
+                        hunks_to_expand.push(HoveredHunk {
                             status: hunk_status(&remaining_hunk),
                             multi_buffer_range: remaining_hunk_point_range
                                 .to_anchors(&snapshot.buffer_snapshot),
@@ -215,7 +335,7 @@ impl Editor {
     pub(super) fn expand_diff_hunk(
         &mut self,
         diff_base_buffer: Option<Model<Buffer>>,
-        hunk: &HunkToExpand,
+        hunk: &HoveredHunk,
         cx: &mut ViewContext<'_, Editor>,
     ) -> Option<()> {
         let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
@@ -303,28 +423,58 @@ impl Editor {
         &mut self,
         diff_base_buffer: Model<Buffer>,
         deleted_text_height: u8,
-        hunk: &HunkToExpand,
+        hunk: &HoveredHunk,
         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_hunk_color, hunk, cx);
+        let editor = cx.view().clone();
         let editor_model = cx.model().clone();
+        let hunk = hunk.clone();
         let mut new_block_ids = self.insert_blocks(
             Some(BlockProperties {
                 position: hunk.multi_buffer_range.start,
                 height: editor_height.max(deleted_text_height),
                 style: BlockStyle::Flex,
+                disposition: BlockDisposition::Above,
                 render: Box::new(move |cx| {
+                    let close_button = editor.update(cx.context, |editor, cx| {
+                        let editor_snapshot = editor.snapshot(cx);
+                        let hunk_start_row = hunk
+                            .multi_buffer_range
+                            .start
+                            .to_display_point(&editor_snapshot)
+                            .row();
+                        editor.render_close_hunk_diff_button(hunk.clone(), hunk_start_row, cx)
+                    });
                     let gutter_dimensions = editor_model.read(cx).gutter_dimensions;
-                    div()
+                    let click_editor = editor.clone();
+                    h_flex()
                         .bg(deleted_hunk_color)
                         .size_full()
-                        .pl(gutter_dimensions.full_width())
+                        .child(
+                            v_flex()
+                                .justify_center()
+                                .max_w(gutter_dimensions.full_width())
+                                .min_w(gutter_dimensions.full_width())
+                                .size_full()
+                                .on_mouse_down(MouseButton::Left, {
+                                    let click_hunk = hunk.clone();
+                                    move |e, cx| {
+                                        let modifiers = e.modifiers;
+                                        if modifiers.control || modifiers.platform {
+                                            click_editor.update(cx, |editor, cx| {
+                                                editor.toggle_hovered_hunk(&click_hunk, cx);
+                                            });
+                                        }
+                                    }
+                                })
+                                .child(close_button),
+                        )
                         .child(editor_with_deleted_text.clone())
                         .into_any_element()
                 }),
-                disposition: BlockDisposition::Above,
             }),
             None,
             cx,
@@ -339,16 +489,21 @@ impl Editor {
         }
     }
 
-    pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut ViewContext<'_, Editor>) {
+    pub(super) fn clear_clicked_diff_hunks(&mut self, cx: &mut ViewContext<'_, Editor>) -> bool {
         self.expanded_hunks.hunk_update_tasks.clear();
+        self.clear_row_highlights::<DiffRowHighlight>();
         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);
+            .collect::<HashSet<_>>();
+        if to_remove.is_empty() {
+            false
+        } else {
+            self.remove_blocks(to_remove, None, cx);
+            true
+        }
     }
 
     pub(super) fn sync_expanded_diff_hunks(
@@ -457,7 +612,7 @@ impl Editor {
                                                 recalculated_hunks.next();
                                                 retain = true;
                                             } else {
-                                                hunks_to_reexpand.push(HunkToExpand {
+                                                hunks_to_reexpand.push(HoveredHunk {
                                                     status,
                                                     multi_buffer_range,
                                                     diff_base_byte_range,
@@ -522,6 +677,29 @@ impl Editor {
     }
 }
 
+fn to_diff_hunk(
+    hovered_hunk: &HoveredHunk,
+    multi_buffer_snapshot: &MultiBufferSnapshot,
+) -> Option<DiffHunk<MultiBufferRow>> {
+    let buffer_id = hovered_hunk
+        .multi_buffer_range
+        .start
+        .buffer_id
+        .or_else(|| hovered_hunk.multi_buffer_range.end.buffer_id)?;
+    let buffer_range = hovered_hunk.multi_buffer_range.start.text_anchor
+        ..hovered_hunk.multi_buffer_range.end.text_anchor;
+    let point_range = hovered_hunk
+        .multi_buffer_range
+        .to_point(&multi_buffer_snapshot);
+    Some(DiffHunk {
+        associated_range: MultiBufferRow(point_range.start.row)
+            ..MultiBufferRow(point_range.end.row),
+        buffer_id,
+        buffer_range,
+        diff_base_byte_range: hovered_hunk.diff_base_byte_range.clone(),
+    })
+}
+
 fn create_diff_base_buffer(buffer: &Model<Buffer>, cx: &mut AppContext) -> Option<Model<Buffer>> {
     buffer
         .update(cx, |buffer, _| {
@@ -555,7 +733,7 @@ fn deleted_hunk_color(cx: &AppContext) -> Hsla {
 fn editor_with_deleted_text(
     diff_base_buffer: Model<Buffer>,
     deleted_color: Hsla,
-    hunk: &HunkToExpand,
+    hunk: &HoveredHunk,
     cx: &mut ViewContext<'_, Editor>,
 ) -> (u8, View<Editor>) {
     let parent_editor = cx.view().downgrade();
@@ -613,11 +791,12 @@ fn editor_with_deleted_text(
                 }
             }),
         ]);
+        let parent_editor_for_reverts = parent_editor.clone();
         let original_multi_buffer_range = hunk.multi_buffer_range.clone();
         let diff_base_range = hunk.diff_base_byte_range.clone();
         editor
             .register_action::<RevertSelectedHunks>(move |_, cx| {
-                parent_editor
+                parent_editor_for_reverts
                     .update(cx, |editor, cx| {
                         let Some((buffer, original_text)) =
                             editor.buffer().update(cx, |buffer, cx| {
@@ -645,6 +824,16 @@ fn editor_with_deleted_text(
                     .ok();
             })
             .detach();
+        let hunk = hunk.clone();
+        editor
+            .register_action::<ToggleHunkDiff>(move |_, cx| {
+                parent_editor
+                    .update(cx, |editor, cx| {
+                        editor.toggle_hovered_hunk(&hunk, cx);
+                    })
+                    .ok();
+            })
+            .detach();
         editor
     });
 

crates/editor/src/mouse_context_menu.rs 🔗

@@ -10,14 +10,62 @@ use gpui::prelude::FluentBuilder;
 use gpui::{DismissEvent, Pixels, Point, Subscription, View, ViewContext};
 use workspace::OpenInTerminal;
 
+pub enum MenuPosition {
+    /// When the editor is scrolled, the context menu stays on the exact
+    /// same position on the screen, never disappearing.
+    PinnedToScreen(Point<Pixels>),
+    /// When the editor is scrolled, the context menu follows the position it is associated with.
+    /// Disappears when the position is no longer visible.
+    PinnedToEditor {
+        source: multi_buffer::Anchor,
+        offset_x: Pixels,
+        offset_y: Pixels,
+    },
+}
+
 pub struct MouseContextMenu {
-    pub(crate) position: Point<Pixels>,
+    pub(crate) position: MenuPosition,
     pub(crate) context_menu: View<ui::ContextMenu>,
     _subscription: Subscription,
 }
 
 impl MouseContextMenu {
-    pub(crate) fn new(
+    pub(crate) fn pinned_to_editor(
+        editor: &mut Editor,
+        source: multi_buffer::Anchor,
+        position: Point<Pixels>,
+        context_menu: View<ui::ContextMenu>,
+        cx: &mut ViewContext<Editor>,
+    ) -> Option<Self> {
+        let context_menu_focus = context_menu.focus_handle(cx);
+        cx.focus(&context_menu_focus);
+
+        let _subscription = cx.subscribe(
+            &context_menu,
+            move |editor, _, _event: &DismissEvent, cx| {
+                editor.mouse_context_menu.take();
+                if context_menu_focus.contains_focused(cx) {
+                    editor.focus(cx);
+                }
+            },
+        );
+
+        let editor_snapshot = editor.snapshot(cx);
+        let source_point = editor.to_pixel_point(source, &editor_snapshot, cx)?;
+        let offset = position - source_point;
+
+        Some(Self {
+            position: MenuPosition::PinnedToEditor {
+                source,
+                offset_x: offset.x,
+                offset_y: offset.y,
+            },
+            context_menu,
+            _subscription,
+        })
+    }
+
+    pub(crate) fn pinned_to_screen(
         position: Point<Pixels>,
         context_menu: View<ui::ContextMenu>,
         cx: &mut ViewContext<Editor>,
@@ -25,16 +73,18 @@ impl MouseContextMenu {
         let context_menu_focus = context_menu.focus_handle(cx);
         cx.focus(&context_menu_focus);
 
-        let _subscription =
-            cx.subscribe(&context_menu, move |this, _, _event: &DismissEvent, cx| {
-                this.mouse_context_menu.take();
+        let _subscription = cx.subscribe(
+            &context_menu,
+            move |editor, _, _event: &DismissEvent, cx| {
+                editor.mouse_context_menu.take();
                 if context_menu_focus.contains_focused(cx) {
-                    this.focus(cx);
+                    editor.focus(cx);
                 }
-            });
+            },
+        );
 
         Self {
-            position,
+            position: MenuPosition::PinnedToScreen(position),
             context_menu,
             _subscription,
         }
@@ -71,6 +121,8 @@ pub fn deploy_context_menu(
         return;
     }
 
+    let display_map = editor.selections.display_map(cx);
+    let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
     let context_menu = if let Some(custom) = editor.custom_context_menu.take() {
         let menu = custom(editor, point, cx);
         editor.custom_context_menu = Some(custom);
@@ -98,6 +150,7 @@ pub fn deploy_context_menu(
         let focus = cx.focused();
         ui::ContextMenu::build(cx, |menu, _cx| {
             let builder = menu
+                .on_blur_subscription(Subscription::new(|| {}))
                 .action("Rename Symbol", Box::new(Rename))
                 .action("Go to Definition", Box::new(GoToDefinition))
                 .action("Go to Type Definition", Box::new(GoToTypeDefinition))
@@ -128,8 +181,9 @@ pub fn deploy_context_menu(
             }
         })
     };
-    let mouse_context_menu = MouseContextMenu::new(position, context_menu, cx);
-    editor.mouse_context_menu = Some(mouse_context_menu);
+
+    editor.mouse_context_menu =
+        MouseContextMenu::pinned_to_editor(editor, source_anchor, position, context_menu, cx);
     cx.notify();
 }
 

crates/ui/src/components/context_menu.rs 🔗

@@ -285,6 +285,11 @@ impl ContextMenu {
             cx.propagate()
         }
     }
+
+    pub fn on_blur_subscription(mut self, new_subscription: Subscription) -> Self {
+        self._on_blur_subscription = new_subscription;
+        self
+    }
 }
 
 impl ContextMenuItem {