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