WIP: git menu

Piotr Osiewicz created

Change summary

crates/collab_ui/src/branch_list.rs          | 178 +++++++++++++++++++++
crates/collab_ui/src/collab_titlebar_item.rs | 123 ++++++++++++--
crates/collab_ui/src/collab_ui.rs            |   2 
crates/fs/src/repository.rs                  |  32 +++
4 files changed, 311 insertions(+), 24 deletions(-)

Detailed changes

crates/collab_ui/src/branch_list.rs 🔗

@@ -0,0 +1,178 @@
+use client::{ContactRequestStatus, User, UserStore};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use project::Project;
+use std::sync::Arc;
+use util::{ResultExt, TryFutureExt};
+
+pub fn init(cx: &mut AppContext) {
+    Picker::<BranchListDelegate>::init(cx);
+}
+
+pub type BranchList = Picker<BranchListDelegate>;
+
+pub fn build_branch_list(
+    project: ModelHandle<Project>,
+    cx: &mut ViewContext<BranchList>,
+) -> BranchList {
+    Picker::new(
+        BranchListDelegate {
+            branches: vec!["Foo".into(), "bar/baz".into()],
+            matches: vec![],
+            project,
+            selected_index: 0,
+        },
+        cx,
+    )
+    .with_theme(|theme| theme.picker.clone())
+}
+
+pub struct BranchListDelegate {
+    branches: Vec<String>,
+    matches: Vec<StringMatch>,
+    project: ModelHandle<Project>,
+    selected_index: usize,
+}
+
+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 candidates = picker
+                .read_with(&mut cx, |view, cx| {
+                    let delegate = view.delegate();
+                    let project = delegate.project.read(&cx);
+                    let mut cwd = project
+                        .visible_worktrees(cx)
+                        .next()
+                        .unwrap()
+                        .read(cx)
+                        .root_entry()
+                        .unwrap()
+                        .path
+                        .to_path_buf();
+                    cwd.push(".git");
+                    let branches = project.fs().open_repo(&cwd).unwrap().lock().branches();
+                    branches
+                        .unwrap()
+                        .iter()
+                        .cloned()
+                        .enumerate()
+                        .map(|(ix, command)| StringMatchCandidate {
+                            id: ix,
+                            string: command.clone(),
+                            char_bag: command.chars().collect(),
+                        })
+                        .collect::<Vec<_>>()
+                })
+                .unwrap();
+            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.branches = actions;
+                    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);
+                    }
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        log::error!("confirm {}", self.selected_index());
+        let current_pick = self.selected_index();
+        let current_pick = self.matches[current_pick].string.clone();
+        log::error!("Hi? {current_pick}");
+        let project = self.project.read(cx);
+        let mut cwd = project
+            .visible_worktrees(cx)
+            .next()
+            .unwrap()
+            .read(cx)
+            .root_entry()
+            .unwrap()
+            .path
+            .to_path_buf();
+        cwd.push(".git");
+        log::error!("{current_pick}");
+        project
+            .fs()
+            .open_repo(&cwd)
+            .unwrap()
+            .lock()
+            .change_branch(&current_pick)
+            .log_err();
+        cx.emit(PickerEvent::Dismiss);
+    }
+
+    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>> {
+        let theme = &theme::current(cx);
+        let user = &self.matches[ix];
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+        Flex::row()
+            .with_child(
+                Label::new(user.string.clone(), style.label.clone())
+                    .with_highlights(user.positions.clone())
+                    .contained()
+                    .aligned()
+                    .left(),
+            )
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(theme.contact_finder.row_height)
+            .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,
 };
@@ -12,12 +15,14 @@ use gpui::{
     actions,
     color::Color,
     elements::*,
+    fonts::TextStyle,
     geometry::{rect::RectF, vector::vec2f, PathBuilder},
     json::{self, ToJson},
     platform::{CursorStyle, MouseButton},
     AppContext, Entity, ImageData, LayoutContext, ModelHandle, SceneBuilder, Subscription, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
+use picker::PickerEvent;
 use project::{Project, RepositoryEntry};
 use std::{ops::Range, sync::Arc};
 use theme::{AvatarStyle, Theme};
@@ -31,6 +36,8 @@ actions!(
     [
         ToggleContactsMenu,
         ToggleUserMenu,
+        ToggleVcsMenu,
+        SwitchBranch,
         ShareProject,
         UnshareProject,
     ]
@@ -41,6 +48,7 @@ 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);
 }
 
 pub struct CollabTitlebarItem {
@@ -49,6 +57,7 @@ pub struct CollabTitlebarItem {
     client: Arc<Client>,
     workspace: WeakViewHandle<Workspace>,
     contacts_popover: Option<ViewHandle<ContactsPopover>>,
+    branch_popover: Option<ViewHandle<BranchList>>,
     user_menu: ViewHandle<ContextMenu>,
     _subscriptions: Vec<Subscription>,
 }
@@ -69,12 +78,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,22 +190,28 @@ impl CollabTitlebarItem {
                 menu.set_position_mode(OverlayPositionMode::Local);
                 menu
             }),
+            branch_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) = {
+            let mut names_and_branches = project.visible_worktrees(cx).map(|worktree| {
+                let worktree = worktree.read(cx);
+                (worktree.root_name(), worktree.root_git_entry())
+            });
 
-        let (name, entry) = names_and_branches.next().unwrap_or(("", None));
+            names_and_branches.next().unwrap_or(("", None))
+        };
+
+        let name = name.to_owned();
         let branch_prepended = entry
             .as_ref()
             .and_then(RepositoryEntry::branch)
@@ -212,22 +226,37 @@ impl CollabTitlebarItem {
             text: text_style,
             highlight_text: Some(highlight),
         };
+        let highlights = (0..name.len()).into_iter().collect();
         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"),
-        );
-        if let Some(git_branch) = branch_prepended {
-            ret = ret.with_child(
-                Label::new(git_branch, style)
+            Stack::new().with_child(
+                Label::new(name, style.clone())
+                    .with_highlights(highlights)
                     .contained()
-                    .with_margin_right(item_spacing)
                     .aligned()
                     .left()
-                    .into_any_named("title-project-branch"),
+                    .into_any_named("title-project-name"),
+            ),
+        );
+        if let Some(git_branch) = branch_prepended {
+            ret = ret.with_child(
+                Stack::new()
+                    .with_child(
+                        MouseEventHandler::<ToggleVcsMenu, Self>::new(0, cx, |state, _| {
+                            Label::new(git_branch, style)
+                                .contained()
+                                .with_margin_right(item_spacing)
+                                .aligned()
+                                .left()
+                                .into_any_named("title-project-branch")
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(MouseButton::Left, move |_, this, cx| {
+                            this.toggle_vcs_menu(&Default::default(), cx)
+                        }),
+                    )
+                    .with_children(
+                        self.render_branches_popover_host(&theme.workspace.titlebar, cx),
+                    ),
             )
         }
         ret.into_any()
@@ -320,7 +349,55 @@ 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, |_, _| {
+                Flex::column()
+                    .with_child(child.flex(1., true))
+                    .contained()
+                    .with_style(theme.contacts_popover.container)
+                    .constrained()
+                    .with_width(theme.contacts_popover.width)
+                    .with_height(theme.contacts_popover.height)
+            })
+            .on_click(MouseButton::Left, |_, _, _| {})
+            .on_down_out(MouseButton::Left, move |_, _, cx| cx.emit(()))
+            .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() {
+            let view = cx.add_view(|cx| build_branch_list(self.project.clone(), cx));
+            cx.subscribe(&view, |this, _, event, cx| {
+                match event {
+                    PickerEvent::Dismiss => {
+                        this.contacts_popover = None;
+                    }
+                }
 
+                cx.notify();
+            })
+            .detach();
+            self.branch_popover = Some(view);
+        }
+
+        cx.notify();
+    }
     fn render_toggle_contacts_button(
         &self,
         theme: &Theme,
@@ -733,7 +810,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/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};
@@ -27,6 +27,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<String>> {
+        Ok(vec![])
+    }
+    fn change_branch(&self, _: &str) -> Result<()> {
+        Ok(())
+    }
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -106,6 +112,30 @@ impl GitRepository for LibGitRepository {
             }
         }
     }
+    fn branches(&self) -> Result<Vec<String>> {
+        let local_branches = self.branches(Some(BranchType::Local))?;
+        let valid_branches = local_branches
+            .filter_map(|branch| {
+                branch
+                    .ok()
+                    .map(|(branch, _)| branch.name().ok().flatten().map(String::from))
+                    .flatten()
+            })
+            .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> {