1use crate::{
2 ConfirmCodeAction, Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DebuggerEvaluateSelectedText,
3 DisplayPoint, DisplaySnapshot, Editor, FindAllReferences, GoToDeclaration, GoToDefinition,
4 GoToImplementation, GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode,
5 SelectionExt, ToDisplayPoint, ToggleCodeActions,
6 actions::{Format, FormatSelections},
7 code_context_menus::CodeActionContents,
8 selections_collection::SelectionsCollection,
9};
10use feature_flags::{Debugger, FeatureFlagAppExt as _};
11use gpui::prelude::FluentBuilder;
12use gpui::{
13 Context, DismissEvent, Entity, FocusHandle, Focusable as _, Pixels, Point, Subscription, Task,
14 Window,
15};
16use std::ops::Range;
17use text::PointUtf16;
18use ui::ContextMenu;
19use util::ResultExt;
20use workspace::OpenInTerminal;
21
22#[derive(Debug)]
23pub enum MenuPosition {
24 /// When the editor is scrolled, the context menu stays on the exact
25 /// same position on the screen, never disappearing.
26 PinnedToScreen(Point<Pixels>),
27 /// When the editor is scrolled, the context menu follows the position it is associated with.
28 /// Disappears when the position is no longer visible.
29 PinnedToEditor {
30 source: multi_buffer::Anchor,
31 offset: Point<Pixels>,
32 },
33}
34
35pub struct MouseCodeAction {
36 pub actions: CodeActionContents,
37 pub buffer: Entity<language::Buffer>,
38}
39
40pub struct MouseContextMenu {
41 pub(crate) position: MenuPosition,
42 pub(crate) context_menu: Entity<ui::ContextMenu>,
43 pub(crate) code_action: Option<MouseCodeAction>,
44 _dismiss_subscription: Subscription,
45 _cursor_move_subscription: Subscription,
46}
47
48enum CodeActionLoadState {
49 Loading,
50 Loaded(CodeActionContents),
51}
52
53impl std::fmt::Debug for MouseContextMenu {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("MouseContextMenu")
56 .field("position", &self.position)
57 .field("context_menu", &self.context_menu)
58 .finish()
59 }
60}
61
62impl MouseContextMenu {
63 pub(crate) fn pinned_to_editor(
64 editor: &mut Editor,
65 source: multi_buffer::Anchor,
66 position: Point<Pixels>,
67 code_action: Option<MouseCodeAction>,
68 context_menu: Entity<ui::ContextMenu>,
69 window: &mut Window,
70 cx: &mut Context<Editor>,
71 ) -> Option<Self> {
72 let editor_snapshot = editor.snapshot(window, cx);
73 let content_origin = editor.last_bounds?.origin
74 + Point {
75 x: editor.gutter_dimensions.width,
76 y: Pixels(0.0),
77 };
78 let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?;
79 let menu_position = MenuPosition::PinnedToEditor {
80 source,
81 offset: position - (source_position + content_origin),
82 };
83 return Some(MouseContextMenu::new(
84 editor,
85 menu_position,
86 context_menu,
87 code_action,
88 window,
89 cx,
90 ));
91 }
92
93 pub(crate) fn new(
94 editor: &Editor,
95 position: MenuPosition,
96 context_menu: Entity<ui::ContextMenu>,
97 code_action: Option<MouseCodeAction>,
98 window: &mut Window,
99 cx: &mut Context<Editor>,
100 ) -> Self {
101 let context_menu_focus = context_menu.focus_handle(cx);
102 window.focus(&context_menu_focus);
103
104 let _dismiss_subscription = cx.subscribe_in(&context_menu, window, {
105 let context_menu_focus = context_menu_focus.clone();
106 move |editor, _, _event: &DismissEvent, window, cx| {
107 editor.mouse_context_menu.take();
108 if context_menu_focus.contains_focused(window, cx) {
109 window.focus(&editor.focus_handle(cx));
110 }
111 }
112 });
113
114 let selection_init = editor.selections.newest_anchor().clone();
115
116 let _cursor_move_subscription = cx.subscribe_in(
117 &cx.entity(),
118 window,
119 move |editor, _, event: &crate::EditorEvent, window, cx| {
120 let crate::EditorEvent::SelectionsChanged { local: true } = event else {
121 return;
122 };
123 let display_snapshot = &editor
124 .display_map
125 .update(cx, |display_map, cx| display_map.snapshot(cx));
126 let selection_init_range = selection_init.display_range(&display_snapshot);
127 let selection_now_range = editor
128 .selections
129 .newest_anchor()
130 .display_range(&display_snapshot);
131 if selection_now_range == selection_init_range {
132 return;
133 }
134 editor.mouse_context_menu.take();
135 if context_menu_focus.contains_focused(window, cx) {
136 window.focus(&editor.focus_handle(cx));
137 }
138 },
139 );
140
141 Self {
142 position,
143 context_menu,
144 code_action,
145 _dismiss_subscription,
146 _cursor_move_subscription,
147 }
148 }
149}
150
151fn display_ranges<'a>(
152 display_map: &'a DisplaySnapshot,
153 selections: &'a SelectionsCollection,
154) -> impl Iterator<Item = Range<DisplayPoint>> + 'a {
155 let pending = selections
156 .pending
157 .as_ref()
158 .map(|pending| &pending.selection);
159 selections
160 .disjoint
161 .iter()
162 .chain(pending)
163 .map(move |s| s.start.to_display_point(display_map)..s.end.to_display_point(display_map))
164}
165
166pub fn deploy_context_menu(
167 editor: &mut Editor,
168 position: Option<Point<Pixels>>,
169 point: DisplayPoint,
170 window: &mut Window,
171 cx: &mut Context<Editor>,
172) {
173 if !editor.is_focused(window) {
174 window.focus(&editor.focus_handle(cx));
175 }
176
177 // Don't show context menu for inline editors
178 if !editor.mode().is_full() {
179 return;
180 }
181
182 let display_map = editor.selections.display_map(cx);
183 let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
184 if let Some(custom) = editor.custom_context_menu.take() {
185 let menu = custom(editor, point, window, cx);
186 editor.custom_context_menu = Some(custom);
187 let Some(menu) = menu else {
188 return;
189 };
190 set_context_menu(editor, menu, source_anchor, position, None, window, cx);
191 } else {
192 // Don't show the context menu if there isn't a project associated with this editor
193 let Some(project) = editor.project.clone() else {
194 return;
195 };
196
197 let display_map = editor.selections.display_map(cx);
198 let buffer = &editor.snapshot(window, cx).buffer_snapshot;
199 let anchor = buffer.anchor_before(point.to_point(&display_map));
200 if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) {
201 // Move the cursor to the clicked location so that dispatched actions make sense
202 editor.change_selections(None, window, cx, |s| {
203 s.clear_disjoint();
204 s.set_pending_anchor_range(anchor..anchor, SelectMode::Character);
205 });
206 }
207
208 let focus = window.focused(cx);
209 let has_reveal_target = editor.target_file(cx).is_some();
210 let has_selections = editor
211 .selections
212 .all::<PointUtf16>(cx)
213 .into_iter()
214 .any(|s| !s.is_empty());
215 let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| {
216 project
217 .read(cx)
218 .git_store()
219 .read(cx)
220 .repository_and_path_for_buffer_id(buffer_id, cx)
221 .is_some()
222 });
223
224 let evaluate_selection = command_palette_hooks::CommandPaletteFilter::try_global(cx)
225 .map_or(false, |filter| {
226 !filter.is_hidden(&DebuggerEvaluateSelectedText)
227 });
228
229 let menu = build_context_menu(
230 focus,
231 has_selections,
232 has_reveal_target,
233 has_git_repo,
234 evaluate_selection,
235 Some(CodeActionLoadState::Loading),
236 window,
237 cx,
238 );
239
240 set_context_menu(editor, menu, source_anchor, position, None, window, cx);
241
242 let mut actions_task = editor.code_actions_task.take();
243 cx.spawn_in(window, async move |editor, cx| {
244 while let Some(prev_task) = actions_task {
245 prev_task.await.log_err();
246 actions_task = editor.update(cx, |this, _| this.code_actions_task.take())?;
247 }
248 let action = ToggleCodeActions {
249 deployed_from_indicator: Some(point.row()),
250 };
251 let context_menu_task = editor.update_in(cx, |editor, window, cx| {
252 let code_actions_task = editor.prepare_code_actions_task(&action, window, cx);
253 Some(cx.spawn_in(window, async move |editor, cx| {
254 let code_action_result = code_actions_task.await;
255 if let Ok(editor_task) = editor.update_in(cx, |editor, window, cx| {
256 let Some(mouse_context_menu) = editor.mouse_context_menu.take() else {
257 return Task::ready(Ok::<_, anyhow::Error>(()));
258 };
259 if mouse_context_menu
260 .context_menu
261 .focus_handle(cx)
262 .contains_focused(window, cx)
263 {
264 window.focus(&editor.focus_handle(cx));
265 }
266 drop(mouse_context_menu);
267 let (state, code_action) =
268 if let Some((buffer, actions)) = code_action_result {
269 (
270 CodeActionLoadState::Loaded(actions.clone()),
271 Some(MouseCodeAction { actions, buffer }),
272 )
273 } else {
274 (
275 CodeActionLoadState::Loaded(CodeActionContents::default()),
276 None,
277 )
278 };
279 let menu = build_context_menu(
280 window.focused(cx),
281 has_selections,
282 has_reveal_target,
283 has_git_repo,
284 evaluate_selection,
285 Some(state),
286 window,
287 cx,
288 );
289 set_context_menu(
290 editor,
291 menu,
292 source_anchor,
293 position,
294 code_action,
295 window,
296 cx,
297 );
298 Task::ready(Ok(()))
299 }) {
300 editor_task.await
301 } else {
302 Ok(())
303 }
304 }))
305 })?;
306 if let Some(task) = context_menu_task {
307 task.await?;
308 }
309 Ok::<_, anyhow::Error>(())
310 })
311 .detach_and_log_err(cx);
312 };
313}
314
315fn build_context_menu(
316 focus: Option<FocusHandle>,
317 has_selections: bool,
318 has_reveal_target: bool,
319 has_git_repo: bool,
320 evaluate_selection: bool,
321 code_action_load_state: Option<CodeActionLoadState>,
322 window: &mut Window,
323 cx: &mut Context<Editor>,
324) -> Entity<ContextMenu> {
325 ui::ContextMenu::build(window, cx, |menu, _window, cx| {
326 let menu = menu
327 .on_blur_subscription(Subscription::new(|| {}))
328 .when(evaluate_selection && has_selections, |builder| {
329 builder
330 .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText))
331 .separator()
332 })
333 .action("Go to Definition", Box::new(GoToDefinition))
334 .action("Go to Declaration", Box::new(GoToDeclaration))
335 .action("Go to Type Definition", Box::new(GoToTypeDefinition))
336 .action("Go to Implementation", Box::new(GoToImplementation))
337 .action("Find All References", Box::new(FindAllReferences))
338 .separator()
339 .action("Rename Symbol", Box::new(Rename))
340 .action("Format Buffer", Box::new(Format))
341 .when(has_selections, |cx| {
342 cx.action("Format Selections", Box::new(FormatSelections))
343 })
344 .separator()
345 .action("Cut", Box::new(Cut))
346 .action("Copy", Box::new(Copy))
347 .action("Copy and trim", Box::new(CopyAndTrim))
348 .action("Paste", Box::new(Paste))
349 .separator()
350 .map(|builder| {
351 let reveal_in_finder_label = if cfg!(target_os = "macos") {
352 "Reveal in Finder"
353 } else {
354 "Reveal in File Manager"
355 };
356 const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal";
357 if has_reveal_target {
358 builder
359 .action(reveal_in_finder_label, Box::new(RevealInFileManager))
360 .action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
361 } else {
362 builder
363 .disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager))
364 .disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal))
365 }
366 })
367 .map(|builder| {
368 const COPY_PERMALINK_LABEL: &str = "Copy Permalink";
369 if has_git_repo {
370 builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
371 } else {
372 builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
373 }
374 })
375 .when_some(code_action_load_state, |menu, state| {
376 menu.separator().map(|menu| match state {
377 CodeActionLoadState::Loading => menu.disabled_action(
378 "Loading code actions...",
379 Box::new(ConfirmCodeAction {
380 item_ix: None,
381 from_mouse_context_menu: true,
382 }),
383 ),
384 CodeActionLoadState::Loaded(actions) => {
385 if actions.is_empty() {
386 menu.disabled_action(
387 "No code actions available",
388 Box::new(ConfirmCodeAction {
389 item_ix: None,
390 from_mouse_context_menu: true,
391 }),
392 )
393 } else {
394 actions
395 .iter()
396 .filter(|action| {
397 if action
398 .as_task()
399 .map(|task| {
400 matches!(task.task_type(), task::TaskType::Debug(_))
401 })
402 .unwrap_or(false)
403 {
404 cx.has_flag::<Debugger>()
405 } else {
406 true
407 }
408 })
409 .enumerate()
410 .fold(menu, |menu, (ix, action)| {
411 menu.action(
412 action.label(),
413 Box::new(ConfirmCodeAction {
414 item_ix: Some(ix),
415 from_mouse_context_menu: true,
416 }),
417 )
418 })
419 }
420 }
421 })
422 });
423 match focus {
424 Some(focus) => menu.context(focus),
425 None => menu,
426 }
427 })
428}
429
430fn set_context_menu(
431 editor: &mut Editor,
432 context_menu: Entity<ui::ContextMenu>,
433 source_anchor: multi_buffer::Anchor,
434 position: Option<Point<Pixels>>,
435 code_action: Option<MouseCodeAction>,
436 window: &mut Window,
437 cx: &mut Context<Editor>,
438) {
439 editor.mouse_context_menu = match position {
440 Some(position) => MouseContextMenu::pinned_to_editor(
441 editor,
442 source_anchor,
443 position,
444 code_action,
445 context_menu,
446 window,
447 cx,
448 ),
449 None => {
450 let character_size = editor.character_size(window);
451 let menu_position = MenuPosition::PinnedToEditor {
452 source: source_anchor,
453 offset: gpui::point(character_size.width, character_size.height),
454 };
455 Some(MouseContextMenu::new(
456 editor,
457 menu_position,
458 context_menu,
459 code_action,
460 window,
461 cx,
462 ))
463 }
464 };
465 cx.notify();
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
472 use indoc::indoc;
473
474 #[gpui::test]
475 async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
476 init_test(cx, |_| {});
477
478 let mut cx = EditorLspTestContext::new_rust(
479 lsp::ServerCapabilities {
480 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
481 ..Default::default()
482 },
483 cx,
484 )
485 .await;
486
487 cx.set_state(indoc! {"
488 fn teˇst() {
489 do_work();
490 }
491 "});
492 let point = cx.display_point(indoc! {"
493 fn test() {
494 do_wˇork();
495 }
496 "});
497 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_none()));
498 cx.update_editor(|editor, window, cx| {
499 deploy_context_menu(editor, Some(Default::default()), point, window, cx)
500 });
501
502 cx.assert_editor_state(indoc! {"
503 fn test() {
504 do_wˇork();
505 }
506 "});
507 cx.editor(|editor, _window, _app| assert!(editor.mouse_context_menu.is_some()));
508 }
509}