Skip to repository content453 lines · 16.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:31:53.315Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
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 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