project_panel: Add Menu key support to project panel context menu (#46744)

Closes #45783 

`Menu` key or `Shift + F10` shortcut wasn't supported in project panel
for opening the context menu, this PR fixes it.

Release Notes:
- Added an action and default keyboard shortcuts to open the context
menu in the project panel. You can now bind
`project_panel::OpenContextMenu` to deploy the context menu at the
currently selected entry in the panel.

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
This commit is contained in:
Giorgi MerebashviliandMrSubidubi authored and GitHub committed 2026-09-14 12:03:12 +00:00
1 parent 3ff99ed78e
commit d643da9c3b
6 files changed
+465 -40

No files matched your search

+2
View File
@@ -1009,6 +1009,8 @@
"alt-d": "project_panel::CompareMarkedFiles",
"shift-find": "project_panel::NewSearchInDirectory",
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
"menu": "project_panel::OpenContextMenu",
"shift-f10": "project_panel::OpenContextMenu",
"shift-down": "menu::SelectNext",
"shift-up": "menu::SelectPrevious",
"escape": "menu::Cancel",
+1
View File
@@ -1054,6 +1054,7 @@
"alt-d": "project_panel::CompareMarkedFiles",
"cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-f10": "project_panel::OpenContextMenu",
"shift-down": "menu::SelectNext",
"shift-up": "menu::SelectPrevious",
"escape": "menu::Cancel",
+2
View File
@@ -1002,6 +1002,8 @@
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory",
"menu": "project_panel::OpenContextMenu",
"shift-f10": "project_panel::OpenContextMenu",
"shift-down": "menu::SelectNext",
"shift-up": "menu::SelectPrevious",
"escape": "menu::Cancel",
+10 -1
View File
@@ -2239,7 +2239,7 @@ impl Anchor {
}
}
/// Returns true if at the center.
/// Returns whether the anchor is center-positioned.
#[inline]
pub fn is_center(&self) -> bool {
matches!(
@@ -2247,6 +2247,15 @@ impl Anchor {
Self::TopCenter | Self::BottomCenter | Self::LeftCenter | Self::RightCenter
)
}
/// Returns whether the anchor is bottom-positioned.
#[inline]
pub fn is_bottom(&self) -> bool {
matches!(
self,
Self::BottomCenter | Self::BottomLeft | Self::BottomRight
)
}
}
/// Represents the corners of a box in a 2D space, such as border radius.
+193 -36
View File
@@ -18,15 +18,15 @@ use git;
use git::status::GitSummary;
use git_ui_core::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry,
ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter,
ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, Focusable,
FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior,
ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render,
ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity,
Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient, point, px,
size, transparent_white, uniform_list,
Action, Anchor, AnyElement, App, AsyncWindowContext, Bounds,
ClipboardEntry as GpuiClipboardEntry, ClipboardItem, Context, CursorStyle, DismissEvent, Div,
DragMoveEvent, Entity, EventEmitter, ExternalDragPayload, ExternalPaths, FileDragPaths,
FileDropEvent, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext,
ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point,
PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task,
UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla,
linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list,
};
use language::DiagnosticSeverity;
use markdown_preview::markdown_preview_view::MarkdownPreviewView;
@@ -147,7 +147,7 @@ pub struct ProjectPanel {
drag_target_entry: Option<DragTarget>,
marked_entries: Vec<SelectedEntry>,
selection: Option<SelectedEntry>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
context_menu: Option<DeployedContextMenu>,
filename_editor: Entity<Editor>,
clipboard: Option<ClipboardEntry>,
_dragged_entry_destination: Option<Arc<Path>>,
@@ -189,6 +189,26 @@ impl Default for UpdateVisibleEntriesTask {
}
}
/// Where a context menu should appear when it is deployed.
#[derive(Copy, Clone, Debug, PartialEq)]
enum ContextMenuPlacement {
/// Slightly offset from the given mouse position, e.g. for a right click.
AtMouse(Point<Pixels>),
/// Next to the row of the entry the menu is deployed for, e.g. when opened from the keyboard.
AtEntry,
}
const CONTEXT_MENU_KEYBOARD_OFFSET: Point<Pixels> = point(px(2.), px(2.));
struct DeployedContextMenu {
menu: Entity<ContextMenu>,
position: Point<Pixels>,
/// Which corner of the menu is placed at `position`, determining the direction the menu opens
/// in. When `None`, the menu is placed like any other anchored element.
anchor: Option<Anchor>,
_subscription: Subscription,
}
enum DragTarget {
/// Dragging on an entry
Entry {
@@ -426,6 +446,8 @@ actions!(
Redo,
/// Opens a markdown preview for the selected file.
OpenMarkdownPreview,
/// Opens the context menu for the selected entry.
OpenContextMenu,
]
);
@@ -1076,9 +1098,26 @@ impl ProjectPanel {
}
}
/// Whether keyboard focus is within the panel, including a deployed context menu.
///
/// `contains_focused` answers from the last rendered frame's element tree. On the frame a
/// context menu is deployed, its focus node is not part of that tree yet, so the menu has
/// to be checked directly against the window's current focus.
///
/// TODO: We really really should fix context menu focus to not have to deal with
/// this everywhere as this is not a proper fix here but a mere workaround. Yet
/// a fix would require some more time and thought to not break other users of this
fn contains_focus(&self, window: &Window, cx: &App) -> bool {
self.focus_handle.contains_focused(window, cx)
|| self
.context_menu
.as_ref()
.is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window))
}
fn deploy_context_menu(
&mut self,
position: Point<Pixels>,
placement: ContextMenuPlacement,
entry_id: ProjectEntryId,
window: &mut Window,
cx: &mut Context<Self>,
@@ -1260,17 +1299,127 @@ impl ProjectPanel {
})
});
let (position, anchor) = match placement {
ContextMenuPlacement::AtMouse(mouse_position) => (mouse_position, None),
ContextMenuPlacement::AtEntry => match self.scroll_selection_into_view() {
Some(entry_bounds) => {
let (position, anchor) =
Self::context_menu_placement_for_entry(entry_bounds, window, cx);
let shifted_position = position
+ if anchor.is_bottom() {
-CONTEXT_MENU_KEYBOARD_OFFSET
} else {
CONTEXT_MENU_KEYBOARD_OFFSET
};
(shifted_position, Some(anchor))
}
None => (window.mouse_position(), None),
},
};
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
this.context_menu.take();
cx.notify();
});
self.context_menu = Some((context_menu, position, subscription));
self.context_menu = Some(DeployedContextMenu {
menu: context_menu,
position,
anchor,
_subscription: subscription,
});
}
cx.notify();
}
fn open_context_menu(
&mut self,
_: &OpenContextMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
let entry_id = if let Some(selection) = &self.selection {
selection.entry_id
} else if let Some(entry_id) = self.state.last_worktree_root_id {
entry_id
} else {
return;
};
self.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx);
}
/// Opens the menu downwards from the entry when the entry is in the upper half of the window
/// and upwards otherwise, and always towards the center of the window from the side the panel
/// is docked on, so that the menu has the most room to grow into.
fn context_menu_placement_for_entry(
entry_bounds: Bounds<Pixels>,
window: &Window,
cx: &App,
) -> (Point<Pixels>, Anchor) {
let opens_downwards = entry_bounds.center().y <= window.viewport_size().center().y;
// The menu's anchored corner sits on the diagonally opposite corner of the entry.
match (ProjectPanelSettings::get_global(cx).dock, opens_downwards) {
(DockSide::Left, true) => (entry_bounds.bottom_left(), Anchor::TopLeft),
(DockSide::Left, false) => (entry_bounds.origin, Anchor::BottomLeft),
(DockSide::Right, true) => (entry_bounds.bottom_right(), Anchor::TopRight),
(DockSide::Right, false) => (entry_bounds.top_right(), Anchor::BottomRight),
}
}
/// The height of a single row in the entries list, as measured by the list during its last layout.
fn entry_row_height(&self) -> Option<Pixels> {
let item_count: usize = self
.state
.visible_entries
.iter()
.map(|worktree| worktree.entries.len())
.sum();
if item_count == 0 {
return None;
}
let last_item_size = self.scroll_handle.0.borrow().last_item_size?;
let row_height = last_item_size.contents.height / item_count as f32;
(row_height > Pixels::ZERO).then_some(row_height)
}
/// Scrolls the entries list the minimal amount needed for the selected entry to be fully
/// visible, and returns the bounds of the entry's row in window coordinates.
fn scroll_selection_into_view(&self) -> Option<Bounds<Pixels>> {
let selection = self.selection?;
let (_, _, index) = self.index_for_selection(selection)?;
let row_height = self.entry_row_height()?;
let viewport = self.scroll_handle.viewport();
let current_offset = self.scroll_handle.offset();
let row_top = row_height * index;
let row_bottom = row_top + row_height;
// Sticky entries are drawn over the top rows of the list, so a row underneath them is not
// actually visible.
let visible_top = row_height * self.sticky_items_count;
let mut offset = current_offset;
if row_top + offset.y < visible_top {
offset.y = visible_top - row_top;
} else if row_bottom + offset.y > viewport.size.height {
offset.y = viewport.size.height - row_bottom;
}
offset.y = offset
.y
.clamp(-self.scroll_handle.max_offset().y, Pixels::ZERO);
if offset != current_offset {
self.scroll_handle.set_offset(offset);
}
Some(Bounds::new(
point(viewport.left(), viewport.top() + offset.y + row_top),
size(viewport.size.width, row_height),
))
}
fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool {
for visible in &self.state.visible_entries {
if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) {
@@ -5822,25 +5971,25 @@ impl ProjectPanel {
None
};
let border_color =
if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
match validation_color_and_message {
Some((color, _)) => color,
None => item_colors.focused,
}
} else {
bg_color
};
let show_focused_border = !self.mouse_down && is_active && self.contains_focus(window, cx);
let border_hover_color =
if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
match validation_color_and_message {
Some((color, _)) => color,
None => item_colors.focused,
}
} else {
bg_hover_color
};
let border_color = if show_focused_border {
match validation_color_and_message {
Some((color, _)) => color,
None => item_colors.focused,
}
} else {
bg_color
};
let border_hover_color = if show_focused_border {
match validation_color_and_message {
Some((color, _)) => color,
None => item_colors.focused,
}
} else {
bg_hover_color
};
let folded_directory_drag_target = self.folded_directory_drag_target;
let is_highlighted = {
@@ -6465,7 +6614,12 @@ impl ProjectPanel {
if !this.marked_entries.contains(&selection) {
this.marked_entries.clear();
}
this.deploy_context_menu(event.position, entry_id, window, cx);
this.deploy_context_menu(
ContextMenuPlacement::AtMouse(event.position),
entry_id,
window,
cx,
);
},
))
.overflow_x(),
@@ -7291,6 +7445,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::fold_directory))
.on_action(cx.listener(Self::remove_from_project))
.on_action(cx.listener(Self::compare_marked_files))
.on_action(cx.listener(Self::open_context_menu))
.when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory))
@@ -7676,7 +7831,7 @@ impl Render for ProjectPanel {
// act as if the user clicked the root of the last worktree.
if let Some(entry_id) = this.state.last_worktree_root_id {
this.deploy_context_menu(
event.position,
ContextMenuPlacement::AtMouse(event.position),
entry_id,
window,
cx,
@@ -7730,12 +7885,14 @@ impl Render for ProjectPanel {
window,
cx,
)
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
.children(self.context_menu.as_ref().map(|context_menu| {
deferred(
anchored()
.position(*position)
.anchor(gpui::Anchor::TopLeft)
.child(menu.clone()),
.position(context_menu.position)
.when_some(context_menu.anchor, |anchored, anchor| {
anchored.anchor(anchor)
})
.child(context_menu.menu.clone()),
)
.with_priority(3)
}))
+257 -3
View File
@@ -9260,7 +9260,7 @@ async fn test_context_menu_new_file_in_empty_hidden_root(cx: &mut gpui::TestAppC
.last_worktree_root_id
.expect("hidden root should be available for background context menu actions");
panel.deploy_context_menu(
gpui::point(gpui::px(1.), gpui::px(1.)),
ContextMenuPlacement::AtMouse(point(px(1.), px(1.))),
root_entry_id,
window,
cx,
@@ -11171,7 +11171,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(
gpui::point(gpui::px(1.), gpui::px(1.)),
ContextMenuPlacement::AtMouse(point(px(1.), px(1.))),
child_entry_id,
window,
cx,
@@ -11188,7 +11188,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(
gpui::point(gpui::px(2.), gpui::px(2.)),
ContextMenuPlacement::AtMouse(point(px(2.), px(2.))),
subdir_entry_id,
window,
cx,
@@ -11268,6 +11268,260 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu
);
}
#[gpui::test]
async fn test_context_menu_opens_at_mouse_position(cx: &mut gpui::TestAppContext) {
init_test(cx);
let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await;
let cx = &mut cx;
let mouse_position = point(px(37.), px(83.));
let entry_id =
find_project_entry(&panel, "root/file_2.txt", cx).expect("file should exist for this test");
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(
ContextMenuPlacement::AtMouse(mouse_position),
entry_id,
window,
cx,
);
});
panel.update(cx, |panel, _| {
let context_menu = panel
.context_menu
.as_ref()
.expect("context menu should be deployed");
assert_eq!(context_menu.position, mouse_position);
assert_eq!(
context_menu.anchor, None,
"mouse-deployed menus should not force an anchor"
);
});
}
#[gpui::test]
async fn test_context_menu_for_entry_in_upper_half_opens_downwards(cx: &mut gpui::TestAppContext) {
init_test(cx);
let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await;
let cx = &mut cx;
let entry_id =
find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test");
let offset_before = panel.read_with(cx, |panel, _| panel.scroll_handle.offset());
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx);
});
panel.update_in(cx, |panel, window, _| {
let context_menu = panel
.context_menu
.as_ref()
.expect("context menu should be deployed");
let selection = panel.selection.expect("deploying should select the entry");
assert_eq!(selection.entry_id, entry_id);
let (_, _, index) = panel
.index_for_selection(selection)
.expect("selected entry should be visible");
let row_height = panel
.entry_row_height()
.expect("the entries list should have been laid out");
let viewport = panel.scroll_handle.viewport();
assert!(
viewport.right() < window.viewport_size().center().x,
"the panel should be docked on the left side of the window"
);
assert_eq!(
panel.scroll_handle.offset(),
offset_before,
"an entry that is already visible should not cause scrolling"
);
let expected_entry_bottom = viewport.top() + offset_before.y + row_height * (index + 1);
assert!(
expected_entry_bottom < window.viewport_size().height / 2.,
"test entry should be in the upper half of the window"
);
assert_eq!(
context_menu.position,
point(viewport.left(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET,
"menu should hang off the bottom left corner of the entry"
);
assert_eq!(context_menu.anchor, Some(Anchor::TopLeft));
});
}
#[gpui::test]
async fn test_context_menu_for_entry_in_right_docked_panel_opens_leftwards(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let (panel, mut cx) = open_panel_with_files(3, DockSide::Right, cx).await;
let cx = &mut cx;
let entry_id =
find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test");
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx);
});
panel.update_in(cx, |panel, window, _| {
let context_menu = panel
.context_menu
.as_ref()
.expect("context menu should be deployed");
let selection = panel.selection.expect("deploying should select the entry");
let (_, _, index) = panel
.index_for_selection(selection)
.expect("selected entry should be visible");
let row_height = panel
.entry_row_height()
.expect("the entries list should have been laid out");
let viewport = panel.scroll_handle.viewport();
assert!(
viewport.left() > window.viewport_size().center().x,
"the panel should be docked on the right side of the window"
);
let expected_entry_bottom =
viewport.top() + panel.scroll_handle.offset().y + row_height * (index + 1);
assert_eq!(
context_menu.position,
point(viewport.right(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET,
"menu should hang off the bottom right corner of the entry"
);
assert_eq!(context_menu.anchor, Some(Anchor::TopRight));
});
}
#[gpui::test]
async fn test_context_menu_for_entry_in_lower_half_scrolls_into_view_and_opens_upwards(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let (panel, mut cx) = open_panel_with_files(200, DockSide::Left, cx).await;
let cx = &mut cx;
let entry_id = find_project_entry(&panel, "root/file_199.txt", cx)
.expect("file should exist for this test");
panel.read_with(cx, |panel, _| {
assert!(
panel.scroll_handle.is_scrollable(),
"the entries list should overflow the panel for this test"
);
assert_eq!(panel.scroll_handle.offset().y, Pixels::ZERO);
});
panel.update_in(cx, |panel, window, cx| {
panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx);
});
cx.run_until_parked();
panel.update_in(cx, |panel, window, _| {
let context_menu = panel
.context_menu
.as_ref()
.expect("context menu should be deployed");
let selection = panel.selection.expect("deploying should select the entry");
let (_, _, index) = panel
.index_for_selection(selection)
.expect("selected entry should be visible");
let row_height = panel
.entry_row_height()
.expect("the entries list should have been laid out");
let viewport = panel.scroll_handle.viewport();
let offset = panel.scroll_handle.offset();
assert!(
offset.y < Pixels::ZERO,
"the list should have scrolled to reveal the entry"
);
let entry_top = viewport.top() + offset.y + row_height * index;
let entry_bottom = entry_top + row_height;
assert!(
entry_top >= viewport.top() && entry_bottom <= viewport.bottom() + px(0.01),
"the entry should be scrolled into view, got {entry_top}..{entry_bottom} in {viewport:?}"
);
assert!(
entry_top > window.viewport_size().height / 2.,
"test entry should be in the lower half of the window"
);
assert_eq!(
context_menu.position,
point(viewport.left(), entry_top) - CONTEXT_MENU_KEYBOARD_OFFSET,
"menu should hang off the top left corner of the entry"
);
assert_eq!(context_menu.anchor, Some(Anchor::BottomLeft));
});
}
#[gpui::test]
async fn test_panel_keeps_focus_highlight_while_context_menu_is_deployed(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await;
let cx = &mut cx;
let entry_id =
find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test");
panel.update_in(cx, |panel, window, cx| {
panel.focus_handle.focus(window, cx);
assert!(panel.contains_focus(window, cx));
panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx);
// No frame has been drawn since the menu took focus, so the rendered element tree does
// not know about the menu yet. The panel must still consider itself focused so the
// selected entry keeps its focus border on this frame.
assert!(
panel
.context_menu
.as_ref()
.is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window)),
"deploying should focus the context menu"
);
assert!(panel.contains_focus(window, cx));
});
cx.run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(panel.contains_focus(window, cx));
});
}
async fn open_panel_with_files(
file_count: usize,
dock: DockSide,
cx: &mut gpui::TestAppContext,
) -> (Entity<ProjectPanel>, VisualTestContext) {
cx.update(|cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(ProjectPanelSettings { dock, ..settings }, cx);
});
let fs = FakeFs::new(cx.executor());
let files = (0..file_count)
.map(|index| (format!("file_{index}.txt"), json!("")))
.collect::<serde_json::Map<_, _>>();
fs.insert_tree(path!("/root"), serde_json::Value::Object(files))
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
workspace.open_panel::<ProjectPanel>(window, cx);
panel
});
cx.run_until_parked();
(panel, cx)
}
async fn run_create_file_in_folded_path_case(
case_name: &str,
active_ancestor_path: &str,