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