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 = buffer
209 .buffer_id_for_anchor(anchor)
210 .is_some_and(|buffer_id| {
211 project
212 .read(cx)
213 .git_store()
214 .read(cx)
215 .repository_and_path_for_buffer_id(buffer_id, cx)
216 .is_some()
217 });
218
219 let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
220 let run_to_cursor = window.is_action_available(&RunToCursor, cx);
221 let disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
222 editor.buffer.read(cx).as_singleton().as_ref(),
223 cx,
224 );
225
226 let is_markdown = editor
227 .buffer()
228 .read(cx)
229 .as_singleton()
230 .and_then(|buffer| buffer.read(cx).language())
231 .is_some_and(|language| language.name().as_ref() == "Markdown");
232
233 let is_svg = editor
234 .buffer()
235 .read(cx)
236 .as_singleton()
237 .and_then(|buffer| buffer.read(cx).file())
238 .is_some_and(|file| {
239 std::path::Path::new(file.file_name(cx))
240 .extension()
241 .is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
242 });
243
244 ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
245 let builder = menu
246 .on_blur_subscription(Subscription::new(|| {}))
247 .when(run_to_cursor, |builder| {
248 builder.action("Run to Cursor", Box::new(RunToCursor))
249 })
250 .when(evaluate_selection && has_selections, |builder| {
251 builder.action("Evaluate Selection", Box::new(EvaluateSelectedText))
252 })
253 .when(
254 run_to_cursor || (evaluate_selection && has_selections),
255 |builder| builder.separator(),
256 )
257 .action("Go to Definition", Box::new(GoToDefinition))
258 .action("Go to Declaration", Box::new(GoToDeclaration))
259 .action("Go to Type Definition", Box::new(GoToTypeDefinition))
260 .action("Go to Implementation", Box::new(GoToImplementation))
261 .action(
262 "Find All References",
263 Box::new(FindAllReferences::default()),
264 )
265 .separator()
266 .action("Rename Symbol", Box::new(Rename))
267 .action("Format Buffer", Box::new(Format))
268 .when(has_selections, |cx| {
269 cx.action("Format Selections", Box::new(FormatSelections))
270 })
271 .action(
272 "Show Code Actions",
273 Box::new(ToggleCodeActions {
274 deployed_from: None,
275 quick_launch: false,
276 }),
277 )
278 .when(!disable_ai && has_selections, |this| {
279 this.action("Add to Agent Thread", Box::new(AddSelectionToThread))
280 })
281 .separator()
282 .action("Cut", Box::new(Cut))
283 .action("Copy", Box::new(Copy))
284 .action("Copy and Trim", Box::new(CopyAndTrim))
285 .action("Paste", Box::new(Paste))
286 .separator()
287 .action_disabled_when(
288 !has_reveal_target,
289 if cfg!(target_os = "macos") {
290 "Reveal in Finder"
291 } else if cfg!(target_os = "windows") {
292 "Reveal in File Explorer"
293 } else {
294 "Reveal in File Manager"
295 },
296 Box::new(RevealInFileManager),
297 )
298 .when(is_markdown, |builder| {
299 builder.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
300 })
301 .when(is_svg, |builder| {
302 builder.action("Open SVG Preview", Box::new(OpenSvgPreview))
303 })
304 .action_disabled_when(
305 !has_reveal_target,
306 "Open in Terminal",
307 Box::new(OpenInTerminal),
308 )
309 .action_disabled_when(
310 !has_git_repo,
311 "Copy Permalink",
312 Box::new(CopyPermalinkToLine),
313 )
314 .action_disabled_when(
315 !has_git_repo,
316 "View File History",
317 Box::new(git::FileHistory),
318 );
319 match focus {
320 Some(focus) => builder.context(focus),
321 None => builder,
322 }
323 })
324 };
325
326 editor.mouse_context_menu = match position {
327 Some(position) => MouseContextMenu::pinned_to_editor(
328 editor,
329 source_anchor,
330 position,
331 context_menu,
332 window,
333 cx,
334 ),
335 None => {
336 let character_size = editor.character_dimensions(window, cx);
337 let menu_position = MenuPosition::PinnedToEditor {
338 source: source_anchor,
339 offset: gpui::point(character_size.em_width, character_size.line_height),
340 };
341 Some(MouseContextMenu::new(
342 editor,
343 menu_position,
344 context_menu,
345 window,
346 cx,
347 ))
348 }
349 };
350 cx.notify();
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
357 use indoc::indoc;
358
359 #[gpui::test]
360 async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
361 init_test(cx, |_| {});
362
363 let mut cx = EditorLspTestContext::new_rust(
364 lsp::ServerCapabilities {
365 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
366 ..Default::default()
367 },
368 cx,
369 )
370 .await;
371
372 cx.set_state(indoc! {"
373 fn teˇst() {
374 do_work();
375 }
376 "});
377 let point = cx.display_point(indoc! {"
378 fn test() {
379 do_wˇork();
380 }
381 "});
382 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
383
384 cx.update_editor(|editor, window, cx| {
385 deploy_context_menu(editor, Some(Default::default()), point, window, cx);
386
387 // Assert that, even after deploying the editor's mouse context
388 // menu, the editor's focus handle still contains the focused
389 // element. The pane's tab bar relies on this to determine whether
390 // to show the tab bar buttons and there was a small flicker when
391 // deploying the mouse context menu that would cause this to not be
392 // true, making it so that the buttons would disappear for a couple
393 // of frames.
394 assert!(editor.focus_handle.contains_focused(window, cx));
395 });
396
397 cx.assert_editor_state(indoc! {"
398 fn test() {
399 do_wˇork();
400 }
401 "});
402 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
403 }
404}