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:
1 parent
3ff99ed78e
commit
d643da9c3b
6 files changed
+465
-40
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user