Z-2276/Z-2275: Project & Branch switchers (#2662)

Nate Butler created

This PR adds project and branch switchers in top left corner. 

Release Notes:
- Added a project & branch switcher under project name.

Change summary

Cargo.lock                                    |   3 
crates/collab_ui/Cargo.toml                   |   2 
crates/collab_ui/src/branch_list.rs           | 238 +++++++++++++++++++
crates/collab_ui/src/collab_titlebar_item.rs  | 253 ++++++++++++++++++--
crates/collab_ui/src/collab_ui.rs             |   2 
crates/fs/Cargo.toml                          |   1 
crates/fs/src/repository.rs                   |  48 +++
crates/gpui/src/app.rs                        |  14 
crates/gpui/src/app/window.rs                 |  38 +-
crates/picker/src/picker.rs                   |  10 
crates/recent_projects/Cargo.toml             |   1 
crates/recent_projects/src/recent_projects.rs |  22 +
crates/theme/src/theme.rs                     |   7 
styles/src/style_tree/contact_finder.ts       |   2 
styles/src/style_tree/picker.ts               |  18 +
styles/src/style_tree/titlebar.ts             |  10 
16 files changed, 601 insertions(+), 68 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1484,6 +1484,7 @@ dependencies = [
  "picker",
  "postage",
  "project",
+ "recent_projects",
  "serde",
  "serde_derive",
  "settings",
@@ -2691,6 +2692,7 @@ dependencies = [
  "smol",
  "sum_tree",
  "tempfile",
+ "time 0.3.21",
  "util",
 ]
 
@@ -5703,6 +5705,7 @@ version = "0.1.0"
 dependencies = [
  "db",
  "editor",
+ "futures 0.3.28",
  "fuzzy",
  "gpui",
  "language",

crates/collab_ui/Cargo.toml 🔗

@@ -35,6 +35,7 @@ gpui = { path = "../gpui" }
 menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
+recent_projects = {path = "../recent_projects"}
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
@@ -42,6 +43,7 @@ util = { path = "../util" }
 workspace = { path = "../workspace" }
 zed-actions = {path = "../zed-actions"}
 
+
 anyhow.workspace = true
 futures.workspace = true
 log.workspace = true

crates/collab_ui/src/branch_list.rs 🔗

@@ -0,0 +1,238 @@
+use anyhow::{anyhow, bail};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{elements::*, AppContext, MouseState, Task, ViewContext, ViewHandle};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use std::{ops::Not, sync::Arc};
+use util::ResultExt;
+use workspace::{Toast, Workspace};
+
+pub fn init(cx: &mut AppContext) {
+    Picker::<BranchListDelegate>::init(cx);
+}
+
+pub type BranchList = Picker<BranchListDelegate>;
+
+pub fn build_branch_list(
+    workspace: ViewHandle<Workspace>,
+    cx: &mut ViewContext<BranchList>,
+) -> BranchList {
+    Picker::new(
+        BranchListDelegate {
+            matches: vec![],
+            workspace,
+            selected_index: 0,
+            last_query: String::default(),
+        },
+        cx,
+    )
+    .with_theme(|theme| theme.picker.clone())
+}
+
+pub struct BranchListDelegate {
+    matches: Vec<StringMatch>,
+    workspace: ViewHandle<Workspace>,
+    selected_index: usize,
+    last_query: String,
+}
+
+impl PickerDelegate for BranchListDelegate {
+    fn placeholder_text(&self) -> Arc<str> {
+        "Select branch...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        cx.spawn(move |picker, mut cx| async move {
+            let Some(candidates) = picker
+                .read_with(&mut cx, |view, cx| {
+                    let delegate = view.delegate();
+                    let project = delegate.workspace.read(cx).project().read(&cx);
+                    let mut cwd =
+                    project
+                        .visible_worktrees(cx)
+                        .next()
+                        .unwrap()
+                        .read(cx)
+                        .abs_path()
+                        .to_path_buf();
+                    cwd.push(".git");
+                    let Some(repo) = project.fs().open_repo(&cwd) else {bail!("Project does not have associated git repository.")};
+                    let mut branches = repo
+                        .lock()
+                        .branches()?;
+                    const RECENT_BRANCHES_COUNT: usize = 10;
+                    if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
+                        // Truncate list of recent branches
+                        // Do a partial sort to show recent-ish branches first.
+                        branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
+                            rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
+                        });
+                        branches.truncate(RECENT_BRANCHES_COUNT);
+                        branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
+                    }
+                    Ok(branches
+                        .iter()
+                        .cloned()
+                        .enumerate()
+                        .map(|(ix, command)| StringMatchCandidate {
+                            id: ix,
+                            char_bag: command.name.chars().collect(),
+                            string: command.name.into(),
+                        })
+                        .collect::<Vec<_>>())
+                })
+                .log_err() else { return; };
+            let Some(candidates) = candidates.log_err() else {return;};
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    true,
+                    10000,
+                    &Default::default(),
+                    cx.background(),
+                )
+                .await
+            };
+            picker
+                .update(&mut cx, |picker, _| {
+                    let delegate = picker.delegate_mut();
+                    delegate.matches = matches;
+                    if delegate.matches.is_empty() {
+                        delegate.selected_index = 0;
+                    } else {
+                        delegate.selected_index =
+                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
+                    }
+                    delegate.last_query = query;
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        let current_pick = self.selected_index();
+        let current_pick = self.matches[current_pick].string.clone();
+        cx.spawn(|picker, mut cx| async move {
+            picker.update(&mut cx, |this, cx| {
+                let project = this.delegate().workspace.read(cx).project().read(cx);
+                let mut cwd = project
+                .visible_worktrees(cx)
+                .next()
+                .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
+                .read(cx)
+                .abs_path()
+                .to_path_buf();
+                cwd.push(".git");
+                let status = project
+                    .fs()
+                    .open_repo(&cwd)
+                    .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?
+                    .lock()
+                    .change_branch(&current_pick);
+                if status.is_err() {
+                    const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
+                    this.delegate().workspace.update(cx, |model, ctx| {
+                        model.show_toast(
+                            Toast::new(
+                                GIT_CHECKOUT_FAILURE_ID,
+                                format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"),
+                            ),
+                            ctx,
+                        )
+                    });
+                    status?;
+                }
+                cx.emit(PickerEvent::Dismiss);
+
+                Ok::<(), anyhow::Error>(())
+            }).log_err();
+        }).detach();
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<Picker<Self>> {
+        const DISPLAYED_MATCH_LEN: usize = 29;
+        let theme = &theme::current(cx);
+        let hit = &self.matches[ix];
+        let shortened_branch_name = util::truncate_and_trailoff(&hit.string, DISPLAYED_MATCH_LEN);
+        let highlights = hit
+            .positions
+            .iter()
+            .copied()
+            .filter(|index| index < &DISPLAYED_MATCH_LEN)
+            .collect();
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+        Flex::row()
+            .with_child(
+                Label::new(shortened_branch_name.clone(), style.label.clone())
+                    .with_highlights(highlights)
+                    .contained()
+                    .aligned()
+                    .left(),
+            )
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(theme.contact_finder.row_height)
+            .into_any()
+    }
+    fn render_header(
+        &self,
+        cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<AnyElement<Picker<Self>>> {
+        let theme = &theme::current(cx);
+        let style = theme.picker.header.clone();
+        let label = if self.last_query.is_empty() {
+            Flex::row()
+                .with_child(Label::new("Recent branches", style.label.clone()))
+                .contained()
+                .with_style(style.container)
+        } else {
+            Flex::row()
+                .with_child(Label::new("Branches", style.label.clone()))
+                .with_children(self.matches.is_empty().not().then(|| {
+                    let suffix = if self.matches.len() == 1 { "" } else { "es" };
+                    Label::new(
+                        format!("{} match{}", self.matches.len(), suffix),
+                        style.label,
+                    )
+                    .flex_float()
+                }))
+                .contained()
+                .with_style(style.container)
+        };
+        Some(label.into_any())
+    }
+}

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -1,5 +1,8 @@
 use crate::{
-    contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
+    branch_list::{build_branch_list, BranchList},
+    contact_notification::ContactNotification,
+    contacts_popover,
+    face_pile::FacePile,
     toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute,
     ToggleScreenSharing,
 };
@@ -18,19 +21,25 @@ use gpui::{
     AppContext, Entity, ImageData, LayoutContext, ModelHandle, SceneBuilder, Subscription, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
+use picker::PickerEvent;
 use project::{Project, RepositoryEntry};
+use recent_projects::{build_recent_projects, RecentProjects};
 use std::{ops::Range, sync::Arc};
 use theme::{AvatarStyle, Theme};
 use util::ResultExt;
-use workspace::{FollowNextCollaborator, Workspace};
+use workspace::{FollowNextCollaborator, Workspace, WORKSPACE_DB};
 
-// const MAX_TITLE_LENGTH: usize = 75;
+const MAX_PROJECT_NAME_LENGTH: usize = 40;
+const MAX_BRANCH_NAME_LENGTH: usize = 40;
 
 actions!(
     collab,
     [
         ToggleContactsMenu,
         ToggleUserMenu,
+        ToggleVcsMenu,
+        ToggleProjectMenu,
+        SwitchBranch,
         ShareProject,
         UnshareProject,
     ]
@@ -41,6 +50,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(CollabTitlebarItem::share_project);
     cx.add_action(CollabTitlebarItem::unshare_project);
     cx.add_action(CollabTitlebarItem::toggle_user_menu);
+    cx.add_action(CollabTitlebarItem::toggle_vcs_menu);
+    cx.add_action(CollabTitlebarItem::toggle_project_menu);
 }
 
 pub struct CollabTitlebarItem {
@@ -49,6 +60,8 @@ pub struct CollabTitlebarItem {
     client: Arc<Client>,
     workspace: WeakViewHandle<Workspace>,
     contacts_popover: Option<ViewHandle<ContactsPopover>>,
+    branch_popover: Option<ViewHandle<BranchList>>,
+    project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
     user_menu: ViewHandle<ContextMenu>,
     _subscriptions: Vec<Subscription>,
 }
@@ -69,12 +82,11 @@ impl View for CollabTitlebarItem {
             return Empty::new().into_any();
         };
 
-        let project = self.project.read(cx);
         let theme = theme::current(cx).clone();
         let mut left_container = Flex::row();
         let mut right_container = Flex::row().align_children_center();
 
-        left_container.add_child(self.collect_title_root_names(&project, theme.clone(), cx));
+        left_container.add_child(self.collect_title_root_names(theme.clone(), cx));
 
         let user = self.user_store.read(cx).current_user();
         let peer_id = self.client.peer_id();
@@ -182,52 +194,97 @@ impl CollabTitlebarItem {
                 menu.set_position_mode(OverlayPositionMode::Local);
                 menu
             }),
+            branch_popover: None,
+            project_popover: None,
             _subscriptions: subscriptions,
         }
     }
 
     fn collect_title_root_names(
         &self,
-        project: &Project,
         theme: Arc<Theme>,
-        cx: &ViewContext<Self>,
+        cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
-        let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
-            let worktree = worktree.read(cx);
-            (worktree.root_name(), worktree.root_git_entry())
-        });
+        let project = self.project.read(cx);
 
-        let (name, entry) = names_and_branches.next().unwrap_or(("", None));
+        let (name, entry) = {
+            let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
+                let worktree = worktree.read(cx);
+                (worktree.root_name(), worktree.root_git_entry())
+            });
+
+            names_and_branches.next().unwrap_or(("", None))
+        };
+
+        let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH);
         let branch_prepended = entry
             .as_ref()
             .and_then(RepositoryEntry::branch)
-            .map(|branch| format!("/{branch}"));
-        let text_style = theme.titlebar.title.clone();
+            .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH));
+        let project_style = theme.titlebar.project_menu_button.clone();
+        let git_style = theme.titlebar.git_menu_button.clone();
+        let divider_style = theme.titlebar.project_name_divider.clone();
         let item_spacing = theme.titlebar.item_spacing;
 
-        let mut highlight = text_style.clone();
-        highlight.color = theme.titlebar.highlight_color;
-
-        let style = LabelStyle {
-            text: text_style,
-            highlight_text: Some(highlight),
-        };
         let mut ret = Flex::row().with_child(
-            Label::new(name.to_owned(), style.clone())
-                .with_highlights((0..name.len()).into_iter().collect())
-                .contained()
-                .aligned()
-                .left()
-                .into_any_named("title-project-name"),
+            Stack::new()
+                .with_child(
+                    MouseEventHandler::<ToggleProjectMenu, Self>::new(0, cx, |mouse_state, _| {
+                        let style = project_style
+                            .in_state(self.project_popover.is_some())
+                            .style_for(mouse_state);
+                        Label::new(name, style.text.clone())
+                            .contained()
+                            .with_style(style.container)
+                            .aligned()
+                            .left()
+                            .into_any_named("title-project-name")
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_down(MouseButton::Left, move |_, this, cx| {
+                        this.toggle_project_menu(&Default::default(), cx)
+                    })
+                    .on_click(MouseButton::Left, move |_, _, _| {}),
+                )
+                .with_children(self.render_project_popover_host(&theme.titlebar, cx)),
         );
         if let Some(git_branch) = branch_prepended {
             ret = ret.with_child(
-                Label::new(git_branch, style)
-                    .contained()
-                    .with_margin_right(item_spacing)
-                    .aligned()
-                    .left()
-                    .into_any_named("title-project-branch"),
+                Flex::row()
+                    .with_child(
+                        Label::new("/", divider_style.text)
+                            .contained()
+                            .with_style(divider_style.container)
+                            .aligned()
+                            .left(),
+                    )
+                    .with_child(
+                        Stack::new()
+                            .with_child(
+                                MouseEventHandler::<ToggleVcsMenu, Self>::new(
+                                    0,
+                                    cx,
+                                    |mouse_state, _| {
+                                        let style = git_style
+                                            .in_state(self.branch_popover.is_some())
+                                            .style_for(mouse_state);
+                                        Label::new(git_branch, style.text.clone())
+                                            .contained()
+                                            .with_style(style.container.clone())
+                                            .with_margin_right(item_spacing)
+                                            .aligned()
+                                            .left()
+                                            .into_any_named("title-project-branch")
+                                    },
+                                )
+                                .with_cursor_style(CursorStyle::PointingHand)
+                                .on_down(MouseButton::Left, move |_, this, cx| {
+                                    this.toggle_vcs_menu(&Default::default(), cx)
+                                })
+                                .on_click(MouseButton::Left, move |_, _, _| {}),
+                            )
+                            .with_children(self.render_branches_popover_host(&theme.titlebar, cx)),
+                    ),
             )
         }
         ret.into_any()
@@ -320,7 +377,135 @@ impl CollabTitlebarItem {
             user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
         });
     }
+    fn render_branches_popover_host<'a>(
+        &'a self,
+        _theme: &'a theme::Titlebar,
+        cx: &'a mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        self.branch_popover.as_ref().map(|child| {
+            let theme = theme::current(cx).clone();
+            let child = ChildView::new(child, cx);
+            let child = MouseEventHandler::<BranchList, Self>::new(0, cx, |_, _| {
+                child
+                    .flex(1., true)
+                    .contained()
+                    .constrained()
+                    .with_width(theme.contacts_popover.width)
+                    .with_height(theme.contacts_popover.height)
+            })
+            .on_click(MouseButton::Left, |_, _, _| {})
+            .on_down_out(MouseButton::Left, move |_, this, cx| {
+                this.branch_popover.take();
+                cx.emit(());
+                cx.notify();
+            })
+            .contained()
+            .into_any();
 
+            Overlay::new(child)
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::TopLeft)
+                .with_z_index(999)
+                .aligned()
+                .bottom()
+                .left()
+                .into_any()
+        })
+    }
+    fn render_project_popover_host<'a>(
+        &'a self,
+        _theme: &'a theme::Titlebar,
+        cx: &'a mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        self.project_popover.as_ref().map(|child| {
+            let theme = theme::current(cx).clone();
+            let child = ChildView::new(child, cx);
+            let child = MouseEventHandler::<RecentProjects, Self>::new(0, cx, |_, _| {
+                child
+                    .flex(1., true)
+                    .contained()
+                    .constrained()
+                    .with_width(theme.contacts_popover.width)
+                    .with_height(theme.contacts_popover.height)
+            })
+            .on_click(MouseButton::Left, |_, _, _| {})
+            .on_down_out(MouseButton::Left, move |_, this, cx| {
+                this.project_popover.take();
+                cx.emit(());
+                cx.notify();
+            })
+            .into_any();
+
+            Overlay::new(child)
+                .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                .with_anchor_corner(AnchorCorner::TopLeft)
+                .with_z_index(999)
+                .aligned()
+                .bottom()
+                .left()
+                .into_any()
+        })
+    }
+    pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
+        if self.branch_popover.take().is_none() {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                let view = cx.add_view(|cx| build_branch_list(workspace, cx));
+                cx.subscribe(&view, |this, _, event, cx| {
+                    match event {
+                        PickerEvent::Dismiss => {
+                            this.branch_popover = None;
+                        }
+                    }
+
+                    cx.notify();
+                })
+                .detach();
+                self.project_popover.take();
+                cx.focus(&view);
+                self.branch_popover = Some(view);
+            }
+        }
+
+        cx.notify();
+    }
+
+    pub fn toggle_project_menu(&mut self, _: &ToggleProjectMenu, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.clone();
+        if self.project_popover.take().is_none() {
+            cx.spawn(|this, mut cx| async move {
+                let workspaces = WORKSPACE_DB
+                    .recent_workspaces_on_disk()
+                    .await
+                    .unwrap_or_default()
+                    .into_iter()
+                    .map(|(_, location)| location)
+                    .collect();
+
+                let workspace = workspace.clone();
+                this.update(&mut cx, move |this, cx| {
+                    let view = cx.add_view(|cx| build_recent_projects(workspace, workspaces, cx));
+
+                    cx.subscribe(&view, |this, _, event, cx| {
+                        match event {
+                            PickerEvent::Dismiss => {
+                                this.project_popover = None;
+                            }
+                        }
+
+                        cx.notify();
+                    })
+                    .detach();
+                    cx.focus(&view);
+                    this.branch_popover.take();
+                    this.project_popover = Some(view);
+                    cx.notify();
+                })
+                .log_err();
+            })
+            .detach();
+        }
+        cx.notify();
+    }
     fn render_toggle_contacts_button(
         &self,
         theme: &Theme,
@@ -733,7 +918,7 @@ impl CollabTitlebarItem {
         self.contacts_popover.as_ref().map(|popover| {
             Overlay::new(ChildView::new(popover, cx))
                 .with_fit_mode(OverlayFitMode::SwitchAnchor)
-                .with_anchor_corner(AnchorCorner::TopRight)
+                .with_anchor_corner(AnchorCorner::TopLeft)
                 .with_z_index(999)
                 .aligned()
                 .bottom()

crates/collab_ui/src/collab_ui.rs 🔗

@@ -1,3 +1,4 @@
+mod branch_list;
 mod collab_titlebar_item;
 mod contact_finder;
 mod contact_list;
@@ -28,6 +29,7 @@ actions!(
 );
 
 pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+    branch_list::init(cx);
     collab_titlebar_item::init(cx);
     contact_list::init(cx);
     contact_finder::init(cx);

crates/fs/Cargo.toml 🔗

@@ -31,6 +31,7 @@ serde_derive.workspace = true
 serde_json.workspace = true
 log.workspace = true
 libc = "0.2"
+time.workspace = true
 
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }

crates/fs/src/repository.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::Result;
 use collections::HashMap;
-use git2::ErrorCode;
+use git2::{BranchType, ErrorCode};
 use parking_lot::Mutex;
 use rpc::proto;
 use serde_derive::{Deserialize, Serialize};
@@ -16,6 +16,12 @@ use util::ResultExt;
 
 pub use git2::Repository as LibGitRepository;
 
+#[derive(Clone, Debug, Hash, PartialEq)]
+pub struct Branch {
+    pub name: Box<str>,
+    /// Timestamp of most recent commit, normalized to Unix Epoch format.
+    pub unix_timestamp: Option<i64>,
+}
 #[async_trait::async_trait]
 pub trait GitRepository: Send {
     fn reload_index(&self);
@@ -27,6 +33,12 @@ pub trait GitRepository: Send {
     fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>>;
 
     fn status(&self, path: &RepoPath) -> Result<Option<GitFileStatus>>;
+    fn branches(&self) -> Result<Vec<Branch>> {
+        Ok(vec![])
+    }
+    fn change_branch(&self, _: &str) -> Result<()> {
+        Ok(())
+    }
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -106,6 +118,40 @@ impl GitRepository for LibGitRepository {
             }
         }
     }
+    fn branches(&self) -> Result<Vec<Branch>> {
+        let local_branches = self.branches(Some(BranchType::Local))?;
+        let valid_branches = local_branches
+            .filter_map(|branch| {
+                branch.ok().and_then(|(branch, _)| {
+                    let name = branch.name().ok().flatten().map(Box::from)?;
+                    let timestamp = branch.get().peel_to_commit().ok()?.time();
+                    let unix_timestamp = timestamp.seconds();
+                    let timezone_offset = timestamp.offset_minutes();
+                    let utc_offset =
+                        time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
+                    let unix_timestamp =
+                        time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
+                    Some(Branch {
+                        name,
+                        unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
+                    })
+                })
+            })
+            .collect();
+        Ok(valid_branches)
+    }
+    fn change_branch(&self, name: &str) -> Result<()> {
+        let revision = self.find_branch(name, BranchType::Local)?;
+        let revision = revision.get();
+        let as_tree = revision.peel_to_tree()?;
+        self.checkout_tree(as_tree.as_object(), None)?;
+        self.set_head(
+            revision
+                .name()
+                .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
+        )?;
+        Ok(())
+    }
 }
 
 fn read_status(status: git2::Status) -> Option<GitFileStatus> {

crates/gpui/src/app.rs 🔗

@@ -3301,11 +3301,15 @@ impl<'a, 'b, V: View> ViewContext<'a, 'b, V> {
         let region_id = MouseRegionId::new::<Tag>(self.view_id, region_id);
         MouseState {
             hovered: self.window.hovered_region_ids.contains(&region_id),
-            clicked: self
-                .window
-                .clicked_region_ids
-                .get(&region_id)
-                .and_then(|_| self.window.clicked_button),
+            clicked: if let Some((clicked_region_id, button)) = self.window.clicked_region {
+                if region_id == clicked_region_id {
+                    Some(button)
+                } else {
+                    None
+                }
+            } else {
+                None
+            },
             accessed_hovered: false,
             accessed_clicked: false,
         }

crates/gpui/src/app/window.rs 🔗

@@ -53,7 +53,7 @@ pub struct Window {
     last_mouse_moved_event: Option<Event>,
     pub(crate) hovered_region_ids: HashSet<MouseRegionId>,
     pub(crate) clicked_region_ids: HashSet<MouseRegionId>,
-    pub(crate) clicked_button: Option<MouseButton>,
+    pub(crate) clicked_region: Option<(MouseRegionId, MouseButton)>,
     mouse_position: Vector2F,
     text_layout_cache: TextLayoutCache,
 }
@@ -86,7 +86,7 @@ impl Window {
             last_mouse_moved_event: None,
             hovered_region_ids: Default::default(),
             clicked_region_ids: Default::default(),
-            clicked_button: None,
+            clicked_region: None,
             mouse_position: vec2f(0., 0.),
             titlebar_height,
             appearance,
@@ -484,8 +484,8 @@ impl<'a> WindowContext<'a> {
                 // specific ancestor element that contained both [positions]'
                 // So we need to store the overlapping regions on mouse down.
 
-                // If there is already clicked_button stored, don't replace it.
-                if self.window.clicked_button.is_none() {
+                // If there is already region being clicked, don't replace it.
+                if self.window.clicked_region.is_none() {
                     self.window.clicked_region_ids = self
                         .window
                         .mouse_regions
@@ -499,7 +499,17 @@ impl<'a> WindowContext<'a> {
                         })
                         .collect();
 
-                    self.window.clicked_button = Some(e.button);
+                    let mut highest_z_index = 0;
+                    let mut clicked_region_id = None;
+                    for (region, z_index) in self.window.mouse_regions.iter() {
+                        if region.bounds.contains_point(e.position) && *z_index >= highest_z_index {
+                            highest_z_index = *z_index;
+                            clicked_region_id = Some(region.id());
+                        }
+                    }
+
+                    self.window.clicked_region =
+                        clicked_region_id.map(|region_id| (region_id, e.button));
                 }
 
                 mouse_events.push(MouseEvent::Down(MouseDown {
@@ -564,7 +574,7 @@ impl<'a> WindowContext<'a> {
                             prev_mouse_position: self.window.mouse_position,
                             platform_event: e.clone(),
                         }));
-                    } else if let Some(clicked_button) = self.window.clicked_button {
+                    } else if let Some((_, clicked_button)) = self.window.clicked_region {
                         // Mouse up event happened outside the current window. Simulate mouse up button event
                         let button_event = e.to_button_event(clicked_button);
                         mouse_events.push(MouseEvent::Up(MouseUp {
@@ -687,8 +697,8 @@ impl<'a> WindowContext<'a> {
                     // Only raise click events if the released button is the same as the one stored
                     if self
                         .window
-                        .clicked_button
-                        .map(|clicked_button| clicked_button == e.button)
+                        .clicked_region
+                        .map(|(_, clicked_button)| clicked_button == e.button)
                         .unwrap_or(false)
                     {
                         // Clear clicked regions and clicked button
@@ -696,7 +706,7 @@ impl<'a> WindowContext<'a> {
                             &mut self.window.clicked_region_ids,
                             Default::default(),
                         );
-                        self.window.clicked_button = None;
+                        self.window.clicked_region = None;
 
                         // Find regions which still overlap with the mouse since the last MouseDown happened
                         for (mouse_region, _) in self.window.mouse_regions.iter().rev() {
@@ -871,18 +881,10 @@ impl<'a> WindowContext<'a> {
         }
         for view_id in &invalidation.updated {
             let titlebar_height = self.window.titlebar_height;
-            let hovered_region_ids = self.window.hovered_region_ids.clone();
-            let clicked_region_ids = self
-                .window
-                .clicked_button
-                .map(|button| (self.window.clicked_region_ids.clone(), button));
-
             let element = self
                 .render_view(RenderParams {
                     view_id: *view_id,
                     titlebar_height,
-                    hovered_region_ids,
-                    clicked_region_ids,
                     refreshing: false,
                     appearance,
                 })
@@ -1191,8 +1193,6 @@ impl<'a> WindowContext<'a> {
 pub struct RenderParams {
     pub view_id: usize,
     pub titlebar_height: f32,
-    pub hovered_region_ids: HashSet<MouseRegionId>,
-    pub clicked_region_ids: Option<(HashSet<MouseRegionId>, MouseButton)>,
     pub refreshing: bool,
     pub appearance: Appearance,
 }

crates/picker/src/picker.rs 🔗

@@ -46,10 +46,16 @@ pub trait PickerDelegate: Sized + 'static {
     fn center_selection_after_match_updates(&self) -> bool {
         false
     }
-    fn render_header(&self, _cx: &AppContext) -> Option<AnyElement<Picker<Self>>> {
+    fn render_header(
+        &self,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<AnyElement<Picker<Self>>> {
         None
     }
-    fn render_footer(&self, _cx: &AppContext) -> Option<AnyElement<Picker<Self>>> {
+    fn render_footer(
+        &self,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<AnyElement<Picker<Self>>> {
         None
     }
 }

crates/recent_projects/Cargo.toml 🔗

@@ -21,6 +21,7 @@ util = { path = "../util"}
 theme = { path = "../theme" }
 workspace = { path = "../workspace" }
 
+futures.workspace = true
 ordered-float.workspace = true
 postage.workspace = true
 smol.workspace = true

crates/recent_projects/src/recent_projects.rs 🔗

@@ -48,7 +48,7 @@ fn toggle(
                     let workspace = cx.weak_handle();
                     cx.add_view(|cx| {
                         RecentProjects::new(
-                            RecentProjectsDelegate::new(workspace, workspace_locations),
+                            RecentProjectsDelegate::new(workspace, workspace_locations, true),
                             cx,
                         )
                         .with_max_size(800., 1200.)
@@ -64,25 +64,40 @@ fn toggle(
     }))
 }
 
-type RecentProjects = Picker<RecentProjectsDelegate>;
+pub fn build_recent_projects(
+    workspace: WeakViewHandle<Workspace>,
+    workspaces: Vec<WorkspaceLocation>,
+    cx: &mut ViewContext<RecentProjects>,
+) -> RecentProjects {
+    Picker::new(
+        RecentProjectsDelegate::new(workspace, workspaces, false),
+        cx,
+    )
+    .with_theme(|theme| theme.picker.clone())
+}
+
+pub type RecentProjects = Picker<RecentProjectsDelegate>;
 
-struct RecentProjectsDelegate {
+pub struct RecentProjectsDelegate {
     workspace: WeakViewHandle<Workspace>,
     workspace_locations: Vec<WorkspaceLocation>,
     selected_match_index: usize,
     matches: Vec<StringMatch>,
+    render_paths: bool,
 }
 
 impl RecentProjectsDelegate {
     fn new(
         workspace: WeakViewHandle<Workspace>,
         workspace_locations: Vec<WorkspaceLocation>,
+        render_paths: bool,
     ) -> Self {
         Self {
             workspace,
             workspace_locations,
             selected_match_index: 0,
             matches: Default::default(),
+            render_paths,
         }
     }
 }
@@ -188,6 +203,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                 highlighted_location
                     .paths
                     .into_iter()
+                    .filter(|_| self.render_paths)
                     .map(|highlighted_path| highlighted_path.render(style.label.clone())),
             )
             .flex(1., false)

crates/theme/src/theme.rs 🔗

@@ -117,8 +117,9 @@ pub struct Titlebar {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub height: f32,
-    pub title: TextStyle,
-    pub highlight_color: Color,
+    pub project_menu_button: Toggleable<Interactive<ContainedText>>,
+    pub project_name_divider: ContainedText,
+    pub git_menu_button: Toggleable<Interactive<ContainedText>>,
     pub item_spacing: f32,
     pub face_pile_spacing: f32,
     pub avatar_ribbon: AvatarRibbon,
@@ -584,6 +585,8 @@ pub struct Picker {
     pub empty_input_editor: FieldEditor,
     pub no_matches: ContainedLabel,
     pub item: Toggleable<Interactive<ContainedLabel>>,
+    pub header: ContainedLabel,
+    pub footer: ContainedLabel,
 }
 
 #[derive(Clone, Debug, Deserialize, Default, JsonSchema)]

styles/src/style_tree/contact_finder.ts 🔗

@@ -46,6 +46,8 @@ export default function contact_finder(): any {
             no_matches: picker_style.no_matches,
             input_editor: picker_input,
             empty_input_editor: picker_input,
+            header: picker_style.header,
+            footer: picker_style.footer,
         },
         row_height: 28,
         contact_avatar: {

styles/src/style_tree/picker.ts 🔗

@@ -110,5 +110,23 @@ export default function picker(): any {
                 top: 8,
             },
         },
+        header: {
+            text: text(theme.lowest, "sans", "variant", { size: "xs" }),
+
+            margin: {
+                top: 1,
+                left: 8,
+                right: 8,
+            },
+        },
+        footer: {
+            text: text(theme.lowest, "sans", "variant", { size: "xs" }),
+            margin: {
+                top: 1,
+                left: 8,
+                right: 8,
+            },
+
+        }
     }
 }

styles/src/style_tree/titlebar.ts 🔗

@@ -179,8 +179,14 @@ export function titlebar(): any {
         },
 
         // Project
-        title: text(theme.lowest, "sans", "variant"),
-        highlight_color: text(theme.lowest, "sans", "active").color,
+        project_name_divider: text(theme.lowest, "sans", "variant"),
+
+        project_menu_button: toggleable_text_button(theme, {
+            color: 'base',
+        }),
+        git_menu_button: toggleable_text_button(theme, {
+            color: 'variant',
+        }),
 
         // Collaborators
         leader_avatar: {