recent_projects: Improve keyboard navigation (#52866)

Danilo Leal created

Previously, within the recent projects modal, the ability to add a
project to the workspace, delete a project from the "this window" or
"recent projects" sections, and remove a project from the current window
were only possible to do with the mouse. This PR enables them with the
keyboard, through buttons/keybindings exposed in the modal's footer and
"actions" menu. Here's a quick video for reference:


https://github.com/user-attachments/assets/b8980ed8-ba32-4e20-93b4-c0a9ea311309

Release Notes:

- Improved keyboard navigation for the recent projects modal.

Change summary

assets/keymaps/default-linux.json             |   2 
assets/keymaps/default-macos.json             |   2 
assets/keymaps/default-windows.json           |   2 
crates/recent_projects/src/recent_projects.rs | 190 +++++++++++++++++---
4 files changed, 166 insertions(+), 30 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -1123,6 +1123,8 @@
     "bindings": {
       "ctrl-k": "recent_projects::ToggleActionsMenu",
       "ctrl-shift-a": "workspace::AddFolderToProject",
+      "shift-backspace": "recent_projects::RemoveSelected",
+      "ctrl-shift-enter": "recent_projects::AddToWorkspace",
     },
   },
   {

assets/keymaps/default-macos.json πŸ”—

@@ -1188,6 +1188,8 @@
     "bindings": {
       "cmd-k": "recent_projects::ToggleActionsMenu",
       "cmd-shift-a": "workspace::AddFolderToProject",
+      "shift-backspace": "recent_projects::RemoveSelected",
+      "cmd-shift-enter": "recent_projects::AddToWorkspace",
     },
   },
   {

assets/keymaps/default-windows.json πŸ”—

@@ -1134,6 +1134,8 @@
     "bindings": {
       "ctrl-k": "recent_projects::ToggleActionsMenu",
       "ctrl-shift-a": "workspace::AddFolderToProject",
+      "shift-backspace": "recent_projects::RemoveSelected",
+      "ctrl-shift-enter": "recent_projects::AddToWorkspace",
     },
   },
   {

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -52,7 +52,10 @@ use workspace::{
 };
 use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
 
-actions!(recent_projects, [ToggleActionsMenu]);
+actions!(
+    recent_projects,
+    [ToggleActionsMenu, RemoveSelected, AddToWorkspace,]
+);
 
 #[derive(Clone, Debug)]
 pub struct RecentProjectEntry {
@@ -684,6 +687,79 @@ impl RecentProjects {
             }
         });
     }
+
+    fn handle_remove_selected(
+        &mut self,
+        _: &RemoveSelected,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            let ix = picker.delegate.selected_index;
+
+            match picker.delegate.filtered_entries.get(ix) {
+                Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
+                    if let Some(folder) = picker.delegate.open_folders.get(*index) {
+                        let worktree_id = folder.worktree_id;
+                        let Some(workspace) = picker.delegate.workspace.upgrade() else {
+                            return;
+                        };
+                        workspace.update(cx, |workspace, cx| {
+                            let project = workspace.project().clone();
+                            project.update(cx, |project, cx| {
+                                project.remove_worktree(worktree_id, cx);
+                            });
+                        });
+                        picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
+                        let query = picker.query(cx);
+                        picker.update_matches(query, window, cx);
+                    }
+                }
+                Some(ProjectPickerEntry::OpenProject(hit)) => {
+                    if let Some((workspace_id, ..)) =
+                        picker.delegate.workspaces.get(hit.candidate_id)
+                    {
+                        let workspace_id = *workspace_id;
+                        picker
+                            .delegate
+                            .remove_sibling_workspace(workspace_id, window, cx);
+                        let query = picker.query(cx);
+                        picker.update_matches(query, window, cx);
+                    }
+                }
+                Some(ProjectPickerEntry::RecentProject(_)) => {
+                    picker.delegate.delete_recent_project(ix, window, cx);
+                }
+                _ => {}
+            }
+        });
+    }
+
+    fn handle_add_to_workspace(
+        &mut self,
+        _: &AddToWorkspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            let ix = picker.delegate.selected_index;
+
+            if let Some(ProjectPickerEntry::RecentProject(hit)) =
+                picker.delegate.filtered_entries.get(ix)
+            {
+                if let Some((_, location, paths, _)) =
+                    picker.delegate.workspaces.get(hit.candidate_id)
+                {
+                    if matches!(location, SerializedWorkspaceLocation::Local) {
+                        let paths_to_add = paths.paths().to_vec();
+                        picker
+                            .delegate
+                            .add_project_to_workspace(paths_to_add, window, cx);
+                    }
+                }
+            }
+        });
+    }
 }
 
 impl EventEmitter<DismissEvent> for RecentProjects {}
@@ -699,6 +775,8 @@ impl Render for RecentProjects {
         v_flex()
             .key_context("RecentProjects")
             .on_action(cx.listener(Self::handle_toggle_open_menu))
+            .on_action(cx.listener(Self::handle_remove_selected))
+            .on_action(cx.listener(Self::handle_add_to_workspace))
             .w(rems(self.rem_width))
             .child(self.picker.clone())
     }
@@ -1364,7 +1442,6 @@ impl PickerDelegate for RecentProjectsDelegate {
                 )
             }
             ProjectPickerEntry::RecentProject(hit) => {
-                let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
                 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
                 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
                 let paths_to_add = paths.paths().to_vec();
@@ -1432,28 +1509,26 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 }),
                         )
                     })
-                    .when(popover_style, |this| {
-                        this.child(
-                            IconButton::new("open_new_window", IconName::ArrowUpRight)
-                                .icon_size(IconSize::XSmall)
-                                .tooltip({
-                                    move |_, cx| {
-                                        Tooltip::for_action_in(
-                                            "Open Project in New Window",
-                                            &menu::SecondaryConfirm,
-                                            &focus_handle,
-                                            cx,
-                                        )
-                                    }
-                                })
-                                .on_click(cx.listener(move |this, _event, window, cx| {
-                                    cx.stop_propagation();
-                                    window.prevent_default();
-                                    this.delegate.set_selected_index(ix, window, cx);
-                                    this.delegate.confirm(true, window, cx);
-                                })),
-                        )
-                    })
+                    .child(
+                        IconButton::new("open_new_window", IconName::ArrowUpRight)
+                            .icon_size(IconSize::XSmall)
+                            .tooltip({
+                                move |_, cx| {
+                                    Tooltip::for_action_in(
+                                        "Open Project in New Window",
+                                        &menu::SecondaryConfirm,
+                                        &focus_handle,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .on_click(cx.listener(move |this, _event, window, cx| {
+                                cx.stop_propagation();
+                                window.prevent_default();
+                                this.delegate.set_selected_index(ix, window, cx);
+                                this.delegate.confirm(true, window, cx);
+                            })),
+                    )
                     .child(
                         IconButton::new("delete", IconName::Close)
                             .icon_size(IconSize::Small)
@@ -1518,9 +1593,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                     .border_t_1()
                     .border_color(cx.theme().colors().border_variant)
                     .child({
-                        let open_action = workspace::Open {
-                            create_new_window: self.create_new_window,
-                        };
+                        let open_action = workspace::Open::default();
                         Button::new("open_local_folder", "Open Local Project")
                             .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
                             .on_click(move |_, window, cx| {
@@ -1551,6 +1624,44 @@ impl PickerDelegate for RecentProjectsDelegate {
             );
         }
 
+        let selected_entry = self.filtered_entries.get(self.selected_index);
+
+        let secondary_footer_actions: Option<AnyElement> = match selected_entry {
+            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_)) => {
+                let label = if matches!(selected_entry, Some(ProjectPickerEntry::OpenFolder { .. }))
+                {
+                    "Remove Folder"
+                } else {
+                    "Remove from Window"
+                };
+                Some(
+                    Button::new("remove_selected", label)
+                        .key_binding(KeyBinding::for_action_in(
+                            &RemoveSelected,
+                            &focus_handle,
+                            cx,
+                        ))
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(RemoveSelected.boxed_clone(), cx)
+                        })
+                        .into_any_element(),
+                )
+            }
+            Some(ProjectPickerEntry::RecentProject(_)) => Some(
+                Button::new("delete_recent", "Delete")
+                    .key_binding(KeyBinding::for_action_in(
+                        &RemoveSelected,
+                        &focus_handle,
+                        cx,
+                    ))
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
+                    })
+                    .into_any_element(),
+            ),
+            _ => None,
+        };
+
         Some(
             h_flex()
                 .flex_1()
@@ -1559,6 +1670,9 @@ impl PickerDelegate for RecentProjectsDelegate {
                 .justify_end()
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
+                .when_some(secondary_footer_actions, |this, actions| {
+                    this.child(actions)
+                })
                 .map(|this| {
                     if is_already_open_entry {
                         this.child(
@@ -1607,7 +1721,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                             y: px(-2.0),
                         })
                         .trigger(
-                            Button::new("actions-trigger", "Actions…")
+                            Button::new("actions-trigger", "Actions")
                                 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                                 .key_binding(KeyBinding::for_action_in(
                                     &ToggleActionsMenu,
@@ -1617,16 +1731,32 @@ impl PickerDelegate for RecentProjectsDelegate {
                         )
                         .menu({
                             let focus_handle = focus_handle.clone();
-                            let create_new_window = self.create_new_window;
+                            let show_add_to_workspace = match selected_entry {
+                                Some(ProjectPickerEntry::RecentProject(hit)) => self
+                                    .workspaces
+                                    .get(hit.candidate_id)
+                                    .map(|(_, loc, ..)| {
+                                        matches!(loc, SerializedWorkspaceLocation::Local)
+                                    })
+                                    .unwrap_or(false),
+                                _ => false,
+                            };
 
                             move |window, cx| {
                                 Some(ContextMenu::build(window, cx, {
                                     let focus_handle = focus_handle.clone();
                                     move |menu, _, _| {
                                         menu.context(focus_handle)
+                                            .when(show_add_to_workspace, |menu| {
+                                                menu.action(
+                                                    "Add to Workspace",
+                                                    AddToWorkspace.boxed_clone(),
+                                                )
+                                                .separator()
+                                            })
                                             .action(
                                                 "Open Local Project",
-                                                workspace::Open { create_new_window }.boxed_clone(),
+                                                workspace::Open::default().boxed_clone(),
                                             )
                                             .action(
                                                 "Open Remote Project",