Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:53.315Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

mouse_context_menu.rs

453 lines · 16.9 KB · rust
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 =
209            buffer
210                .anchor_to_buffer_anchor(anchor)
211                .is_some_and(|(buffer_anchor, _)| {
212                    project
213                        .read(cx)
214                        .git_store()
215                        .read(cx)
216                        .repository_and_path_for_buffer_id(buffer_anchor.buffer_id, cx)
217                        .is_some()
218                });
219
220        let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
221        let run_to_cursor = window.is_action_available(&RunToCursor, cx);
222        let format_selections = window.is_action_available(&FormatSelections, cx);
223        let disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
224            editor.buffer.read(cx).as_singleton().as_ref(),
225            cx,
226        );
227
228        let is_markdown = editor
229            .buffer()
230            .read(cx)
231            .as_singleton()
232            .and_then(|buffer| buffer.read(cx).language())
233            .is_some_and(|language| language.name().as_ref() == "Markdown");
234
235        let is_svg = editor
236            .buffer()
237            .read(cx)
238            .as_singleton()
239            .and_then(|buffer| buffer.read(cx).file())
240            .is_some_and(|file| {
241                std::path::Path::new(file.file_name(cx))
242                    .extension()
243                    .is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
244            });
245
246        ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
247            let builder = menu
248                .on_blur_subscription(Subscription::new(|| {}))
249                .when(run_to_cursor, |builder| {
250                    builder.action("Run to Cursor", Box::new(RunToCursor))
251                })
252                .when(evaluate_selection && has_selections, |builder| {
253                    builder.action("Evaluate Selection", Box::new(EvaluateSelectedText))
254                })
255                .when(
256                    run_to_cursor || (evaluate_selection && has_selections),
257                    |builder| builder.separator(),
258                )
259                .action("Go to Definition", Box::new(GoToDefinition::default()))
260                .action("Go to Declaration", Box::new(GoToDeclaration))
261                .action("Go to Type Definition", Box::new(GoToTypeDefinition))
262                .action(
263                    "Go to Implementation",
264                    Box::new(GoToImplementation::default()),
265                )
266                .action(
267                    "Find All References",
268                    Box::new(FindAllReferences::default()),
269                )
270                .separator()
271                .action("Rename Symbol", Box::new(Rename))
272                .action("Format Buffer", Box::new(Format))
273                .when(format_selections, |cx| {
274                    cx.action("Format Selections", Box::new(FormatSelections))
275                })
276                .action(
277                    "Show Code Actions",
278                    Box::new(ToggleCodeActions {
279                        deployed_from: None,
280                        quick_launch: false,
281                    }),
282                )
283                .when(!disable_ai && has_selections, |this| {
284                    this.action("Add to Agent Thread", Box::new(AddSelectionToThread))
285                })
286                .separator()
287                .action("Cut", Box::new(Cut))
288                .action("Copy", Box::new(Copy))
289                .action("Copy and Trim", Box::new(CopyAndTrim))
290                .action("Paste", Box::new(Paste))
291                .separator()
292                .action_disabled_when(
293                    !has_reveal_target,
294                    ui::utils::reveal_in_file_manager_label(false),
295                    Box::new(RevealInFileManager),
296                )
297                .when(is_markdown, |builder| {
298                    builder.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
299                })
300                .when(is_svg, |builder| {
301                    builder.action("Open SVG Preview", Box::new(OpenSvgPreview))
302                })
303                .action_disabled_when(
304                    !has_reveal_target,
305                    "Open in Terminal",
306                    Box::new(OpenInTerminal),
307                )
308                .action_disabled_when(
309                    !has_git_repo,
310                    "Copy Permalink",
311                    Box::new(CopyPermalinkToLine),
312                )
313                .action_disabled_when(
314                    !has_git_repo,
315                    "View File History",
316                    Box::new(git::FileHistory),
317                );
318            match focus {
319                Some(focus) => builder.context(focus),
320                None => builder,
321            }
322        })
323    };
324
325    editor.mouse_context_menu = match position {
326        Some(position) => MouseContextMenu::pinned_to_editor(
327            editor,
328            source_anchor,
329            position,
330            context_menu,
331            window,
332            cx,
333        ),
334        None => {
335            let character_size = editor.character_dimensions(window, cx);
336            let menu_position = MenuPosition::PinnedToEditor {
337                source: source_anchor,
338                offset: gpui::point(character_size.em_width, character_size.line_height),
339            };
340            Some(MouseContextMenu::new(
341                editor,
342                menu_position,
343                context_menu,
344                window,
345                cx,
346            ))
347        }
348    };
349    cx.notify();
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::{
356        editor_tests::init_test,
357        test::{
358            editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext,
359        },
360    };
361    use indoc::indoc;
362
363    #[gpui::test]
364    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
365        init_test(cx, |_| {});
366
367        let mut cx = EditorLspTestContext::new_rust(
368            lsp::ServerCapabilities {
369                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
370                ..Default::default()
371            },
372            cx,
373        )
374        .await;
375
376        cx.set_state(indoc! {"
377            fn teˇst() {
378                do_work();
379            }
380        "});
381        let point = cx.display_point(indoc! {"
382            fn test() {
383                do_wˇork();
384            }
385        "});
386        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
387
388        cx.update_editor(|editor, window, cx| {
389            deploy_context_menu(editor, Some(Default::default()), point, window, cx);
390
391            // Assert that, even after deploying the editor's mouse context
392            // menu, the editor's focus handle still contains the focused
393            // element. The pane's tab bar relies on this to determine whether
394            // to show the tab bar buttons and there was a small flicker when
395            // deploying the mouse context menu that would cause this to not be
396            // true, making it so that the buttons would disappear for a couple
397            // of frames.
398            assert!(editor.focus_handle.contains_focused(window, cx));
399        });
400
401        cx.assert_editor_state(indoc! {"
402            fn test() {
403                do_wˇork();
404            }
405        "});
406        cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
407    }
408
409    #[gpui::test]
410    async fn test_mouse_context_menu_at_pixel_snapped_scroll_position(
411        cx: &mut gpui::TestAppContext,
412    ) {
413        init_test(cx, |_| {});
414
415        let mut cx = EditorTestContext::new(cx).await;
416        cx.set_state(&format!("ˇ{}", "aaaaa\n".repeat(100)));
417        cx.update(|window, _| window.set_scale_factor(1.25));
418        cx.update_editor(|editor, _, cx| {
419            editor.set_text_style_refinement(gpui::TextStyleRefinement {
420                font_size: Some(gpui::px(14.).into()),
421                line_height: Some(gpui::relative(1.3)),
422                ..Default::default()
423            });
424            cx.notify();
425        });
426        cx.run_until_parked();
427
428        cx.update_editor(|editor, window, cx| {
429            assert_eq!(window.scale_factor(), 1.25);
430            let line_height = editor
431                .style(cx)
432                .text
433                .line_height_in_pixels(window.rem_size());
434            assert_eq!(line_height, gpui::px(18.));
435            assert!(window.pixel_snap_f64(f64::from(line_height)) / f64::from(line_height) < 1.);
436            editor.set_scroll_position(gpui::point(0., 1.), window, cx);
437            assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.);
438
439            deploy_context_menu(
440                editor,
441                Some(gpui::point(gpui::px(200.), gpui::px(200.))),
442                DisplayPoint::new(crate::display_map::DisplayRow(5), 0),
443                window,
444                cx,
445            );
446            assert!(editor.mouse_context_menu.is_some());
447        });
448        cx.run_until_parked();
449
450        assert!(cx.debug_bounds("MENU_ITEM-Copy").is_some());
451    }
452}
453
Served at tenant.openagents/omega Member data and write actions are omitted.