Detailed changes
@@ -1982,6 +1982,7 @@ dependencies = [
"tree-sitter-markdown",
"ui2",
"util",
+ "vcs_menu2",
"workspace2",
"zed_actions2",
]
@@ -10873,6 +10874,20 @@ dependencies = [
"workspace",
]
+[[package]]
+name = "vcs_menu2"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "fs2",
+ "fuzzy2",
+ "gpui2",
+ "picker2",
+ "ui2",
+ "util",
+ "workspace2",
+]
+
[[package]]
name = "version_check"
version = "0.9.4"
@@ -123,6 +123,7 @@ members = [
"crates/story",
"crates/vim",
"crates/vcs_menu",
+ "crates/vcs_menu2",
"crates/workspace2",
"crates/welcome",
"crates/welcome2",
@@ -47,7 +47,7 @@ settings = { package = "settings2", path = "../settings2" }
feature_flags = { package = "feature_flags2", path = "../feature_flags2"}
theme = { package = "theme2", path = "../theme2" }
# theme_selector = { path = "../theme_selector" }
-# vcs_menu = { path = "../vcs_menu" }
+vcs_menu = { package = "vcs_menu2", path = "../vcs_menu2" }
ui = { package = "ui2", path = "../ui2" }
util = { path = "../util" }
workspace = { package = "workspace2", path = "../workspace2" }
@@ -2,10 +2,10 @@ use crate::face_pile::FacePile;
use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, Client, ParticipantIndex, User, UserStore};
use gpui::{
- actions, canvas, div, overlay, point, px, rems, AppContext, DismissEvent, Div, Element,
- FocusableView, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path, Render,
- Stateful, StatefulInteractiveElement, Styled, Subscription, ViewContext, VisualContext,
- WeakView, WindowBounds,
+ actions, canvas, div, overlay, point, px, rems, AnyElement, AppContext, DismissEvent, Div,
+ Element, FocusableView, Hsla, InteractiveElement, IntoElement, Model, ParentElement, Path,
+ Render, Stateful, StatefulInteractiveElement, Styled, Subscription, View, ViewContext,
+ VisualContext, WeakView, WindowBounds,
};
use project::{Project, RepositoryEntry};
use recent_projects::RecentProjects;
@@ -13,9 +13,10 @@ use std::sync::Arc;
use theme::{ActiveTheme, PlayerColors};
use ui::{
h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
- IconButton, IconElement, KeyBinding, Tooltip,
+ IconButton, IconElement, Tooltip,
};
use util::ResultExt;
+use vcs_menu::{build_branch_list, BranchList, OpenRecent as ToggleVcsMenu};
use workspace::{notifications::NotifyResultExt, Workspace, WORKSPACE_DB};
const MAX_PROJECT_NAME_LENGTH: usize = 40;
@@ -50,7 +51,7 @@ pub struct CollabTitlebarItem {
user_store: Model<UserStore>,
client: Arc<Client>,
workspace: WeakView<Workspace>,
- //branch_popover: Option<ViewHandle<BranchList>>,
+ branch_popover: Option<View<BranchList>>,
project_popover: Option<recent_projects::RecentProjects>,
//user_menu: ViewHandle<ContextMenu>,
_subscriptions: Vec<Subscription>,
@@ -329,7 +330,7 @@ impl CollabTitlebarItem {
// menu.set_position_mode(OverlayPositionMode::Local);
// menu
// }),
- // branch_popover: None,
+ branch_popover: None,
project_popover: None,
_subscriptions: subscriptions,
}
@@ -408,23 +409,25 @@ impl CollabTitlebarItem {
.map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?;
Some(
- div().border().border_color(gpui::red()).child(
- Button::new("project_branch_trigger", branch_name)
- .style(ButtonStyle::Subtle)
- .tooltip(move |cx| {
- cx.build_view(|_| {
- Tooltip::new("Recent Branches")
- .key_binding(KeyBinding::new(gpui::KeyBinding::new(
- "cmd-b",
- // todo!() Replace with real action.
- gpui::NoAction,
- None,
- )))
- .meta("Local branches only")
+ div()
+ .border()
+ .border_color(gpui::red())
+ .child(
+ Button::new("project_branch_trigger", branch_name)
+ .style(ButtonStyle::Subtle)
+ .tooltip(move |cx| {
+ Tooltip::with_meta(
+ "Recent Branches",
+ Some(&ToggleVcsMenu),
+ "Local branches only",
+ cx,
+ )
})
- .into()
- }),
- ),
+ .on_click(
+ cx.listener(|this, _, cx| this.toggle_vcs_menu(&ToggleVcsMenu, cx)),
+ ),
+ )
+ .children(self.render_branches_popover_host()),
)
}
@@ -503,131 +506,34 @@ impl CollabTitlebarItem {
.log_err();
}
- // pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
- // self.user_menu.update(cx, |user_menu, cx| {
- // let items = if let Some(_) = self.user_store.read(cx).current_user() {
- // vec![
- // ContextMenuItem::action("Settings", zed_actions::OpenSettings),
- // ContextMenuItem::action("Theme", theme_selector::Toggle),
- // ContextMenuItem::separator(),
- // ContextMenuItem::action(
- // "Share Feedback",
- // feedback::feedback_editor::GiveFeedback,
- // ),
- // ContextMenuItem::action("Sign Out", SignOut),
- // ]
- // } else {
- // vec![
- // ContextMenuItem::action("Settings", zed_actions::OpenSettings),
- // ContextMenuItem::action("Theme", theme_selector::Toggle),
- // ContextMenuItem::separator(),
- // ContextMenuItem::action(
- // "Share Feedback",
- // feedback::feedback_editor::GiveFeedback,
- // ),
- // ]
- // };
- // 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::new::<BranchList, _>(0, cx, |_, _| {
- // child
- // .flex(1., true)
- // .contained()
- // .constrained()
- // .with_width(theme.titlebar.menu.width)
- // .with_height(theme.titlebar.menu.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::new::<RecentProjects, _>(0, cx, |_, _| {
- // child
- // .flex(1., true)
- // .contained()
- // .constrained()
- // .with_width(theme.titlebar.menu.width)
- // .with_height(theme.titlebar.menu.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 Some(view) =
- // cx.add_option_view(|cx| build_branch_list(workspace, cx).log_err())
- // else {
- // return;
- // };
- // cx.subscribe(&view, |this, _, event, cx| {
- // match event {
- // PickerEvent::Dismiss => {
- // this.branch_popover = None;
- // }
- // }
+ fn render_branches_popover_host<'a>(&'a self) -> Option<AnyElement> {
+ self.branch_popover.as_ref().map(|child| {
+ overlay()
+ .child(div().min_w_64().child(child.clone()))
+ .into_any()
+ })
+ }
- // cx.notify();
- // })
- // .detach();
- // self.project_popover.take();
- // cx.focus(&view);
- // self.branch_popover = Some(view);
- // }
- // }
+ 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() {
+ let Some(view) = build_branch_list(workspace, cx).log_err() else {
+ return;
+ };
+ cx.subscribe(&view, |this, _, _, cx| {
+ this.branch_popover = None;
+ cx.notify();
+ })
+ .detach();
+ self.project_popover.take();
+ let focus_handle = view.focus_handle(cx);
+ cx.focus(&focus_handle);
+ self.branch_popover = Some(view);
+ }
+ }
- // cx.notify();
- // }
+ cx.notify();
+ }
pub fn toggle_project_menu(&mut self, _: &ToggleProjectMenu, cx: &mut ViewContext<Self>) {
let workspace = self.workspace.clone();
@@ -34,7 +34,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
ChatPanelSettings::register(cx);
NotificationPanelSettings::register(cx);
- // vcs_menu::init(cx);
+ vcs_menu::init(cx);
collab_titlebar_item::init(cx);
collab_panel::init(cx);
channel_view::init(cx);
@@ -0,0 +1,17 @@
+[package]
+name = "vcs_menu2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+fuzzy = {package = "fuzzy2", path = "../fuzzy2"}
+fs = {package = "fs2", path = "../fs2"}
+gpui = {package = "gpui2", path = "../gpui2"}
+picker = {package = "picker2", path = "../picker2"}
+util = {path = "../util"}
+ui = {package = "ui2", path = "../ui2"}
+workspace = {package = "workspace2", path = "../workspace2"}
+
+anyhow.workspace = true
@@ -0,0 +1,359 @@
+use anyhow::{anyhow, bail, Result};
+use fs::repository::Branch;
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+ actions, rems, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView,
+ ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext,
+ WindowContext,
+};
+use picker::{Picker, PickerDelegate};
+use std::sync::Arc;
+use ui::{v_stack, HighlightedLabel, ListItem, Selectable};
+use util::ResultExt;
+use workspace::{ModalView, Toast, Workspace};
+
+actions!(branches, [OpenRecent]);
+
+pub fn init(cx: &mut AppContext) {
+ // todo!() po
+ cx.observe_new_views(|workspace: &mut Workspace, _| {
+ workspace.register_action(|workspace, action, cx| {
+ ModalBranchList::toggle_modal(workspace, action, cx).log_err();
+ });
+ })
+ .detach();
+}
+pub type BranchList = Picker<BranchListDelegate>;
+
+pub struct ModalBranchList {
+ pub picker: View<Picker<BranchListDelegate>>,
+}
+
+impl ModalView for ModalBranchList {}
+impl EventEmitter<DismissEvent> for ModalBranchList {}
+
+impl FocusableView for ModalBranchList {
+ fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
+ self.picker.focus_handle(cx)
+ }
+}
+
+impl Render for ModalBranchList {
+ type Element = Div;
+
+ fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
+ v_stack().w(rems(34.)).child(self.picker.clone())
+ }
+}
+
+pub fn build_branch_list(
+ workspace: View<Workspace>,
+ cx: &mut WindowContext<'_>,
+) -> Result<View<BranchList>> {
+ let delegate = workspace.update(cx, |workspace, cx| {
+ BranchListDelegate::new(workspace, cx.view().clone(), 29, cx)
+ })?;
+
+ Ok(cx.build_view(|cx| Picker::new(delegate, cx)))
+}
+
+impl ModalBranchList {
+ fn toggle_modal(
+ workspace: &mut Workspace,
+ _: &OpenRecent,
+ cx: &mut ViewContext<Workspace>,
+ ) -> Result<()> {
+ // Modal branch picker has a longer trailoff than a popover one.
+ let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
+ workspace.toggle_modal(cx, |cx| ModalBranchList {
+ picker: cx.build_view(|cx| Picker::new(delegate, cx)),
+ });
+
+ Ok(())
+ }
+}
+
+pub struct BranchListDelegate {
+ matches: Vec<StringMatch>,
+ all_branches: Vec<Branch>,
+ workspace: View<Workspace>,
+ selected_index: usize,
+ last_query: String,
+ /// Max length of branch name before we truncate it and add a trailing `...`.
+ branch_name_trailoff_after: usize,
+}
+
+impl BranchListDelegate {
+ fn new(
+ workspace: &Workspace,
+ handle: View<Workspace>,
+ branch_name_trailoff_after: usize,
+ cx: &AppContext,
+ ) -> Result<Self> {
+ let project = workspace.project().read(&cx);
+ let Some(worktree) = project.visible_worktrees(cx).next() else {
+ bail!("Cannot update branch list as there are no visible worktrees")
+ };
+
+ let mut cwd = worktree.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 all_branches = repo.lock().branches()?;
+ Ok(Self {
+ matches: vec![],
+ workspace: handle,
+ all_branches,
+ selected_index: 0,
+ last_query: Default::default(),
+ branch_name_trailoff_after,
+ })
+ }
+
+ fn display_error_toast(&self, message: String, cx: &mut ViewContext<BranchList>) {
+ const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
+ self.workspace.update(cx, |model, ctx| {
+ model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
+ });
+ }
+}
+
+impl PickerDelegate for BranchListDelegate {
+ type ListItem = ListItem;
+ 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.update(&mut cx, |view, _| {
+ const RECENT_BRANCHES_COUNT: usize = 10;
+ let mut branches = view.delegate.all_branches.clone();
+ 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));
+ }
+ branches
+ .into_iter()
+ .enumerate()
+ .map(|(ix, command)| StringMatchCandidate {
+ id: ix,
+ char_bag: command.name.chars().collect(),
+ string: command.name.into(),
+ })
+ .collect::<Vec<StringMatchCandidate>>()
+ });
+ 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_executor().clone(),
+ )
+ .await
+ };
+ picker
+ .update(&mut cx, |picker, _| {
+ let delegate = &mut picker.delegate;
+ 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, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+ let current_pick = self.selected_index();
+ let Some(current_pick) = self
+ .matches
+ .get(current_pick)
+ .map(|pick| pick.string.clone())
+ else {
+ return;
+ };
+ 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(¤t_pick);
+ if status.is_err() {
+ this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
+ status?;
+ }
+ cx.emit(DismissEvent);
+
+ Ok::<(), anyhow::Error>(())
+ })
+ .log_err();
+ })
+ .detach();
+ }
+
+ fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _cx: &mut ViewContext<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ let hit = &self.matches[ix];
+ let shortened_branch_name =
+ util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
+ let highlights: Vec<_> = hit
+ .positions
+ .iter()
+ .filter(|index| index < &&self.branch_name_trailoff_after)
+ .copied()
+ .collect();
+ Some(
+ ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
+ .start_slot(HighlightedLabel::new(shortened_branch_name, highlights))
+ .selected(selected),
+ )
+ }
+ // 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())
+ // }
+ // fn render_footer(
+ // &self,
+ // cx: &mut ViewContext<Picker<Self>>,
+ // ) -> Option<AnyElement<Picker<Self>>> {
+ // if !self.last_query.is_empty() {
+ // let theme = &theme::current(cx);
+ // let style = theme.picker.footer.clone();
+ // enum BranchCreateButton {}
+ // Some(
+ // Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
+ // let style = style.style_for(state);
+ // Label::new("Create branch", style.label.clone())
+ // .contained()
+ // .with_style(style.container)
+ // })
+ // .with_cursor_style(CursorStyle::PointingHand)
+ // .on_down(MouseButton::Left, |_, _, cx| {
+ // cx.spawn(|picker, mut cx| async move {
+ // picker.update(&mut cx, |this, cx| {
+ // let project = this.delegate().workspace.read(cx).project().read(cx);
+ // let current_pick = &this.delegate().last_query;
+ // 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 repo = project
+ // .fs()
+ // .open_repo(&cwd)
+ // .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
+ // let repo = repo
+ // .lock();
+ // let status = repo
+ // .create_branch(¤t_pick);
+ // if status.is_err() {
+ // this.delegate().display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
+ // status?;
+ // }
+ // let status = repo.change_branch(¤t_pick);
+ // if status.is_err() {
+ // this.delegate().display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
+ // status?;
+ // }
+ // cx.emit(PickerEvent::Dismiss);
+ // Ok::<(), anyhow::Error>(())
+ // })
+ // }).detach();
+ // })).aligned().right()
+ // .into_any(),
+ // )
+ // } else {
+ // None
+ // }
+ // }
+}