From 99829c7ce7c20af5ed25597754ca5f910e329c35 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Wed, 14 Jan 2026 18:34:27 -0300
Subject: [PATCH] workspace: Add recent projects in the multi-project dropdown
(#46828)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Release Notes:
- Workspace: Added the list of recent projects to the multi-project
title bar menu.
---
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(-)
diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs
index 524677da8917d51637c26c6cd766bdeb57920d43..ceb716a55a2d719effb1804febeafad5bfab04cf 100644
--- a/crates/recent_projects/src/recent_projects.rs
+++ b/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,
+ pub workspace_id: WorkspaceId,
+}
+
+pub async fn get_recent_projects(
+ current_workspace_id: Option,
+ limit: Option,
+) -> Vec {
+ let workspaces = WORKSPACE_DB
+ .recent_workspaces_on_disk()
+ .await
+ .unwrap_or_default();
+
+ let entries: Vec = workspaces
+ .into_iter()
+ .filter(|(id, _, _)| Some(*id) != current_workspace_id)
+ .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
+ .map(|(workspace_id, _, path_list)| {
+ let paths: Vec = 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::>()
+ .join(", ")
+ };
+
+ let full_path = ordered_paths
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect::>()
+ .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| {
diff --git a/crates/title_bar/src/project_dropdown.rs b/crates/title_bar/src/project_dropdown.rs
index c946db957a07667f243ecd28c08868a400b6f38d..a0927918c7493c1da711fcab3fa0af546bc4a0e5 100644
--- a/crates/title_bar/src/project_dropdown.rs
+++ b/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,
worktree_ids: Rc>>,
menu_shell: Rc>>>,
+ _recent_projects: Rc>>,
_subscription: Subscription,
}
@@ -38,6 +43,8 @@ impl ProjectDropdown {
) -> Self {
let menu_shell: Rc>>> = Rc::new(RefCell::new(None));
let worktree_ids: Rc>> = Rc::new(RefCell::new(Vec::new()));
+ let recent_projects: Rc>> =
+ 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,
menu_shell: Rc>>>,
worktree_ids: Rc>>,
+ recent_projects: Rc>>,
window: &mut Window,
cx: &mut Context,
) -> Entity {
- 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 = 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,
+ menu_shell: Rc>>>,
+ recent_projects: Rc>>,
+ 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,
+ paths: Vec,
+ 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,
diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs
index 5e6b2222c54d39a08e9210d4ac196822a35f1d1c..4372ebd821c9a935e44a4289bb377dd4af023311 100644
--- a/crates/ui/src/components/context_menu.rs
+++ b/crates/ui/src/components/context_menu.rs
@@ -90,6 +90,7 @@ pub struct ContextMenuEntry {
icon_size: IconSize,
icon_color: Option,
handler: Rc, &mut Window, &mut App)>,
+ secondary_handler: Option, &mut Window, &mut App)>>,
action: Option>,
disabled: bool,
documentation_aside: Option,
@@ -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,
+ ) -> 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) -> 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,
+ ) {
+ 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) {
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 {