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 {