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