mouse_context_menu.rs

  1use crate::{
  2    ConfirmCodeAction, Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DebuggerEvaluateSelectedText,
  3    DisplayPoint, DisplaySnapshot, Editor, FindAllReferences, GoToDeclaration, GoToDefinition,
  4    GoToImplementation, GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode,
  5    SelectionExt, ToDisplayPoint, ToggleCodeActions,
  6    actions::{Format, FormatSelections},
  7    code_context_menus::CodeActionContents,
  8    selections_collection::SelectionsCollection,
  9};
 10use feature_flags::{Debugger, FeatureFlagAppExt as _};
 11use gpui::prelude::FluentBuilder;
 12use gpui::{
 13    Context, DismissEvent, Entity, FocusHandle, Focusable as _, Pixels, Point, Subscription, Task,
 14    Window,
 15};
 16use std::ops::Range;
 17use text::PointUtf16;
 18use ui::ContextMenu;
 19use util::ResultExt;
 20use workspace::OpenInTerminal;
 21
 22#[derive(Debug)]
 23pub enum MenuPosition {
 24    /// When the editor is scrolled, the context menu stays on the exact
 25    /// same position on the screen, never disappearing.
 26    PinnedToScreen(Point<Pixels>),
 27    /// When the editor is scrolled, the context menu follows the position it is associated with.
 28    /// Disappears when the position is no longer visible.
 29    PinnedToEditor {
 30        source: multi_buffer::Anchor,
 31        offset: Point<Pixels>,
 32    },
 33}
 34
 35pub struct MouseCodeAction {
 36    pub actions: CodeActionContents,
 37    pub buffer: Entity<language::Buffer>,
 38}
 39
 40pub struct MouseContextMenu {
 41    pub(crate) position: MenuPosition,
 42    pub(crate) context_menu: Entity<ui::ContextMenu>,
 43    pub(crate) code_action: Option<MouseCodeAction>,
 44    _dismiss_subscription: Subscription,
 45    _cursor_move_subscription: Subscription,
 46}
 47
 48enum CodeActionLoadState {
 49    Loading,
 50    Loaded(CodeActionContents),
 51}
 52
 53impl std::fmt::Debug for MouseContextMenu {
 54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 55        f.debug_struct("MouseContextMenu")
 56            .field("position", &self.position)
 57            .field("context_menu", &self.context_menu)
 58            .finish()
 59    }
 60}
 61
 62impl MouseContextMenu {
 63    pub(crate) fn pinned_to_editor(
 64        editor: &mut Editor,
 65        source: multi_buffer::Anchor,
 66        position: Point<Pixels>,
 67        code_action: Option<MouseCodeAction>,
 68        context_menu: Entity<ui::ContextMenu>,
 69        window: &mut Window,
 70        cx: &mut Context<Editor>,
 71    ) -> Option<Self> {
 72        let editor_snapshot = editor.snapshot(window, cx);
 73        let content_origin = editor.last_bounds?.origin
 74            + Point {
 75                x: editor.gutter_dimensions.width,
 76                y: Pixels(0.0),
 77            };
 78        let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?;
 79        let menu_position = MenuPosition::PinnedToEditor {
 80            source,
 81            offset: position - (source_position + content_origin),
 82        };
 83        return Some(MouseContextMenu::new(
 84            editor,
 85            menu_position,
 86            context_menu,
 87            code_action,
 88            window,
 89            cx,
 90        ));
 91    }
 92
 93    pub(crate) fn new(
 94        editor: &Editor,
 95        position: MenuPosition,
 96        context_menu: Entity<ui::ContextMenu>,
 97        code_action: Option<MouseCodeAction>,
 98        window: &mut Window,
 99        cx: &mut Context<Editor>,
100    ) -> Self {
101        let context_menu_focus = context_menu.focus_handle(cx);
102        window.focus(&context_menu_focus);
103
104        let _dismiss_subscription = cx.subscribe_in(&context_menu, window, {
105            let context_menu_focus = context_menu_focus.clone();
106            move |editor, _, _event: &DismissEvent, window, cx| {
107                editor.mouse_context_menu.take();
108                if context_menu_focus.contains_focused(window, cx) {
109                    window.focus(&editor.focus_handle(cx));
110                }
111            }
112        });
113
114        let selection_init = editor.selections.newest_anchor().clone();
115
116        let _cursor_move_subscription = cx.subscribe_in(
117            &cx.entity(),
118            window,
119            move |editor, _, event: &crate::EditorEvent, window, cx| {
120                let crate::EditorEvent::SelectionsChanged { local: true } = event else {
121                    return;
122                };
123                let display_snapshot = &editor
124                    .display_map
125                    .update(cx, |display_map, cx| display_map.snapshot(cx));
126                let selection_init_range = selection_init.display_range(&display_snapshot);
127                let selection_now_range = editor
128                    .selections
129                    .newest_anchor()
130                    .display_range(&display_snapshot);
131                if selection_now_range == selection_init_range {
132                    return;
133                }
134                editor.mouse_context_menu.take();
135                if context_menu_focus.contains_focused(window, cx) {
136                    window.focus(&editor.focus_handle(cx));
137                }
138            },
139        );
140
141        Self {
142            position,
143            context_menu,
144            code_action,
145            _dismiss_subscription,
146            _cursor_move_subscription,
147        }
148    }
149}
150
151fn display_ranges<'a>(
152    display_map: &'a DisplaySnapshot,
153    selections: &'a SelectionsCollection,
154) -> impl Iterator<Item = Range<DisplayPoint>> + 'a {
155    let pending = selections
156        .pending
157        .as_ref()
158        .map(|pending| &pending.selection);
159    selections
160        .disjoint
161        .iter()
162        .chain(pending)
163        .map(move |s| s.start.to_display_point(display_map)..s.end.to_display_point(display_map))
164}
165
166pub fn deploy_context_menu(
167    editor: &mut Editor,
168    position: Option<Point<Pixels>>,
169    point: DisplayPoint,
170    window: &mut Window,
171    cx: &mut Context<Editor>,
172) {
173    if !editor.is_focused(window) {
174        window.focus(&editor.focus_handle(cx));
175    }
176
177    // Don't show context menu for inline editors
178    if !editor.mode().is_full() {
179        return;
180    }
181
182    let display_map = editor.selections.display_map(cx);
183    let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
184    if let Some(custom) = editor.custom_context_menu.take() {
185        let menu = custom(editor, point, window, cx);
186        editor.custom_context_menu = Some(custom);
187        let Some(menu) = menu else {
188            return;
189        };
190        set_context_menu(editor, menu, source_anchor, position, None, window, cx);
191    } else {
192        // Don't show the context menu if there isn't a project associated with this editor
193        let Some(project) = editor.project.clone() else {
194            return;
195        };
196
197        let display_map = editor.selections.display_map(cx);
198        let buffer = &editor.snapshot(window, cx).buffer_snapshot;
199        let anchor = buffer.anchor_before(point.to_point(&display_map));
200        if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) {
201            // Move the cursor to the clicked location so that dispatched actions make sense
202            editor.change_selections(None, window, cx, |s| {
203                s.clear_disjoint();
204                s.set_pending_anchor_range(anchor..anchor, SelectMode::Character);
205            });
206        }
207
208        let focus = window.focused(cx);
209        let has_reveal_target = editor.target_file(cx).is_some();
210        let has_selections = editor
211            .selections
212            .all::<PointUtf16>(cx)
213            .into_iter()
214            .any(|s| !s.is_empty());
215        let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| {
216            project
217                .read(cx)
218                .git_store()
219                .read(cx)
220                .repository_and_path_for_buffer_id(buffer_id, cx)
221                .is_some()
222        });
223
224        let evaluate_selection = command_palette_hooks::CommandPaletteFilter::try_global(cx)
225            .map_or(false, |filter| {
226                !filter.is_hidden(&DebuggerEvaluateSelectedText)
227            });
228
229        let menu = build_context_menu(
230            focus,
231            has_selections,
232            has_reveal_target,
233            has_git_repo,
234            evaluate_selection,
235            Some(CodeActionLoadState::Loading),
236            window,
237            cx,
238        );
239
240        set_context_menu(editor, menu, source_anchor, position, None, window, cx);
241
242        let mut actions_task = editor.code_actions_task.take();
243        cx.spawn_in(window, async move |editor, cx| {
244            while let Some(prev_task) = actions_task {
245                prev_task.await.log_err();
246                actions_task = editor.update(cx, |this, _| this.code_actions_task.take())?;
247            }
248            let action = ToggleCodeActions {
249                deployed_from_indicator: Some(point.row()),
250            };
251            let context_menu_task = editor.update_in(cx, |editor, window, cx| {
252                let code_actions_task = editor.prepare_code_actions_task(&action, window, cx);
253                Some(cx.spawn_in(window, async move |editor, cx| {
254                    let code_action_result = code_actions_task.await;
255                    if let Ok(editor_task) = editor.update_in(cx, |editor, window, cx| {
256                        let Some(mouse_context_menu) = editor.mouse_context_menu.take() else {
257                            return Task::ready(Ok::<_, anyhow::Error>(()));
258                        };
259                        if mouse_context_menu
260                            .context_menu
261                            .focus_handle(cx)
262                            .contains_focused(window, cx)
263                        {
264                            window.focus(&editor.focus_handle(cx));
265                        }
266                        drop(mouse_context_menu);
267                        let (state, code_action) =
268                            if let Some((buffer, actions)) = code_action_result {
269                                (
270                                    CodeActionLoadState::Loaded(actions.clone()),
271                                    Some(MouseCodeAction { actions, buffer }),
272                                )
273                            } else {
274                                (
275                                    CodeActionLoadState::Loaded(CodeActionContents::default()),
276                                    None,
277                                )
278                            };
279                        let menu = build_context_menu(
280                            window.focused(cx),
281                            has_selections,
282                            has_reveal_target,
283                            has_git_repo,
284                            evaluate_selection,
285                            Some(state),
286                            window,
287                            cx,
288                        );
289                        set_context_menu(
290                            editor,
291                            menu,
292                            source_anchor,
293                            position,
294                            code_action,
295                            window,
296                            cx,
297                        );
298                        Task::ready(Ok(()))
299                    }) {
300                        editor_task.await
301                    } else {
302                        Ok(())
303                    }
304                }))
305            })?;
306            if let Some(task) = context_menu_task {
307                task.await?;
308            }
309            Ok::<_, anyhow::Error>(())
310        })
311        .detach_and_log_err(cx);
312    };
313}
314
315fn build_context_menu(
316    focus: Option<FocusHandle>,
317    has_selections: bool,
318    has_reveal_target: bool,
319    has_git_repo: bool,
320    evaluate_selection: bool,
321    code_action_load_state: Option<CodeActionLoadState>,
322    window: &mut Window,
323    cx: &mut Context<Editor>,
324) -> Entity<ContextMenu> {
325    ui::ContextMenu::build(window, cx, |menu, _window, cx| {
326        let menu = menu
327            .on_blur_subscription(Subscription::new(|| {}))
328            .when(evaluate_selection && has_selections, |builder| {
329                builder
330                    .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText))
331                    .separator()
332            })
333            .action("Go to Definition", Box::new(GoToDefinition))
334            .action("Go to Declaration", Box::new(GoToDeclaration))
335            .action("Go to Type Definition", Box::new(GoToTypeDefinition))
336            .action("Go to Implementation", Box::new(GoToImplementation))
337            .action("Find All References", Box::new(FindAllReferences))
338            .separator()
339            .action("Rename Symbol", Box::new(Rename))
340            .action("Format Buffer", Box::new(Format))
341            .when(has_selections, |cx| {
342                cx.action("Format Selections", Box::new(FormatSelections))
343            })
344            .separator()
345            .action("Cut", Box::new(Cut))
346            .action("Copy", Box::new(Copy))
347            .action("Copy and trim", Box::new(CopyAndTrim))
348            .action("Paste", Box::new(Paste))
349            .separator()
350            .map(|builder| {
351                let reveal_in_finder_label = if cfg!(target_os = "macos") {
352                    "Reveal in Finder"
353                } else {
354                    "Reveal in File Manager"
355                };
356                const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal";
357                if has_reveal_target {
358                    builder
359                        .action(reveal_in_finder_label, Box::new(RevealInFileManager))
360                        .action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
361                } else {
362                    builder
363                        .disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager))
364                        .disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
365                }
366            })
367            .map(|builder| {
368                const COPY_PERMALINK_LABEL: &str = "Copy Permalink";
369                if has_git_repo {
370                    builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
371                } else {
372                    builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
373                }
374            })
375            .when_some(code_action_load_state, |menu, state| {
376                menu.separator().map(|menu| match state {
377                    CodeActionLoadState::Loading => menu.disabled_action(
378                        "Loading code actions...",
379                        Box::new(ConfirmCodeAction {
380                            item_ix: None,
381                            from_mouse_context_menu: true,
382                        }),
383                    ),
384                    CodeActionLoadState::Loaded(actions) => {
385                        if actions.is_empty() {
386                            menu.disabled_action(
387                                "No code actions available",
388                                Box::new(ConfirmCodeAction {
389                                    item_ix: None,
390                                    from_mouse_context_menu: true,
391                                }),
392                            )
393                        } else {
394                            actions
395                                .iter()
396                                .filter(|action| {
397                                    if action
398                                        .as_task()
399                                        .map(|task| {
400                                            matches!(task.task_type(), task::TaskType::Debug(_))
401                                        })
402                                        .unwrap_or(false)
403                                    {
404                                        cx.has_flag::<Debugger>()
405                                    } else {
406                                        true
407                                    }
408                                })
409                                .enumerate()
410                                .fold(menu, |menu, (ix, action)| {
411                                    menu.action(
412                                        action.label(),
413                                        Box::new(ConfirmCodeAction {
414                                            item_ix: Some(ix),
415                                            from_mouse_context_menu: true,
416                                        }),
417                                    )
418                                })
419                        }
420                    }
421                })
422            });
423        match focus {
424            Some(focus) => menu.context(focus),
425            None => menu,
426        }
427    })
428}
429
430fn set_context_menu(
431    editor: &mut Editor,
432    context_menu: Entity<ui::ContextMenu>,
433    source_anchor: multi_buffer::Anchor,
434    position: Option<Point<Pixels>>,
435    code_action: Option<MouseCodeAction>,
436    window: &mut Window,
437    cx: &mut Context<Editor>,
438) {
439    editor.mouse_context_menu = match position {
440        Some(position) => MouseContextMenu::pinned_to_editor(
441            editor,
442            source_anchor,
443            position,
444            code_action,
445            context_menu,
446            window,
447            cx,
448        ),
449        None => {
450            let character_size = editor.character_size(window);
451            let menu_position = MenuPosition::PinnedToEditor {
452                source: source_anchor,
453                offset: gpui::point(character_size.width, character_size.height),
454            };
455            Some(MouseContextMenu::new(
456                editor,
457                menu_position,
458                context_menu,
459                code_action,
460                window,
461                cx,
462            ))
463        }
464    };
465    cx.notify();
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
472    use indoc::indoc;
473
474    #[gpui::test]
475    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
476        init_test(cx, |_| {});
477
478        let mut cx = EditorLspTestContext::new_rust(
479            lsp::ServerCapabilities {
480                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
481                ..Default::default()
482            },
483            cx,
484        )
485        .await;
486
487        cx.set_state(indoc! {"
488            fn teˇst() {
489                do_work();
490            }
491        "});
492        let point = cx.display_point(indoc! {"
493            fn test() {
494                do_wˇork();
495            }
496        "});
497        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
498        cx.update_editor(|editor, window, cx| {
499            deploy_context_menu(editor, Some(Default::default()), point, window, cx)
500        });
501
502        cx.assert_editor_state(indoc! {"
503            fn test() {
504                do_wˇork();
505            }
506        "});
507        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
508    }
509}