From 9bce30687eb6b6262e7417bb32d4f2549ce1a8ce Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:36:53 +0100 Subject: [PATCH] Vcs menu2 (#3648) Header and footer are gonna be added in a separate PR as they require changes to Picker trait that I feel are separate from the contents of this PR. Release Notes: - N/A --- Cargo.lock | 15 + Cargo.toml | 1 + crates/collab_ui2/Cargo.toml | 2 +- crates/collab_ui2/src/collab_titlebar_item.rs | 198 +++------- crates/collab_ui2/src/collab_ui.rs | 2 +- crates/vcs_menu2/Cargo.toml | 17 + crates/vcs_menu2/src/lib.rs | 359 ++++++++++++++++++ 7 files changed, 446 insertions(+), 148 deletions(-) create mode 100644 crates/vcs_menu2/Cargo.toml create mode 100644 crates/vcs_menu2/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 52fadb73e1904bdaf9b59adef9582fa22fdc9aea..4281553aff5f7100dc91be770eeda88bad9825ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 95cf2ae78c7cb31909e841c6ee54f769f5cd60e7..3b453527b89dca285b303c9ddd89d5bcc6792310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,6 +123,7 @@ members = [ "crates/story", "crates/vim", "crates/vcs_menu", + "crates/vcs_menu2", "crates/workspace2", "crates/welcome", "crates/welcome2", diff --git a/crates/collab_ui2/Cargo.toml b/crates/collab_ui2/Cargo.toml index 65aced8e7edf1e522a97d44cd4dcbdf04536f348..9d84d7f887c56b77df18c237d697be063f745091 100644 --- a/crates/collab_ui2/Cargo.toml +++ b/crates/collab_ui2/Cargo.toml @@ -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" } diff --git a/crates/collab_ui2/src/collab_titlebar_item.rs b/crates/collab_ui2/src/collab_titlebar_item.rs index 3d8fedd06bb363c4efacd4e6458b6e796a366f94..5b3d4c0942f30b33332f3be7913244eec8cd1dd9 100644 --- a/crates/collab_ui2/src/collab_titlebar_item.rs +++ b/crates/collab_ui2/src/collab_titlebar_item.rs @@ -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, client: Arc, workspace: WeakView, - //branch_popover: Option>, + branch_popover: Option>, project_popover: Option, //user_menu: ViewHandle, _subscriptions: Vec, @@ -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.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, - // ) -> Option> { - // self.branch_popover.as_ref().map(|child| { - // let theme = theme::current(cx).clone(); - // let child = ChildView::new(child, cx); - // let child = MouseEventHandler::new::(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, - // ) -> Option> { - // self.project_popover.as_ref().map(|child| { - // let theme = theme::current(cx).clone(); - // let child = ChildView::new(child, cx); - // let child = MouseEventHandler::new::(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) { - // 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 { + 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) { + 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) { let workspace = self.workspace.clone(); diff --git a/crates/collab_ui2/src/collab_ui.rs b/crates/collab_ui2/src/collab_ui.rs index df81af3e5797a5b001d37d3eccb461611cf6224c..6b81998a8adf0828f93e4cb74bed1aee78b61054 100644 --- a/crates/collab_ui2/src/collab_ui.rs +++ b/crates/collab_ui2/src/collab_ui.rs @@ -34,7 +34,7 @@ pub fn init(app_state: &Arc, 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); diff --git a/crates/vcs_menu2/Cargo.toml b/crates/vcs_menu2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..bc9d76a7dc7e50ae52e4bbefcd93d8103ec5e9a3 --- /dev/null +++ b/crates/vcs_menu2/Cargo.toml @@ -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 diff --git a/crates/vcs_menu2/src/lib.rs b/crates/vcs_menu2/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..e867e04dcdb96229eb7513f891d60cc31ce1ec26 --- /dev/null +++ b/crates/vcs_menu2/src/lib.rs @@ -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; + +pub struct ModalBranchList { + pub picker: View>, +} + +impl ModalView for ModalBranchList {} +impl EventEmitter 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::Element { + v_stack().w(rems(34.)).child(self.picker.clone()) + } +} + +pub fn build_branch_list( + workspace: View, + cx: &mut WindowContext<'_>, +) -> Result> { + 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, + ) -> 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, + all_branches: Vec, + workspace: View, + 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, + branch_name_trailoff_after: usize, + cx: &AppContext, + ) -> Result { + 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) { + 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 { + "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>) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> 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::>() + }); + 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>) { + 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>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + 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>, + // ) -> Option>> { + // 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>, + // ) -> Option>> { + // 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::(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 + // } + // } +}