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

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
This commit is contained in:
Lukas Wirth 2025-08-18 12:40:39 +02:00 committed by GitHub
parent 5591fc810e
commit 472f1a8cc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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,16 +3584,17 @@ 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();
let header =
div()
.p_1()
.w_full()
@ -3694,9 +3702,7 @@ impl EditorElement {
.unwrap_or_else(|| "untitled".into()),
)
.single_line()
.when_some(
file_status,
|el, status| {
.when_some(file_status, |el, status| {
el.color(if status.is_conflicted() {
Color::Conflict
} else if status.is_modified() {
@ -3707,8 +3713,7 @@ impl EditorElement {
Color::Created
})
.when(status.is_deleted(), |el| el.strikethrough())
},
),
}),
)
.when_some(parent_path, |then, path| {
then.child(div().child(path).text_color(
@ -3720,7 +3725,9 @@ impl EditorElement {
))
}),
)
.when(can_open_excerpts && is_selected && path.is_some(), |el| {
.when(
can_open_excerpts && is_selected && relative_path.is_some(),
|el| {
el.child(
h_flex()
.id("jump-to-file-button")
@ -3736,7 +3743,8 @@ impl EditorElement {
.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| {
@ -3749,7 +3757,101 @@ impl EditorElement {
}
})),
),
);
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_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,
))
});
}
}),
)
})
.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(