workspace: Add recent projects in the multi-project dropdown (#46828)

Danilo Leal created

This PR adds a list of recent projects to the multi-project dropdown,
avoiding the need to open up the picker and the mouse travel when using
a pointer to interact with it. When the recent projects list is bigger
than 5, we display more in a "View More" submenu.

<img width="500" height="800" alt="Screenshot 2026-01-14 at 5β€― 15@2x"
src="https://github.com/user-attachments/assets/31f11e3e-d010-4b66-a539-f74cbc572e40"
/>

Release Notes:

- Workspace: Added the list of recent projects to the multi-project
title bar menu.

Change summary

crates/recent_projects/src/recent_projects.rs |  64 ++++
crates/title_bar/src/project_dropdown.rs      | 289 +++++++++++++++++++-
crates/ui/src/components/context_menu.rs      |  91 ++++++
3 files changed, 419 insertions(+), 25 deletions(-)

Detailed changes

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

@@ -37,6 +37,70 @@ use workspace::{
 };
 use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
 
+#[derive(Clone, Debug)]
+pub struct RecentProjectEntry {
+    pub name: SharedString,
+    pub full_path: SharedString,
+    pub paths: Vec<PathBuf>,
+    pub workspace_id: WorkspaceId,
+}
+
+pub async fn get_recent_projects(
+    current_workspace_id: Option<WorkspaceId>,
+    limit: Option<usize>,
+) -> Vec<RecentProjectEntry> {
+    let workspaces = WORKSPACE_DB
+        .recent_workspaces_on_disk()
+        .await
+        .unwrap_or_default();
+
+    let entries: Vec<RecentProjectEntry> = workspaces
+        .into_iter()
+        .filter(|(id, _, _)| Some(*id) != current_workspace_id)
+        .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
+        .map(|(workspace_id, _, path_list)| {
+            let paths: Vec<PathBuf> = path_list.paths().to_vec();
+            let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
+
+            let name = if ordered_paths.len() == 1 {
+                ordered_paths[0]
+                    .file_name()
+                    .map(|n| n.to_string_lossy().to_string())
+                    .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
+            } else {
+                ordered_paths
+                    .iter()
+                    .filter_map(|p| p.file_name())
+                    .map(|n| n.to_string_lossy().to_string())
+                    .collect::<Vec<_>>()
+                    .join(", ")
+            };
+
+            let full_path = ordered_paths
+                .iter()
+                .map(|p| p.to_string_lossy().to_string())
+                .collect::<Vec<_>>()
+                .join("\n");
+
+            RecentProjectEntry {
+                name: SharedString::from(name),
+                full_path: SharedString::from(full_path),
+                paths,
+                workspace_id,
+            }
+        })
+        .collect();
+
+    match limit {
+        Some(n) => entries.into_iter().take(n).collect(),
+        None => entries,
+    }
+}
+
+pub async fn delete_recent_project(workspace_id: WorkspaceId) {
+    let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
+}
+
 pub fn init(cx: &mut App) {
     #[cfg(target_os = "windows")]
     cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {

crates/title_bar/src/project_dropdown.rs πŸ”—

@@ -1,4 +1,5 @@
 use std::cell::RefCell;
+use std::path::PathBuf;
 use std::rc::Rc;
 
 use gpui::{
@@ -7,12 +8,15 @@ use gpui::{
 };
 use menu;
 use project::{Project, Worktree, git_store::Repository};
+use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects};
 use settings::WorktreeId;
-use ui::{ContextMenu, Tooltip, prelude::*};
-use workspace::Workspace;
+use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*};
+use workspace::{CloseIntent, Workspace};
 
 actions!(project_dropdown, [RemoveSelectedFolder]);
 
+const RECENT_PROJECTS_INLINE_LIMIT: usize = 5;
+
 struct ProjectEntry {
     worktree_id: WorktreeId,
     name: SharedString,
@@ -25,6 +29,7 @@ pub struct ProjectDropdown {
     workspace: WeakEntity<Workspace>,
     worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
     menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
+    _recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
     _subscription: Subscription,
 }
 
@@ -38,6 +43,8 @@ impl ProjectDropdown {
     ) -> Self {
         let menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>> = Rc::new(RefCell::new(None));
         let worktree_ids: Rc<RefCell<Vec<WorktreeId>>> = Rc::new(RefCell::new(Vec::new()));
+        let recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>> =
+            Rc::new(RefCell::new(Vec::new()));
 
         let menu = Self::build_menu(
             project,
@@ -45,6 +52,7 @@ impl ProjectDropdown {
             initial_active_worktree_id,
             menu_shell.clone(),
             worktree_ids.clone(),
+            recent_projects.clone(),
             window,
             cx,
         );
@@ -55,11 +63,41 @@ impl ProjectDropdown {
             cx.emit(DismissEvent);
         });
 
+        let recent_projects_for_fetch = recent_projects.clone();
+        let menu_shell_for_fetch = menu_shell.clone();
+        let workspace_for_fetch = workspace.clone();
+
+        cx.spawn_in(window, async move |_this, cx| {
+            let current_workspace_id = cx
+                .update(|_, cx| {
+                    workspace_for_fetch
+                        .upgrade()
+                        .and_then(|ws| ws.read(cx).database_id())
+                })
+                .ok()
+                .flatten();
+
+            let projects = get_recent_projects(current_workspace_id, None).await;
+
+            cx.update(|window, cx| {
+                *recent_projects_for_fetch.borrow_mut() = projects;
+
+                if let Some(menu_entity) = menu_shell_for_fetch.borrow().clone() {
+                    menu_entity.update(cx, |menu, cx| {
+                        menu.rebuild(window, cx);
+                    });
+                }
+            })
+            .ok()
+        })
+        .detach();
+
         Self {
             menu,
             workspace,
             worktree_ids,
             menu_shell,
+            _recent_projects: recent_projects,
             _subscription,
         }
     }
@@ -70,10 +108,11 @@ impl ProjectDropdown {
         initial_active_worktree_id: Option<WorktreeId>,
         menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
         worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
+        recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Entity<ContextMenu> {
-        ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
+        ContextMenu::build_persistent(window, cx, move |menu, window, cx| {
             let active_worktree_id = if menu_shell.borrow().is_some() {
                 workspace
                     .upgrade()
@@ -106,15 +145,12 @@ impl ProjectDropdown {
                 let workspace_for_remove = workspace.clone();
                 let menu_shell_for_remove = menu_shell.clone();
 
-                let menu_focus_handle = menu.focus_handle(cx);
-
                 menu = menu.custom_entry(
                     move |_window, _cx| {
                         let name = name.clone();
                         let branch = branch.clone();
                         let workspace_for_remove = workspace_for_remove.clone();
                         let menu_shell = menu_shell_for_remove.clone();
-                        let menu_focus_handle = menu_focus_handle.clone();
 
                         h_flex()
                             .group(name.clone())
@@ -139,13 +175,21 @@ impl ProjectDropdown {
                                 .visible_on_hover(name)
                                 .icon_size(IconSize::Small)
                                 .icon_color(Color::Muted)
-                                .tooltip(move |_, cx| {
-                                    Tooltip::for_action_in(
-                                        "Remove Folder",
-                                        &RemoveSelectedFolder,
-                                        &menu_focus_handle,
-                                        cx,
-                                    )
+                                .tooltip({
+                                    let menu_shell = menu_shell.clone();
+                                    move |window, cx| {
+                                        if let Some(menu_entity) = menu_shell.borrow().as_ref() {
+                                            let focus_handle = menu_entity.focus_handle(cx);
+                                            Tooltip::for_action_in(
+                                                "Remove Folder",
+                                                &RemoveSelectedFolder,
+                                                &focus_handle,
+                                                cx,
+                                            )
+                                        } else {
+                                            Tooltip::text("Remove Folder")(window, cx)
+                                        }
+                                    }
                                 })
                                 .on_click({
                                     let workspace = workspace_for_remove;
@@ -174,21 +218,216 @@ impl ProjectDropdown {
                 );
             }
 
-            menu.separator()
-                .action(
-                    "Add Folder to Workspace",
-                    workspace::AddFolderToProject.boxed_clone(),
-                )
-                .action(
-                    "Open Recent Projects",
-                    zed_actions::OpenRecent {
-                        create_new_window: false,
-                    }
-                    .boxed_clone(),
-                )
+            menu = menu.separator();
+
+            let recent = recent_projects.borrow();
+
+            if !recent.is_empty() {
+                menu = menu.header("Recent Projects");
+
+                let enter_hint = window.keystroke_text_for(&menu::Confirm);
+                let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm);
+
+                let inline_count = recent.len().min(RECENT_PROJECTS_INLINE_LIMIT);
+                for entry in recent.iter().take(inline_count) {
+                    menu = Self::add_recent_project_entry(
+                        menu,
+                        entry.clone(),
+                        workspace.clone(),
+                        menu_shell.clone(),
+                        recent_projects.clone(),
+                        &enter_hint,
+                        &cmd_enter_hint,
+                    );
+                }
+
+                if recent.len() > RECENT_PROJECTS_INLINE_LIMIT {
+                    let remaining_projects: Vec<RecentProjectEntry> = recent
+                        .iter()
+                        .skip(RECENT_PROJECTS_INLINE_LIMIT)
+                        .cloned()
+                        .collect();
+                    let workspace_for_submenu = workspace.clone();
+                    let menu_shell_for_submenu = menu_shell.clone();
+                    let recent_projects_for_submenu = recent_projects.clone();
+
+                    menu = menu.submenu("View More…", move |submenu, window, _cx| {
+                        let enter_hint = window.keystroke_text_for(&menu::Confirm);
+                        let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm);
+
+                        let mut submenu = submenu;
+                        for entry in &remaining_projects {
+                            submenu = Self::add_recent_project_entry(
+                                submenu,
+                                entry.clone(),
+                                workspace_for_submenu.clone(),
+                                menu_shell_for_submenu.clone(),
+                                recent_projects_for_submenu.clone(),
+                                &enter_hint,
+                                &cmd_enter_hint,
+                            );
+                        }
+                        submenu
+                    });
+                }
+
+                menu = menu.separator();
+            }
+            drop(recent);
+
+            menu.action(
+                "Add Folder to Workspace",
+                workspace::AddFolderToProject.boxed_clone(),
+            )
         })
     }
 
+    fn add_recent_project_entry(
+        menu: ContextMenu,
+        entry: RecentProjectEntry,
+        workspace: WeakEntity<Workspace>,
+        menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
+        recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
+        enter_hint: &str,
+        cmd_enter_hint: &str,
+    ) -> ContextMenu {
+        let name = entry.name.clone();
+        let full_path = entry.full_path.clone();
+        let paths = entry.paths.clone();
+        let workspace_id = entry.workspace_id;
+
+        let element_id = format!("remove-recent-{}", full_path);
+
+        let enter_hint = enter_hint.to_string();
+        let cmd_enter_hint = cmd_enter_hint.to_string();
+        let full_path_for_docs = full_path;
+        let docs_aside = DocumentationAside {
+            side: DocumentationSide::Right,
+            render: Rc::new(move |cx| {
+                v_flex()
+                    .gap_1()
+                    .child(Label::new(full_path_for_docs.clone()).size(LabelSize::Small))
+                    .child(
+                        h_flex()
+                            .pt_1()
+                            .gap_1()
+                            .border_t_1()
+                            .border_color(cx.theme().colors().border_variant)
+                            .child(
+                                Label::new(format!("{} reuses this window", enter_hint))
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new(format!("{} opens a new one", cmd_enter_hint))
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            ),
+                    )
+                    .into_any_element()
+            }),
+        };
+
+        menu.custom_entry_with_docs(
+            {
+                let menu_shell_for_delete = menu_shell;
+                let recent_projects_for_delete = recent_projects;
+
+                move |_window, _cx| {
+                    let name = name.clone();
+                    let menu_shell = menu_shell_for_delete.clone();
+                    let recent_projects = recent_projects_for_delete.clone();
+
+                    h_flex()
+                        .group(name.clone())
+                        .w_full()
+                        .justify_between()
+                        .child(Label::new(name.clone()))
+                        .child(
+                            IconButton::new(element_id.clone(), IconName::Close)
+                                .visible_on_hover(name)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted)
+                                .tooltip(Tooltip::text("Remove from Recent Projects"))
+                                .on_click({
+                                    move |_, window, cx| {
+                                        let menu_shell = menu_shell.clone();
+                                        let recent_projects = recent_projects.clone();
+
+                                        recent_projects
+                                            .borrow_mut()
+                                            .retain(|p| p.workspace_id != workspace_id);
+
+                                        if let Some(menu_entity) = menu_shell.borrow().clone() {
+                                            menu_entity.update(cx, |menu, cx| {
+                                                menu.rebuild(window, cx);
+                                            });
+                                        }
+
+                                        cx.background_spawn(async move {
+                                            delete_recent_project(workspace_id).await;
+                                        })
+                                        .detach();
+                                    }
+                                }),
+                        )
+                        .into_any_element()
+                }
+            },
+            move |window, cx| {
+                let create_new_window = window.modifiers().platform;
+                Self::open_recent_project(
+                    workspace.clone(),
+                    paths.clone(),
+                    create_new_window,
+                    window,
+                    cx,
+                );
+                window.dispatch_action(menu::Cancel.boxed_clone(), cx);
+            },
+            Some(docs_aside),
+        )
+    }
+
+    fn open_recent_project(
+        workspace: WeakEntity<Workspace>,
+        paths: Vec<PathBuf>,
+        create_new_window: bool,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let Some(workspace) = workspace.upgrade() else {
+            return;
+        };
+
+        workspace.update(cx, |workspace, cx| {
+            if create_new_window {
+                workspace.open_workspace_for_paths(false, paths, window, cx)
+            } else {
+                cx.spawn_in(window, {
+                    let paths = paths.clone();
+                    async move |workspace, cx| {
+                        let continue_replacing = workspace
+                            .update_in(cx, |workspace, window, cx| {
+                                workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
+                            })?
+                            .await?;
+                        if continue_replacing {
+                            workspace
+                                .update_in(cx, |workspace, window, cx| {
+                                    workspace.open_workspace_for_paths(true, paths, window, cx)
+                                })?
+                                .await
+                        } else {
+                            Ok(())
+                        }
+                    }
+                })
+            }
+            .detach_and_log_err(cx);
+        });
+    }
+
     /// Get all projects sorted alphabetically with their branch info.
     fn get_project_entries(
         project: &Entity<Project>,

crates/ui/src/components/context_menu.rs πŸ”—

@@ -90,6 +90,7 @@ pub struct ContextMenuEntry {
     icon_size: IconSize,
     icon_color: Option<Color>,
     handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
+    secondary_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
     action: Option<Box<dyn Action>>,
     disabled: bool,
     documentation_aside: Option<DocumentationAside>,
@@ -111,6 +112,7 @@ impl ContextMenuEntry {
             icon_size: IconSize::Small,
             icon_color: None,
             handler: Rc::new(|_, _, _| {}),
+            secondary_handler: None,
             action: None,
             disabled: false,
             documentation_aside: None,
@@ -175,6 +177,11 @@ impl ContextMenuEntry {
         self
     }
 
+    pub fn secondary_handler(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+        self.secondary_handler = Some(Rc::new(move |_, window, cx| handler(window, cx)));
+        self
+    }
+
     pub fn disabled(mut self, disabled: bool) -> Self {
         self.disabled = disabled;
         self
@@ -523,6 +530,7 @@ impl ContextMenu {
             toggle: None,
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -553,6 +561,7 @@ impl ContextMenu {
             toggle: None,
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -583,6 +592,7 @@ impl ContextMenu {
             toggle: None,
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -612,6 +622,7 @@ impl ContextMenu {
             toggle: Some((position, toggled)),
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -656,6 +667,21 @@ impl ContextMenu {
         self
     }
 
+    pub fn custom_entry_with_docs(
+        mut self,
+        entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
+        handler: impl Fn(&mut Window, &mut App) + 'static,
+        documentation_aside: Option<DocumentationAside>,
+    ) -> Self {
+        self.items.push(ContextMenuItem::CustomEntry {
+            entry_render: Box::new(entry_render),
+            handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            selectable: true,
+            documentation_aside,
+        });
+        self
+    }
+
     pub fn label(mut self, label: impl Into<SharedString>) -> Self {
         self.items.push(ContextMenuItem::Label(label.into()));
         self
@@ -685,6 +711,7 @@ impl ContextMenu {
                 }
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -717,6 +744,7 @@ impl ContextMenu {
                 }
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
+            secondary_handler: None,
             icon: None,
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -739,6 +767,7 @@ impl ContextMenu {
             label: label.into(),
             action: Some(action.boxed_clone()),
             handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
+            secondary_handler: None,
             icon: Some(IconName::ArrowUpRight),
             custom_icon_path: None,
             custom_icon_svg: None,
@@ -888,6 +917,66 @@ impl ContextMenu {
         }
     }
 
+    pub fn secondary_confirm(
+        &mut self,
+        _: &menu::SecondaryConfirm,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(ix) = self.selected_index else {
+            return;
+        };
+
+        if let Some(ContextMenuItem::Submenu { builder, .. }) = self.items.get(ix) {
+            self.open_submenu(
+                ix,
+                builder.clone(),
+                SubmenuOpenTrigger::Keyboard,
+                window,
+                cx,
+            );
+
+            if let SubmenuState::Open(open_submenu) = &self.submenu_state {
+                let focus_handle = open_submenu.entity.read(cx).focus_handle.clone();
+                window.focus(&focus_handle, cx);
+                open_submenu.entity.update(cx, |submenu, cx| {
+                    submenu.select_first(&SelectFirst, window, cx);
+                });
+            }
+
+            cx.notify();
+            return;
+        }
+
+        let context = self.action_context.as_ref();
+
+        if let Some(ContextMenuItem::Entry(ContextMenuEntry {
+            handler,
+            secondary_handler,
+            disabled: false,
+            ..
+        })) = self.items.get(ix)
+        {
+            if let Some(secondary) = secondary_handler {
+                (secondary)(context, window, cx)
+            } else {
+                (handler)(context, window, cx)
+            }
+        } else if let Some(ContextMenuItem::CustomEntry { handler, .. }) = self.items.get(ix) {
+            (handler)(context, window, cx)
+        }
+
+        if self.main_menu.is_some() && !self.keep_open_on_confirm {
+            self.clicked = true;
+        }
+
+        if self.keep_open_on_confirm {
+            self.rebuild(window, cx);
+        } else {
+            cx.emit(DismissEvent);
+        }
+    }
+
     pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
         if self.main_menu.is_some() {
             cx.emit(DismissEvent);
@@ -1609,6 +1698,7 @@ impl ContextMenu {
             end_slot_title,
             end_slot_handler,
             show_end_slot_on_hover,
+            secondary_handler: _,
         } = entry;
         let this = cx.weak_entity();
 
@@ -2035,6 +2125,7 @@ impl Render for ContextMenu {
                         .on_action(cx.listener(ContextMenu::select_submenu_child))
                         .on_action(cx.listener(ContextMenu::select_submenu_parent))
                         .on_action(cx.listener(ContextMenu::confirm))
+                        .on_action(cx.listener(ContextMenu::secondary_confirm))
                         .on_action(cx.listener(ContextMenu::cancel))
                         .on_hover(cx.listener(|this, hovered: &bool, _, cx| {
                             if *hovered {