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 disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
223            editor.buffer.read(cx).as_singleton().as_ref(),
224            cx,
225        );
226
227        let is_markdown = editor
228            .buffer()
229            .read(cx)
230            .as_singleton()
231            .and_then(|buffer| buffer.read(cx).language())
232            .is_some_and(|language| language.name().as_ref() == "Markdown");
233
234        let is_svg = editor
235            .buffer()
236            .read(cx)
237            .as_singleton()
238            .and_then(|buffer| buffer.read(cx).file())
239            .is_some_and(|file| {
240                std::path::Path::new(file.file_name(cx))
241                    .extension()
242                    .is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
243            });
244
245        ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
246            let builder = menu
247                .on_blur_subscription(Subscription::new(|| {}))
248                .when(run_to_cursor, |builder| {
249                    builder.action("Run to Cursor", Box::new(RunToCursor))
250                })
251                .when(evaluate_selection && has_selections, |builder| {
252                    builder.action("Evaluate Selection", Box::new(EvaluateSelectedText))
253                })
254                .when(
255                    run_to_cursor || (evaluate_selection && has_selections),
256                    |builder| builder.separator(),
257                )
258                .action("Go to Definition", Box::new(GoToDefinition))
259                .action("Go to Declaration", Box::new(GoToDeclaration))
260                .action("Go to Type Definition", Box::new(GoToTypeDefinition))
261                .action("Go to Implementation", Box::new(GoToImplementation))
262                .action(
263                    "Find All References",
264                    Box::new(FindAllReferences::default()),
265                )
266                .separator()
267                .action("Rename Symbol", Box::new(Rename))
268                .action("Format Buffer", Box::new(Format))
269                .when(has_selections, |cx| {
270                    cx.action("Format Selections", Box::new(FormatSelections))
271                })
272                .action(
273                    "Show Code Actions",
274                    Box::new(ToggleCodeActions {
275                        deployed_from: None,
276                        quick_launch: false,
277                    }),
278                )
279                .when(!disable_ai && has_selections, |this| {
280                    this.action("Add to Agent Thread", Box::new(AddSelectionToThread))
281                })
282                .separator()
283                .action("Cut", Box::new(Cut))
284                .action("Copy", Box::new(Copy))
285                .action("Copy and Trim", Box::new(CopyAndTrim))
286                .action("Paste", Box::new(Paste))
287                .separator()
288                .action_disabled_when(
289                    !has_reveal_target,
290                    ui::utils::reveal_in_file_manager_label(false),
291                    Box::new(RevealInFileManager),
292                )
293                .when(is_markdown, |builder| {
294                    builder.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
295                })
296                .when(is_svg, |builder| {
297                    builder.action("Open SVG Preview", Box::new(OpenSvgPreview))
298                })
299                .action_disabled_when(
300                    !has_reveal_target,
301                    "Open in Terminal",
302                    Box::new(OpenInTerminal),
303                )
304                .action_disabled_when(
305                    !has_git_repo,
306                    "Copy Permalink",
307                    Box::new(CopyPermalinkToLine),
308                )
309                .action_disabled_when(
310                    !has_git_repo,
311                    "View File History",
312                    Box::new(git::FileHistory),
313                );
314            match focus {
315                Some(focus) => builder.context(focus),
316                None => builder,
317            }
318        })
319    };
320
321    editor.mouse_context_menu = match position {
322        Some(position) => MouseContextMenu::pinned_to_editor(
323            editor,
324            source_anchor,
325            position,
326            context_menu,
327            window,
328            cx,
329        ),
330        None => {
331            let character_size = editor.character_dimensions(window, cx);
332            let menu_position = MenuPosition::PinnedToEditor {
333                source: source_anchor,
334                offset: gpui::point(character_size.em_width, character_size.line_height),
335            };
336            Some(MouseContextMenu::new(
337                editor,
338                menu_position,
339                context_menu,
340                window,
341                cx,
342            ))
343        }
344    };
345    cx.notify();
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
352    use indoc::indoc;
353
354    #[gpui::test]
355    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
356        init_test(cx, |_| {});
357
358        let mut cx = EditorLspTestContext::new_rust(
359            lsp::ServerCapabilities {
360                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
361                ..Default::default()
362            },
363            cx,
364        )
365        .await;
366
367        cx.set_state(indoc! {"
368            fn teˇst() {
369                do_work();
370            }
371        "});
372        let point = cx.display_point(indoc! {"
373            fn test() {
374                do_wˇork();
375            }
376        "});
377        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
378
379        cx.update_editor(|editor, window, cx| {
380            deploy_context_menu(editor, Some(Default::default()), point, window, cx);
381
382            // Assert that, even after deploying the editor's mouse context
383            // menu, the editor's focus handle still contains the focused
384            // element. The pane's tab bar relies on this to determine whether
385            // to show the tab bar buttons and there was a small flicker when
386            // deploying the mouse context menu that would cause this to not be
387            // true, making it so that the buttons would disappear for a couple
388            // of frames.
389            assert!(editor.focus_handle.contains_focused(window, cx));
390        });
391
392        cx.assert_editor_state(indoc! {"
393            fn test() {
394                do_wˇork();
395            }
396        "});
397        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
398    }
399}