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::{ use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent,
MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle,
ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
transparent_black,
}; };
use itertools::Itertools; use itertools::Itertools;
use language::language_settings::{ use language::language_settings::{
@ -60,7 +61,7 @@ use multi_buffer::{
}; };
use project::{ use project::{
ProjectPath, Entry, ProjectPath,
debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings}, project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings},
}; };
@ -80,11 +81,17 @@ use std::{
use sum_tree::Bias; use sum_tree::Bias;
use text::{BufferId, SelectionGoal}; use text::{BufferId, SelectionGoal};
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; 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 unicode_segmentation::UnicodeSegmentation;
use util::post_inc; use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic}; 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. /// Determines what kinds of highlights should be applied to a lines background.
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
@ -3556,7 +3563,7 @@ impl EditorElement {
jump_data: JumpData, jump_data: JumpData,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Div { ) -> impl IntoElement {
let editor = self.editor.read(cx); let editor = self.editor.read(cx);
let file_status = editor let file_status = editor
.buffer .buffer
@ -3577,16 +3584,17 @@ impl EditorElement {
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1) .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default(); .unwrap_or_default();
let can_open_excerpts = Editor::can_open_excerpts_in_file(for_excerpt.buffer.file()); 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 relative_path = for_excerpt.buffer.resolve_file_path(cx, include_root);
let filename = path let filename = relative_path
.as_ref() .as_ref()
.and_then(|path| Some(path.file_name()?.to_string_lossy().to_string())); .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) Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
}); });
let focus_handle = editor.focus_handle(cx); let focus_handle = editor.focus_handle(cx);
let colors = cx.theme().colors(); let colors = cx.theme().colors();
let header =
div() div()
.p_1() .p_1()
.w_full() .w_full()
@ -3694,9 +3702,7 @@ impl EditorElement {
.unwrap_or_else(|| "untitled".into()), .unwrap_or_else(|| "untitled".into()),
) )
.single_line() .single_line()
.when_some( .when_some(file_status, |el, status| {
file_status,
|el, status| {
el.color(if status.is_conflicted() { el.color(if status.is_conflicted() {
Color::Conflict Color::Conflict
} else if status.is_modified() { } else if status.is_modified() {
@ -3707,8 +3713,7 @@ impl EditorElement {
Color::Created Color::Created
}) })
.when(status.is_deleted(), |el| el.strikethrough()) .when(status.is_deleted(), |el| el.strikethrough())
}, }),
),
) )
.when_some(parent_path, |then, path| { .when_some(parent_path, |then, path| {
then.child(div().child(path).text_color( 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( el.child(
h_flex() h_flex()
.id("jump-to-file-button") .id("jump-to-file-button")
@ -3736,7 +3743,8 @@ impl EditorElement {
.map(|binding| binding.into_any_element()), .map(|binding| binding.into_any_element()),
), ),
) )
}) },
)
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(window.listener_for(&self.editor, { .on_click(window.listener_for(&self.editor, {
move |editor, e: &ClickEvent, window, cx| { 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( fn render_blocks(