editor: Add right click context menu to buffer headers (#36398)

Lukas Wirth created

This adds a context menu to buffer headers mimicking that of pane tabs,
notably being able to copy the relative and absolute paths of the buffer
as well as opening a terminal in the parent.

Confusingly prior to this right clicking a buffer header used to open
the context menu of the underlying editor.

Release Notes:

- Added context menu for buffer titles

Change summary

crates/editor/src/element.rs | 416 +++++++++++++++++++++++--------------
1 file changed, 259 insertions(+), 157 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -40,14 +40,15 @@ use git::{
 };
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
-    Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
-    Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
-    HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length,
-    ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent,
-    MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent,
-    ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun,
-    TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop,
-    linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black,
+    Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
+    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
+    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
+    Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle,
+    ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
+    TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
+    linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
+    transparent_black,
 };
 use itertools::Itertools;
 use language::language_settings::{
@@ -60,7 +61,7 @@ use multi_buffer::{
 };
 
 use project::{
-    ProjectPath,
+    Entry, ProjectPath,
     debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
     project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings},
 };
@@ -80,11 +81,17 @@ use std::{
 use sum_tree::Bias;
 use text::{BufferId, SelectionGoal};
 use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
-use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
+use ui::{
+    ButtonLike, ContextMenu, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*,
+    right_click_menu,
+};
 use unicode_segmentation::UnicodeSegmentation;
 use util::post_inc;
 use util::{RangeExt, ResultExt, debug_panic};
-use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
+use workspace::{
+    CollaboratorId, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::Item,
+    notifications::NotifyTaskExt,
+};
 
 /// Determines what kinds of highlights should be applied to a lines background.
 #[derive(Clone, Copy, Default)]
@@ -3556,7 +3563,7 @@ impl EditorElement {
         jump_data: JumpData,
         window: &mut Window,
         cx: &mut App,
-    ) -> Div {
+    ) -> impl IntoElement {
         let editor = self.editor.read(cx);
         let file_status = editor
             .buffer
@@ -3577,126 +3584,125 @@ impl EditorElement {
             .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
             .unwrap_or_default();
         let can_open_excerpts = Editor::can_open_excerpts_in_file(for_excerpt.buffer.file());
-        let path = for_excerpt.buffer.resolve_file_path(cx, include_root);
-        let filename = path
+        let relative_path = for_excerpt.buffer.resolve_file_path(cx, include_root);
+        let filename = relative_path
             .as_ref()
             .and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
-        let parent_path = path.as_ref().and_then(|path| {
+        let parent_path = relative_path.as_ref().and_then(|path| {
             Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
         });
         let focus_handle = editor.focus_handle(cx);
         let colors = cx.theme().colors();
 
-        div()
-            .p_1()
-            .w_full()
-            .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
-            .child(
-                h_flex()
-                    .size_full()
-                    .gap_2()
-                    .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
-                    .pl_0p5()
-                    .pr_5()
-                    .rounded_sm()
-                    .when(is_sticky, |el| el.shadow_md())
-                    .border_1()
-                    .map(|div| {
-                        let border_color = if is_selected
-                            && is_folded
-                            && focus_handle.contains_focused(window, cx)
-                        {
-                            colors.border_focused
-                        } else {
-                            colors.border
-                        };
-                        div.border_color(border_color)
-                    })
-                    .bg(colors.editor_subheader_background)
-                    .hover(|style| style.bg(colors.element_hover))
-                    .map(|header| {
-                        let editor = self.editor.clone();
-                        let buffer_id = for_excerpt.buffer_id;
-                        let toggle_chevron_icon =
-                            FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
-                        header.child(
-                            div()
-                                .hover(|style| style.bg(colors.element_selected))
-                                .rounded_xs()
-                                .child(
-                                    ButtonLike::new("toggle-buffer-fold")
-                                        .style(ui::ButtonStyle::Transparent)
-                                        .height(px(28.).into())
-                                        .width(px(28.))
-                                        .children(toggle_chevron_icon)
-                                        .tooltip({
-                                            let focus_handle = focus_handle.clone();
-                                            move |window, cx| {
-                                                Tooltip::with_meta_in(
-                                                    "Toggle Excerpt Fold",
-                                                    Some(&ToggleFold),
-                                                    "Alt+click to toggle all",
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                            }
-                                        })
-                                        .on_click(move |event, window, cx| {
-                                            if event.modifiers().alt {
-                                                // Alt+click toggles all buffers
-                                                editor.update(cx, |editor, cx| {
-                                                    editor.toggle_fold_all(
-                                                        &ToggleFoldAll,
+        let header =
+            div()
+                .p_1()
+                .w_full()
+                .h(FILE_HEADER_HEIGHT as f32 * window.line_height())
+                .child(
+                    h_flex()
+                        .size_full()
+                        .gap_2()
+                        .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
+                        .pl_0p5()
+                        .pr_5()
+                        .rounded_sm()
+                        .when(is_sticky, |el| el.shadow_md())
+                        .border_1()
+                        .map(|div| {
+                            let border_color = if is_selected
+                                && is_folded
+                                && focus_handle.contains_focused(window, cx)
+                            {
+                                colors.border_focused
+                            } else {
+                                colors.border
+                            };
+                            div.border_color(border_color)
+                        })
+                        .bg(colors.editor_subheader_background)
+                        .hover(|style| style.bg(colors.element_hover))
+                        .map(|header| {
+                            let editor = self.editor.clone();
+                            let buffer_id = for_excerpt.buffer_id;
+                            let toggle_chevron_icon =
+                                FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
+                            header.child(
+                                div()
+                                    .hover(|style| style.bg(colors.element_selected))
+                                    .rounded_xs()
+                                    .child(
+                                        ButtonLike::new("toggle-buffer-fold")
+                                            .style(ui::ButtonStyle::Transparent)
+                                            .height(px(28.).into())
+                                            .width(px(28.))
+                                            .children(toggle_chevron_icon)
+                                            .tooltip({
+                                                let focus_handle = focus_handle.clone();
+                                                move |window, cx| {
+                                                    Tooltip::with_meta_in(
+                                                        "Toggle Excerpt Fold",
+                                                        Some(&ToggleFold),
+                                                        "Alt+click to toggle all",
+                                                        &focus_handle,
                                                         window,
                                                         cx,
-                                                    );
-                                                });
-                                            } else {
-                                                // Regular click toggles single buffer
-                                                if is_folded {
+                                                    )
+                                                }
+                                            })
+                                            .on_click(move |event, window, cx| {
+                                                if event.modifiers().alt {
+                                                    // Alt+click toggles all buffers
                                                     editor.update(cx, |editor, cx| {
-                                                        editor.unfold_buffer(buffer_id, cx);
+                                                        editor.toggle_fold_all(
+                                                            &ToggleFoldAll,
+                                                            window,
+                                                            cx,
+                                                        );
                                                     });
                                                 } else {
-                                                    editor.update(cx, |editor, cx| {
-                                                        editor.fold_buffer(buffer_id, cx);
-                                                    });
+                                                    // Regular click toggles single buffer
+                                                    if is_folded {
+                                                        editor.update(cx, |editor, cx| {
+                                                            editor.unfold_buffer(buffer_id, cx);
+                                                        });
+                                                    } else {
+                                                        editor.update(cx, |editor, cx| {
+                                                            editor.fold_buffer(buffer_id, cx);
+                                                        });
+                                                    }
                                                 }
-                                            }
-                                        }),
-                                ),
+                                            }),
+                                    ),
+                            )
+                        })
+                        .children(
+                            editor
+                                .addons
+                                .values()
+                                .filter_map(|addon| {
+                                    addon.render_buffer_header_controls(for_excerpt, window, cx)
+                                })
+                                .take(1),
                         )
-                    })
-                    .children(
-                        editor
-                            .addons
-                            .values()
-                            .filter_map(|addon| {
-                                addon.render_buffer_header_controls(for_excerpt, window, cx)
-                            })
-                            .take(1),
-                    )
-                    .child(
-                        h_flex()
-                            .cursor_pointer()
-                            .id("path header block")
-                            .size_full()
-                            .justify_between()
-                            .overflow_hidden()
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(
-                                        Label::new(
-                                            filename
-                                                .map(SharedString::from)
-                                                .unwrap_or_else(|| "untitled".into()),
-                                        )
-                                        .single_line()
-                                        .when_some(
-                                            file_status,
-                                            |el, status| {
+                        .child(
+                            h_flex()
+                                .cursor_pointer()
+                                .id("path header block")
+                                .size_full()
+                                .justify_between()
+                                .overflow_hidden()
+                                .child(
+                                    h_flex()
+                                        .gap_2()
+                                        .child(
+                                            Label::new(
+                                                filename
+                                                    .map(SharedString::from)
+                                                    .unwrap_or_else(|| "untitled".into()),
+                                            )
+                                            .single_line()
+                                            .when_some(file_status, |el, status| {
                                                 el.color(if status.is_conflicted() {
                                                     Color::Conflict
                                                 } else if status.is_modified() {
@@ -3707,49 +3713,145 @@ impl EditorElement {
                                                     Color::Created
                                                 })
                                                 .when(status.is_deleted(), |el| el.strikethrough())
-                                            },
-                                        ),
-                                    )
-                                    .when_some(parent_path, |then, path| {
-                                        then.child(div().child(path).text_color(
-                                            if file_status.is_some_and(FileStatus::is_deleted) {
-                                                colors.text_disabled
-                                            } else {
-                                                colors.text_muted
-                                            },
-                                        ))
+                                            }),
+                                        )
+                                        .when_some(parent_path, |then, path| {
+                                            then.child(div().child(path).text_color(
+                                                if file_status.is_some_and(FileStatus::is_deleted) {
+                                                    colors.text_disabled
+                                                } else {
+                                                    colors.text_muted
+                                                },
+                                            ))
+                                        }),
+                                )
+                                .when(
+                                    can_open_excerpts && is_selected && relative_path.is_some(),
+                                    |el| {
+                                        el.child(
+                                            h_flex()
+                                                .id("jump-to-file-button")
+                                                .gap_2p5()
+                                                .child(Label::new("Jump To File"))
+                                                .children(
+                                                    KeyBinding::for_action_in(
+                                                        &OpenExcerpts,
+                                                        &focus_handle,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                    .map(|binding| binding.into_any_element()),
+                                                ),
+                                        )
+                                    },
+                                )
+                                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+                                .on_click(window.listener_for(&self.editor, {
+                                    move |editor, e: &ClickEvent, window, cx| {
+                                        editor.open_excerpts_common(
+                                            Some(jump_data.clone()),
+                                            e.modifiers().secondary(),
+                                            window,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                );
+
+        let file = for_excerpt.buffer.file().cloned();
+        let editor = self.editor.clone();
+        right_click_menu("buffer-header-context-menu")
+            .trigger(move |_, _, _| header)
+            .menu(move |window, cx| {
+                let menu_context = focus_handle.clone();
+                let editor = editor.clone();
+                let file = file.clone();
+                ContextMenu::build(window, cx, move |mut menu, window, cx| {
+                    if let Some(file) = file
+                        && let Some(project) = editor.read(cx).project()
+                        && let Some(worktree) =
+                            project.read(cx).worktree_for_id(file.worktree_id(cx), cx)
+                    {
+                        let relative_path = file.path();
+                        let entry_for_path = worktree.read(cx).entry_for_path(relative_path);
+                        let abs_path = entry_for_path.and_then(|e| e.canonical_path.as_deref());
+                        let has_relative_path =
+                            worktree.read(cx).root_entry().is_some_and(Entry::is_dir);
+
+                        let parent_abs_path =
+                            abs_path.and_then(|abs_path| Some(abs_path.parent()?.to_path_buf()));
+                        let relative_path = has_relative_path
+                            .then_some(relative_path)
+                            .map(ToOwned::to_owned);
+
+                        let visible_in_project_panel =
+                            relative_path.is_some() && worktree.read(cx).is_visible();
+                        let reveal_in_project_panel = entry_for_path
+                            .filter(|_| visible_in_project_panel)
+                            .map(|entry| entry.id);
+                        menu = menu
+                            .when_some(abs_path.map(ToOwned::to_owned), |menu, abs_path| {
+                                menu.entry(
+                                    "Copy Path",
+                                    Some(Box::new(zed_actions::workspace::CopyPath)),
+                                    window.handler_for(&editor, move |_, _, cx| {
+                                        cx.write_to_clipboard(ClipboardItem::new_string(
+                                            abs_path.to_string_lossy().to_string(),
+                                        ));
                                     }),
+                                )
+                            })
+                            .when_some(relative_path, |menu, relative_path| {
+                                menu.entry(
+                                    "Copy Relative Path",
+                                    Some(Box::new(zed_actions::workspace::CopyRelativePath)),
+                                    window.handler_for(&editor, move |_, _, cx| {
+                                        cx.write_to_clipboard(ClipboardItem::new_string(
+                                            relative_path.to_string_lossy().to_string(),
+                                        ));
+                                    }),
+                                )
+                            })
+                            .when(
+                                reveal_in_project_panel.is_some() || parent_abs_path.is_some(),
+                                |menu| menu.separator(),
                             )
-                            .when(can_open_excerpts && is_selected && path.is_some(), |el| {
-                                el.child(
-                                    h_flex()
-                                        .id("jump-to-file-button")
-                                        .gap_2p5()
-                                        .child(Label::new("Jump To File"))
-                                        .children(
-                                            KeyBinding::for_action_in(
-                                                &OpenExcerpts,
-                                                &focus_handle,
-                                                window,
-                                                cx,
-                                            )
-                                            .map(|binding| binding.into_any_element()),
-                                        ),
+                            .when_some(reveal_in_project_panel, |menu, entry_id| {
+                                menu.entry(
+                                    "Reveal In Project Panel",
+                                    Some(Box::new(RevealInProjectPanel::default())),
+                                    window.handler_for(&editor, move |editor, _, cx| {
+                                        if let Some(project) = &mut editor.project {
+                                            project.update(cx, |_, cx| {
+                                                cx.emit(project::Event::RevealInProjectPanel(
+                                                    entry_id,
+                                                ))
+                                            });
+                                        }
+                                    }),
                                 )
                             })
-                            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
-                            .on_click(window.listener_for(&self.editor, {
-                                move |editor, e: &ClickEvent, window, cx| {
-                                    editor.open_excerpts_common(
-                                        Some(jump_data.clone()),
-                                        e.modifiers().secondary(),
-                                        window,
-                                        cx,
-                                    );
-                                }
-                            })),
-                    ),
-            )
+                            .when_some(parent_abs_path, |menu, parent_abs_path| {
+                                menu.entry(
+                                    "Open in Terminal",
+                                    Some(Box::new(OpenInTerminal)),
+                                    window.handler_for(&editor, move |_, window, cx| {
+                                        window.dispatch_action(
+                                            OpenTerminal {
+                                                working_directory: parent_abs_path.clone(),
+                                            }
+                                            .boxed_clone(),
+                                            cx,
+                                        );
+                                    }),
+                                )
+                            });
+                    }
+
+                    menu.context(menu_context)
+                })
+            })
     }
 
     fn render_blocks(