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