Files
zed/crates/title_bar/src/project_dropdown.rs
T
Danilo Leal 99829c7ce7 workspace: Add recent projects in the multi-project dropdown (#46828)
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.
2026-01-14 18:34:27 -03:00

593 lines
22 KiB
Rust

use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
WeakEntity, actions,
};
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, 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,
branch: Option<SharedString>,
is_active: bool,
}
pub struct ProjectDropdown {
menu: Entity<ContextMenu>,
workspace: WeakEntity<Workspace>,
worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
_recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
_subscription: Subscription,
}
impl ProjectDropdown {
pub fn new(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
initial_active_worktree_id: Option<WorktreeId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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,
workspace.clone(),
initial_active_worktree_id,
menu_shell.clone(),
worktree_ids.clone(),
recent_projects.clone(),
window,
cx,
);
*menu_shell.borrow_mut() = Some(menu.clone());
let _subscription = cx.subscribe(&menu, |_, _, _: &DismissEvent, cx| {
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,
}
}
fn build_menu(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
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| {
let active_worktree_id = if menu_shell.borrow().is_some() {
workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_worktree_override())
.or(initial_active_worktree_id)
} else {
initial_active_worktree_id
};
let entries = Self::get_project_entries(&project, active_worktree_id, cx);
// Update the worktree_ids list so we can map selected_index -> worktree_id.
{
let mut ids = worktree_ids.borrow_mut();
ids.clear();
for entry in &entries {
ids.push(entry.worktree_id);
}
}
let mut menu = menu.header("Open Folders");
for entry in entries {
let worktree_id = entry.worktree_id;
let name = entry.name.clone();
let branch = entry.branch.clone();
let is_active = entry.is_active;
let workspace_for_select = workspace.clone();
let workspace_for_remove = workspace.clone();
let menu_shell_for_remove = menu_shell.clone();
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();
h_flex()
.group(name.clone())
.w_full()
.justify_between()
.child(
h_flex()
.gap_1()
.child(
Label::new(name.clone())
.when(is_active, |label| label.color(Color::Accent)),
)
.when_some(branch, |this, branch| {
this.child(Label::new(branch).color(Color::Muted))
}),
)
.child(
IconButton::new(
("remove", worktree_id.to_usize()),
IconName::Close,
)
.visible_on_hover(name)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.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;
move |_, window, cx| {
Self::handle_remove(
workspace.clone(),
worktree_id,
window,
cx,
);
if let Some(menu_entity) = menu_shell.borrow().clone() {
menu_entity.update(cx, |menu, cx| {
menu.rebuild(window, cx);
});
}
}
}),
)
.into_any_element()
},
move |window, cx| {
Self::handle_select(workspace_for_select.clone(), worktree_id, window, cx);
window.dispatch_action(menu::Cancel.boxed_clone(), cx);
},
);
}
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>,
active_worktree_id: Option<WorktreeId>,
cx: &App,
) -> Vec<ProjectEntry> {
let project = project.read(cx);
let git_store = project.git_store().read(cx);
let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
let mut entries: Vec<ProjectEntry> = project
.visible_worktrees(cx)
.map(|worktree| {
let worktree_ref = worktree.read(cx);
let worktree_id = worktree_ref.id();
let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
let branch = Self::get_branch_for_worktree(worktree_ref, &repositories, cx);
let is_active = active_worktree_id == Some(worktree_id);
ProjectEntry {
worktree_id,
name,
branch,
is_active,
}
})
.collect();
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
entries
}
fn get_branch_for_worktree(
worktree: &Worktree,
repositories: &[Entity<Repository>],
cx: &App,
) -> Option<SharedString> {
let worktree_abs_path = worktree.abs_path();
for repo in repositories {
let repo = repo.read(cx);
if repo.work_directory_abs_path == worktree_abs_path
|| worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
{
if let Some(branch) = &repo.branch {
return Some(SharedString::from(branch.name().to_string()));
}
}
}
None
}
fn handle_select(
workspace: WeakEntity<Workspace>,
worktree_id: WorktreeId,
_window: &mut Window,
cx: &mut App,
) {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.set_active_worktree_override(Some(worktree_id), cx);
});
}
}
fn handle_remove(
workspace: WeakEntity<Workspace>,
worktree_id: WorktreeId,
_window: &mut Window,
cx: &mut App,
) {
if let Some(workspace) = workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
let project = workspace.project().clone();
let current_active_id = workspace.active_worktree_override();
let is_removing_active = current_active_id == Some(worktree_id);
if is_removing_active {
let worktrees: Vec<_> = project.read(cx).visible_worktrees(cx).collect();
let mut sorted: Vec<_> = worktrees
.iter()
.map(|wt| {
let wt = wt.read(cx);
(wt.root_name().as_unix_str().to_string(), wt.id())
})
.collect();
sorted.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
if let Some(idx) = sorted.iter().position(|(_, id)| *id == worktree_id) {
let new_active_id = if idx > 0 {
Some(sorted[idx - 1].1)
} else if sorted.len() > 1 {
Some(sorted[1].1)
} else {
None
};
workspace.set_active_worktree_override(new_active_id, cx);
}
}
project.update(cx, |project, cx| {
project.remove_worktree(worktree_id, cx);
});
});
}
}
fn remove_selected_folder(
&mut self,
_: &RemoveSelectedFolder,
window: &mut Window,
cx: &mut Context<Self>,
) {
let selected_index = self.menu.read(cx).selected_index();
if let Some(menu_index) = selected_index {
// Early return because the "Open Folders" header is index 0.
if menu_index == 0 {
return;
}
let entry_index = menu_index - 1;
let worktree_ids = self.worktree_ids.borrow();
if entry_index < worktree_ids.len() {
let worktree_id = worktree_ids[entry_index];
drop(worktree_ids);
Self::handle_remove(self.workspace.clone(), worktree_id, window, cx);
if let Some(menu_entity) = self.menu_shell.borrow().clone() {
menu_entity.update(cx, |menu, cx| {
menu.rebuild(window, cx);
});
}
}
}
}
}
impl Render for ProjectDropdown {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.key_context("MultiProjectDropdown")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::remove_selected_folder))
.child(self.menu.clone())
}
}
impl EventEmitter<DismissEvent> for ProjectDropdown {}
impl Focusable for ProjectDropdown {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.menu.focus_handle(cx)
}
}