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))
260 .action("Go to Declaration", Box::new(GoToDeclaration))
261 .action("Go to Type Definition", Box::new(GoToTypeDefinition))
262 .action("Go to Implementation", Box::new(GoToImplementation))
263 .action(
264 "Find All References",
265 Box::new(FindAllReferences::default()),
266 )
267 .separator()
268 .action("Rename Symbol", Box::new(Rename))
269 .action("Format Buffer", Box::new(Format))
270 .when(format_selections, |cx| {
271 cx.action("Format Selections", Box::new(FormatSelections))
272 })
273 .action(
274 "Show Code Actions",
275 Box::new(ToggleCodeActions {
276 deployed_from: None,
277 quick_launch: false,
278 }),
279 )
280 .when(!disable_ai && has_selections, |this| {
281 this.action("Add to Agent Thread", Box::new(AddSelectionToThread))
282 })
283 .separator()
284 .action("Cut", Box::new(Cut))
285 .action("Copy", Box::new(Copy))
286 .action("Copy and Trim", Box::new(CopyAndTrim))
287 .action("Paste", Box::new(Paste))
288 .separator()
289 .action_disabled_when(
290 !has_reveal_target,
291 ui::utils::reveal_in_file_manager_label(false),
292 Box::new(RevealInFileManager),
293 )
294 .when(is_markdown, |builder| {
295 builder.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
296 })
297 .when(is_svg, |builder| {
298 builder.action("Open SVG Preview", Box::new(OpenSvgPreview))
299 })
300 .action_disabled_when(
301 !has_reveal_target,
302 "Open in Terminal",
303 Box::new(OpenInTerminal),
304 )
305 .action_disabled_when(
306 !has_git_repo,
307 "Copy Permalink",
308 Box::new(CopyPermalinkToLine),
309 )
310 .action_disabled_when(
311 !has_git_repo,
312 "View File History",
313 Box::new(git::FileHistory),
314 );
315 match focus {
316 Some(focus) => builder.context(focus),
317 None => builder,
318 }
319 })
320 };
321
322 editor.mouse_context_menu = match position {
323 Some(position) => MouseContextMenu::pinned_to_editor(
324 editor,
325 source_anchor,
326 position,
327 context_menu,
328 window,
329 cx,
330 ),
331 None => {
332 let character_size = editor.character_dimensions(window, cx);
333 let menu_position = MenuPosition::PinnedToEditor {
334 source: source_anchor,
335 offset: gpui::point(character_size.em_width, character_size.line_height),
336 };
337 Some(MouseContextMenu::new(
338 editor,
339 menu_position,
340 context_menu,
341 window,
342 cx,
343 ))
344 }
345 };
346 cx.notify();
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
353 use indoc::indoc;
354
355 #[gpui::test]
356 async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
357 init_test(cx, |_| {});
358
359 let mut cx = EditorLspTestContext::new_rust(
360 lsp::ServerCapabilities {
361 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
362 ..Default::default()
363 },
364 cx,
365 )
366 .await;
367
368 cx.set_state(indoc! {"
369 fn teˇst() {
370 do_work();
371 }
372 "});
373 let point = cx.display_point(indoc! {"
374 fn test() {
375 do_wˇork();
376 }
377 "});
378 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
379
380 cx.update_editor(|editor, window, cx| {
381 deploy_context_menu(editor, Some(Default::default()), point, window, cx);
382
383 // Assert that, even after deploying the editor's mouse context
384 // menu, the editor's focus handle still contains the focused
385 // element. The pane's tab bar relies on this to determine whether
386 // to show the tab bar buttons and there was a small flicker when
387 // deploying the mouse context menu that would cause this to not be
388 // true, making it so that the buttons would disappear for a couple
389 // of frames.
390 assert!(editor.focus_handle.contains_focused(window, cx));
391 });
392
393 cx.assert_editor_state(indoc! {"
394 fn test() {
395 do_wˇork();
396 }
397 "});
398 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
399 }
400}