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_some(code_action_load_state, |menu, state| {
299                match state {
300                    CodeActionLoadState::Loading => menu.disabled_action(
301                        "Loading code actions...",
302                        Box::new(ConfirmCodeAction {
303                            item_ix: None,
304                            from_mouse_context_menu: true,
305                        }),
306                    ),
307                    CodeActionLoadState::Loaded(actions) => {
308                        if actions.is_empty() {
309                            menu.disabled_action(
310                                "No code actions available",
311                                Box::new(ConfirmCodeAction {
312                                    item_ix: None,
313                                    from_mouse_context_menu: true,
314                                }),
315                            )
316                        } else {
317                            actions
318                                .iter()
319                                .filter(|action| {
320                                    if action
321                                        .as_task()
322                                        .map(|task| {
323                                            matches!(task.task_type(), task::TaskType::Debug(_))
324                                        })
325                                        .unwrap_or(false)
326                                    {
327                                        cx.has_flag::<Debugger>()
328                                    } else {
329                                        true
330                                    }
331                                })
332                                .enumerate()
333                                .fold(menu, |menu, (ix, action)| {
334                                    menu.action(
335                                        action.label(),
336                                        Box::new(ConfirmCodeAction {
337                                            item_ix: Some(ix),
338                                            from_mouse_context_menu: true,
339                                        }),
340                                    )
341                                })
342                        }
343                    }
344                }
345                .separator()
346            })
347            .when(evaluate_selection && has_selections, |builder| {
348                builder
349                    .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText))
350                    .separator()
351            })
352            .action("Go to Definition", Box::new(GoToDefinition))
353            .action("Go to Declaration", Box::new(GoToDeclaration))
354            .action("Go to Type Definition", Box::new(GoToTypeDefinition))
355            .action("Go to Implementation", Box::new(GoToImplementation))
356            .action("Find All References", Box::new(FindAllReferences))
357            .separator()
358            .action("Rename Symbol", Box::new(Rename))
359            .action("Format Buffer", Box::new(Format))
360            .when(has_selections, |cx| {
361                cx.action("Format Selections", Box::new(FormatSelections))
362            })
363            .separator()
364            .action("Cut", Box::new(Cut))
365            .action("Copy", Box::new(Copy))
366            .action("Copy and trim", Box::new(CopyAndTrim))
367            .action("Paste", Box::new(Paste))
368            .separator()
369            .map(|builder| {
370                let reveal_in_finder_label = if cfg!(target_os = "macos") {
371                    "Reveal in Finder"
372                } else {
373                    "Reveal in File Manager"
374                };
375                const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal";
376                if has_reveal_target {
377                    builder
378                        .action(reveal_in_finder_label, Box::new(RevealInFileManager))
379                        .action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
380                } else {
381                    builder
382                        .disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager))
383                        .disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
384                }
385            })
386            .map(|builder| {
387                const COPY_PERMALINK_LABEL: &str = "Copy Permalink";
388                if has_git_repo {
389                    builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
390                } else {
391                    builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
392                }
393            });
394        match focus {
395            Some(focus) => menu.context(focus),
396            None => menu,
397        }
398    })
399}
400
401fn set_context_menu(
402    editor: &mut Editor,
403    context_menu: Entity<ui::ContextMenu>,
404    source_anchor: multi_buffer::Anchor,
405    position: Option<Point<Pixels>>,
406    code_action: Option<MouseCodeAction>,
407    window: &mut Window,
408    cx: &mut Context<Editor>,
409) {
410    editor.mouse_context_menu = match position {
411        Some(position) => MouseContextMenu::pinned_to_editor(
412            editor,
413            source_anchor,
414            position,
415            code_action,
416            context_menu,
417            window,
418            cx,
419        ),
420        None => {
421            let character_size = editor.character_size(window);
422            let menu_position = MenuPosition::PinnedToEditor {
423                source: source_anchor,
424                offset: gpui::point(character_size.width, character_size.height),
425            };
426            Some(MouseContextMenu::new(
427                menu_position,
428                context_menu,
429                code_action,
430                window,
431                cx,
432            ))
433        }
434    };
435    cx.notify();
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
442    use indoc::indoc;
443
444    #[gpui::test]
445    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
446        init_test(cx, |_| {});
447
448        let mut cx = EditorLspTestContext::new_rust(
449            lsp::ServerCapabilities {
450                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
451                ..Default::default()
452            },
453            cx,
454        )
455        .await;
456
457        cx.set_state(indoc! {"
458            fn teˇst() {
459                do_work();
460            }
461        "});
462        let point = cx.display_point(indoc! {"
463            fn test() {
464                do_wˇork();
465            }
466        "});
467        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
468        cx.update_editor(|editor, window, cx| {
469            deploy_context_menu(editor, Some(Default::default()), point, window, cx)
470        });
471
472        cx.assert_editor_state(indoc! {"
473            fn test() {
474                do_wˇork();
475            }
476        "});
477        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
478    }
479}