diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index eded580181..763bf84574 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5a89af2d21..15bf7cabe7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3e06d85659..b523118161 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 05dafbf91e..8fe6d20826 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -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. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c9ab9baeb9..88a3184f6b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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, marked_entries: Vec, selection: Option, - context_menu: Option<(Entity, Point, Subscription)>, + context_menu: Option, filename_editor: Entity, clipboard: Option, _dragged_entry_destination: Option>, @@ -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), + /// 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 = point(px(2.), px(2.)); + +struct DeployedContextMenu { + menu: Entity, + position: Point, + /// 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, + _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, + placement: ContextMenuPlacement, entry_id: ProjectEntryId, window: &mut Window, cx: &mut Context, @@ -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, + ) { + 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, + window: &Window, + cx: &App, + ) -> (Point, 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 { + 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> { + 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) })) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index e6bb8e78b9..1eb129e53e 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -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, 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::>(); + 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::(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,