mouse_context_menu.rs

  1use crate::{
  2    Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DisplayPoint, DisplaySnapshot, Editor,
  3    EvaluateSelectedText, FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation,
  4    GoToTypeDefinition, Paste, Rename, RevealInFileManager, RunToCursor, SelectMode,
  5    SelectionEffects, SelectionExt, ToDisplayPoint, ToggleCodeActions,
  6    actions::{Format, FormatSelections},
  7    selections_collection::SelectionsCollection,
  8};
  9use gpui::prelude::FluentBuilder;
 10use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscription, Window};
 11use project::DisableAiSettings;
 12use std::ops::Range;
 13use text::PointUtf16;
 14use workspace::OpenInTerminal;
 15use zed_actions::agent::AddSelectionToThread;
 16use zed_actions::preview::{
 17    markdown::OpenPreview as OpenMarkdownPreview, svg::OpenPreview as OpenSvgPreview,
 18};
 19
 20#[derive(Debug)]
 21pub enum MenuPosition {
 22    /// When the editor is scrolled, the context menu stays on the exact
 23    /// same position on the screen, never disappearing.
 24    PinnedToScreen(Point<Pixels>),
 25    /// When the editor is scrolled, the context menu follows the position it is associated with.
 26    /// Disappears when the position is no longer visible.
 27    PinnedToEditor {
 28        source: multi_buffer::Anchor,
 29        offset: Point<Pixels>,
 30    },
 31}
 32
 33pub struct MouseContextMenu {
 34    pub(crate) position: MenuPosition,
 35    pub(crate) context_menu: Entity<ui::ContextMenu>,
 36    _dismiss_subscription: Subscription,
 37    _cursor_move_subscription: Subscription,
 38}
 39
 40impl std::fmt::Debug for MouseContextMenu {
 41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 42        f.debug_struct("MouseContextMenu")
 43            .field("position", &self.position)
 44            .field("context_menu", &self.context_menu)
 45            .finish()
 46    }
 47}
 48
 49impl MouseContextMenu {
 50    pub(crate) fn pinned_to_editor(
 51        editor: &mut Editor,
 52        source: multi_buffer::Anchor,
 53        position: Point<Pixels>,
 54        context_menu: Entity<ui::ContextMenu>,
 55        window: &mut Window,
 56        cx: &mut Context<Editor>,
 57    ) -> Option<Self> {
 58        let editor_snapshot = editor.snapshot(window, cx);
 59        let content_origin = editor.last_bounds?.origin
 60            + Point {
 61                x: editor.gutter_dimensions.width,
 62                y: Pixels::ZERO,
 63            };
 64        let source_position = editor.to_pixel_point(source, &editor_snapshot, window, cx)?;
 65        let menu_position = MenuPosition::PinnedToEditor {
 66            source,
 67            offset: position - (source_position + content_origin),
 68        };
 69        Some(MouseContextMenu::new(
 70            editor,
 71            menu_position,
 72            context_menu,
 73            window,
 74            cx,
 75        ))
 76    }
 77
 78    pub(crate) fn new(
 79        editor: &Editor,
 80        position: MenuPosition,
 81        context_menu: Entity<ui::ContextMenu>,
 82        window: &mut Window,
 83        cx: &mut Context<Editor>,
 84    ) -> Self {
 85        let context_menu_focus = context_menu.focus_handle(cx);
 86
 87        // Since `ContextMenu` is rendered in a deferred fashion its focus
 88        // handle is not linked to the Editor's until after the deferred draw
 89        // callback runs.
 90        // We need to wait for that to happen before focusing it, so that
 91        // calling `contains_focused` on the editor's focus handle returns
 92        // `true` when the `ContextMenu` is focused.
 93        let focus_handle = context_menu_focus.clone();
 94        cx.on_next_frame(window, move |_, window, cx| {
 95            cx.on_next_frame(window, move |_, window, cx| {
 96                window.focus(&focus_handle, cx);
 97            });
 98        });
 99
100        let _dismiss_subscription = cx.subscribe_in(&context_menu, window, {
101            let context_menu_focus = context_menu_focus.clone();
102            move |editor, _, _event: &DismissEvent, window, cx| {
103                editor.mouse_context_menu.take();
104                if context_menu_focus.contains_focused(window, cx) {
105                    window.focus(&editor.focus_handle(cx), cx);
106                }
107            }
108        });
109
110        let selection_init = editor.selections.newest_anchor().clone();
111
112        let _cursor_move_subscription = cx.subscribe_in(
113            &cx.entity(),
114            window,
115            move |editor, _, event: &crate::EditorEvent, window, cx| {
116                let crate::EditorEvent::SelectionsChanged { local: true } = event else {
117                    return;
118                };
119                let display_snapshot = &editor
120                    .display_map
121                    .update(cx, |display_map, cx| display_map.snapshot(cx));
122                let selection_init_range = selection_init.display_range(display_snapshot);
123                let selection_now_range = editor
124                    .selections
125                    .newest_anchor()
126                    .display_range(display_snapshot);
127                if selection_now_range == selection_init_range {
128                    return;
129                }
130                editor.mouse_context_menu.take();
131                if context_menu_focus.contains_focused(window, cx) {
132                    window.focus(&editor.focus_handle(cx), cx);
133                }
134            },
135        );
136
137        Self {
138            position,
139            context_menu,
140            _dismiss_subscription,
141            _cursor_move_subscription,
142        }
143    }
144}
145
146fn display_ranges<'a>(
147    display_map: &'a DisplaySnapshot,
148    selections: &'a SelectionsCollection,
149) -> impl Iterator<Item = Range<DisplayPoint>> + 'a {
150    let pending = selections.pending_anchor();
151    selections
152        .disjoint_anchors()
153        .iter()
154        .chain(pending)
155        .map(move |s| s.start.to_display_point(display_map)..s.end.to_display_point(display_map))
156}
157
158pub fn deploy_context_menu(
159    editor: &mut Editor,
160    position: Option<Point<Pixels>>,
161    point: DisplayPoint,
162    window: &mut Window,
163    cx: &mut Context<Editor>,
164) {
165    if !editor.is_focused(window) {
166        window.focus(&editor.focus_handle(cx), cx);
167    }
168
169    let display_map = editor.display_snapshot(cx);
170    let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
171    let context_menu = if let Some(custom) = editor.custom_context_menu.take() {
172        let menu = custom(editor, point, window, cx);
173        editor.custom_context_menu = Some(custom);
174        let Some(menu) = menu else {
175            return;
176        };
177        menu
178    } else {
179        // Don't show context menu for inline editors (only applies to default menu)
180        if !editor.mode().is_full() {
181            return;
182        }
183
184        // Don't show the context menu if there isn't a project associated with this editor
185        let Some(project) = editor.project.clone() else {
186            return;
187        };
188
189        let snapshot = editor.snapshot(window, cx);
190        let display_map = editor.display_snapshot(cx);
191        let buffer = snapshot.buffer_snapshot();
192        let anchor = buffer.anchor_before(point.to_point(&display_map));
193        if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) {
194            // Move the cursor to the clicked location so that dispatched actions make sense
195            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
196                s.clear_disjoint();
197                s.set_pending_anchor_range(anchor..anchor, SelectMode::Character);
198            });
199        }
200
201        let focus = window.focused(cx);
202        let has_reveal_target = editor.target_file(cx).is_some();
203        let has_selections = editor
204            .selections
205            .all::<PointUtf16>(&display_map)
206            .into_iter()
207            .any(|s| !s.is_empty());
208        let has_git_repo =
209            buffer
210                .anchor_to_buffer_anchor(anchor)
211                .is_some_and(|(buffer_anchor, _)| {
212                    project
213                        .read(cx)
214                        .git_store()
215                        .read(cx)
216                        .repository_and_path_for_buffer_id(buffer_anchor.buffer_id, cx)
217                        .is_some()
218                });
219
220        let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
221        let run_to_cursor = window.is_action_available(&RunToCursor, cx);
222        let format_selections = window.is_action_available(&FormatSelections, cx);
223        let disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
224            editor.buffer.read(cx).as_singleton().as_ref(),
225            cx,
226        );
227
228        let is_markdown = editor
229            .buffer()
230            .read(cx)
231            .as_singleton()
232            .and_then(|buffer| buffer.read(cx).language())
233            .is_some_and(|language| language.name().as_ref() == "Markdown");
234
235        let is_svg = editor
236            .buffer()
237            .read(cx)
238            .as_singleton()
239            .and_then(|buffer| buffer.read(cx).file())
240            .is_some_and(|file| {
241                std::path::Path::new(file.file_name(cx))
242                    .extension()
243                    .is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
244            });
245
246        ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
247            let builder = menu
248                .on_blur_subscription(Subscription::new(|| {}))
249                .when(run_to_cursor, |builder| {
250                    builder.action("Run to Cursor", Box::new(RunToCursor))
251                })
252                .when(evaluate_selection && has_selections, |builder| {
253                    builder.action("Evaluate Selection", Box::new(EvaluateSelectedText))
254                })
255                .when(
256                    run_to_cursor || (evaluate_selection && has_selections),
257                    |builder| builder.separator(),
258                )
259                .action("Go to Definition", Box::new(GoToDefinition))
260                .action("Go to Declaration", Box::new(GoToDeclaration))
261                .action("Go to Type Definition", Box::new(GoToTypeDefinition))
262                .action("Go to Implementation", Box::new(GoToImplementation))
263                .action(
264                    "Find All References",
265                    Box::new(FindAllReferences::default()),
266                )
267                .separator()
268                .action("Rename Symbol", Box::new(Rename))
269                .action("Format Buffer", Box::new(Format))
270                .when(format_selections, |cx| {
271                    cx.action("Format Selections", Box::new(FormatSelections))
272                })
273                .action(
274                    "Show Code Actions",
275                    Box::new(ToggleCodeActions {
276                        deployed_from: None,
277                        quick_launch: false,
278                    }),
279                )
280                .when(!disable_ai && has_selections, |this| {
281                    this.action("Add to Agent Thread", Box::new(AddSelectionToThread))
282                })
283                .separator()
284                .action("Cut", Box::new(Cut))
285                .action("Copy", Box::new(Copy))
286                .action("Copy and Trim", Box::new(CopyAndTrim))
287                .action("Paste", Box::new(Paste))
288                .separator()
289                .action_disabled_when(
290                    !has_reveal_target,
291                    ui::utils::reveal_in_file_manager_label(false),
292                    Box::new(RevealInFileManager),
293                )
294                .when(is_markdown, |builder| {
295                    builder.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
296                })
297                .when(is_svg, |builder| {
298                    builder.action("Open SVG Preview", Box::new(OpenSvgPreview))
299                })
300                .action_disabled_when(
301                    !has_reveal_target,
302                    "Open in Terminal",
303                    Box::new(OpenInTerminal),
304                )
305                .action_disabled_when(
306                    !has_git_repo,
307                    "Copy Permalink",
308                    Box::new(CopyPermalinkToLine),
309                )
310                .action_disabled_when(
311                    !has_git_repo,
312                    "View File History",
313                    Box::new(git::FileHistory),
314                );
315            match focus {
316                Some(focus) => builder.context(focus),
317                None => builder,
318            }
319        })
320    };
321
322    editor.mouse_context_menu = match position {
323        Some(position) => MouseContextMenu::pinned_to_editor(
324            editor,
325            source_anchor,
326            position,
327            context_menu,
328            window,
329            cx,
330        ),
331        None => {
332            let character_size = editor.character_dimensions(window, cx);
333            let menu_position = MenuPosition::PinnedToEditor {
334                source: source_anchor,
335                offset: gpui::point(character_size.em_width, character_size.line_height),
336            };
337            Some(MouseContextMenu::new(
338                editor,
339                menu_position,
340                context_menu,
341                window,
342                cx,
343            ))
344        }
345    };
346    cx.notify();
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
353    use indoc::indoc;
354
355    #[gpui::test]
356    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
357        init_test(cx, |_| {});
358
359        let mut cx = EditorLspTestContext::new_rust(
360            lsp::ServerCapabilities {
361                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
362                ..Default::default()
363            },
364            cx,
365        )
366        .await;
367
368        cx.set_state(indoc! {"
369            fn teˇst() {
370                do_work();
371            }
372        "});
373        let point = cx.display_point(indoc! {"
374            fn test() {
375                do_wˇork();
376            }
377        "});
378        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
379
380        cx.update_editor(|editor, window, cx| {
381            deploy_context_menu(editor, Some(Default::default()), point, window, cx);
382
383            // Assert that, even after deploying the editor's mouse context
384            // menu, the editor's focus handle still contains the focused
385            // element. The pane's tab bar relies on this to determine whether
386            // to show the tab bar buttons and there was a small flicker when
387            // deploying the mouse context menu that would cause this to not be
388            // true, making it so that the buttons would disappear for a couple
389            // of frames.
390            assert!(editor.focus_handle.contains_focused(window, cx));
391        });
392
393        cx.assert_editor_state(indoc! {"
394            fn test() {
395                do_wˇork();
396            }
397        "});
398        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
399    }
400}