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