diff --git a/Cargo.lock b/Cargo.lock index e90baba51731723782f5f9cba7c550dafc5eab7a..ed04063d10576fb8a0864b1ac0964e54c36abd64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5450,7 +5450,9 @@ dependencies = [ "askpass", "assistant_settings", "buffer_diff", + "chrono", "collections", + "command_palette_hooks", "component", "ctor", "db", @@ -13984,6 +13986,7 @@ dependencies = [ "client", "collections", "feature_flags", + "git_ui", "gpui", "http_client", "notifications", diff --git a/assets/icons/git_onboarding_bg.svg b/assets/icons/git_onboarding_bg.svg new file mode 100644 index 0000000000000000000000000000000000000000..18da0230a26c4e67b7e3ac2e64894132102f93c6 --- /dev/null +++ b/assets/icons/git_onboarding_bg.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 4d7d300d19ef7d65d531abd7fcf44f4628726f68..e0be28e20b1a4385b802648be781a2fab10ad9ea 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -21,7 +21,9 @@ anyhow.workspace = true askpass.workspace = true assistant_settings.workspace = true buffer_diff.workspace = true +chrono.workspace = true collections.workspace = true +command_palette_hooks.workspace = true component.workspace = true db.workspace = true editor.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 48c1eb19e7c6a0b1e2f8e13e9088d031e5ad92a3..28ce505ab3bac8e15b95b5126f43a89f1fd2be81 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -702,7 +702,10 @@ impl GitPanel { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("GitPanel"); - if self.is_focused(window, cx) { + if window + .focused(cx) + .map_or(false, |focused| self.focus_handle == focused) + { dispatch_context.add("menu"); dispatch_context.add("ChangesList"); } @@ -714,12 +717,6 @@ impl GitPanel { dispatch_context } - fn is_focused(&self, window: &Window, cx: &Context) -> bool { - window - .focused(cx) - .map_or(false, |focused| self.focus_handle == focused) - } - fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context) { cx.emit(PanelEvent::Close); } @@ -3811,8 +3808,12 @@ impl Render for GitPanel { } impl Focusable for GitPanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + if self.entries.is_empty() { + self.commit_editor.focus_handle(cx) + } else { + self.focus_handle.clone() + } } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 5e10289f1c1e8cdd2cefcb01d1d9437881d95891..27499edeea7fb789774cef14d239353b0c301ce1 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,10 +1,14 @@ +use std::any::Any; + use ::settings::Settings; +use command_palette_hooks::CommandPaletteFilter; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use git_panel_settings::GitPanelSettings; -use gpui::{App, Entity, FocusHandle}; +use gpui::{actions, App, Entity, FocusHandle}; +use onboarding::{clear_dismissed, GitOnboardingModal}; use project::Project; use project_diff::ProjectDiff; use ui::prelude::*; @@ -15,11 +19,14 @@ pub mod branch_picker; mod commit_modal; pub mod git_panel; mod git_panel_settings; +pub mod onboarding; pub mod picker_prompt; pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; +actions!(git, [ResetOnboarding]); + pub fn init(cx: &mut App) { GitPanelSettings::register(cx); branch_picker::init(cx); @@ -82,6 +89,21 @@ pub fn init(cx: &mut App) { panel.unstage_all(action, window, cx); }); }); + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&[ + zed_actions::OpenGitIntegrationOnboarding.type_id(), + // ResetOnboarding.type_id(), + ]); + }); + workspace.register_action( + move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| { + GitOnboardingModal::toggle(workspace, window, cx) + }, + ); + workspace.register_action(move |_, _: &ResetOnboarding, window, cx| { + clear_dismissed(cx); + window.refresh(); + }); workspace.register_action(|workspace, _action: &git::Init, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; diff --git a/crates/git_ui/src/onboarding.rs b/crates/git_ui/src/onboarding.rs new file mode 100644 index 0000000000000000000000000000000000000000..68a4a3f42070033f8539f66e9ddf634a0c7a3b8f --- /dev/null +++ b/crates/git_ui/src/onboarding.rs @@ -0,0 +1,267 @@ +use gpui::{ + svg, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global, + MouseDownEvent, Render, +}; +use ui::{prelude::*, ButtonLike, TintColor, Tooltip}; +use util::ResultExt; +use workspace::{ModalView, Workspace}; + +use crate::git_panel::GitPanel; + +macro_rules! git_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "Git Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "Git Onboarding", $($key $(= $value)?),+); + }; +} + +/// Introduces user to the Git Panel and overall improved Git support +pub struct GitOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl GitOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + + cx.emit(DismissEvent); + + git_onboarding_event!("Open Panel Clicked"); + } + + fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url("https://zed.dev/blog/git"); + cx.notify(); + + git_onboarding_event!("Blog Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for GitOnboardingModal {} + +impl Focusable for GitOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for GitOnboardingModal {} + +impl Render for GitOnboardingModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_height = window.viewport_size().height; + let max_height = window_height - px(200.); + + let base = v_flex() + .id("git-onboarding") + .key_context("GitOnboardingModal") + .relative() + .w(px(450.)) + .h_full() + .max_h(max_height) + .p_4() + .gap_2() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + git_onboarding_event!("Cancelled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child( + div().p_1p5().absolute().inset_0().h(px(160.)).child( + svg() + .path("icons/git_onboarding_bg.svg") + .text_color(cx.theme().colors().icon_disabled) + .w(px(420.)) + .h(px(128.)) + .overflow_hidden(), + ), + ) + .child( + v_flex() + .w_full() + .gap_1() + .child( + Label::new("Introducing") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Native Git Support").size(HeadlineSize::Large)), + ) + .child(h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::X).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + git_onboarding_event!("Cancelled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + )); + + let open_panel_button = Button::new("open-panel", "Get Started with the Git Panel") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let blog_post_button = Button::new("view-blog", "Check out the Blog Post") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_blog)); + + let copy = "First-class support for staging, committing, pulling, pushing, viewing diffs, and more. All without leaving Zed."; + + base.child(Label::new(copy).color(Color::Muted)).child( + v_flex() + .w_full() + .mt_2() + .gap_2() + .child(open_panel_button) + .child(blog_post_button), + ) + } +} + +/// Prompts the user to try Zed's git features +pub struct GitBanner { + dismissed: bool, +} + +#[derive(Clone)] +struct GitBannerGlobal(Entity); +impl Global for GitBannerGlobal {} + +impl GitBanner { + pub fn new(cx: &mut Context) -> Self { + cx.set_global(GitBannerGlobal(cx.entity())); + Self { + dismissed: get_dismissed(), + } + } + + fn should_show(&self, _cx: &mut App) -> bool { + !self.dismissed + } + + fn dismiss(&mut self, cx: &mut Context) { + git_onboarding_event!("Banner Dismissed"); + persist_dismissed(cx); + self.dismissed = true; + cx.notify(); + } +} + +const DISMISSED_AT_KEY: &str = "zed_git_banner_dismissed_at"; + +fn get_dismissed() -> bool { + db::kvp::KEY_VALUE_STORE + .read_kvp(DISMISSED_AT_KEY) + .log_err() + .map_or(false, |dismissed| dismissed.is_some()) +} + +fn persist_dismissed(cx: &mut App) { + cx.spawn(|_| { + let time = chrono::Utc::now().to_rfc3339(); + db::kvp::KEY_VALUE_STORE.write_kvp(DISMISSED_AT_KEY.into(), time) + }) + .detach_and_log_err(cx); +} + +pub(crate) fn clear_dismissed(cx: &mut App) { + cx.defer(|cx| { + cx.global::() + .clone() + .0 + .update(cx, |this, cx| { + this.dismissed = false; + cx.notify(); + }); + }); + + cx.spawn(|_| db::kvp::KEY_VALUE_STORE.delete_kvp(DISMISSED_AT_KEY.into())) + .detach_and_log_err(cx); +} + +impl Render for GitBanner { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.should_show(cx) { + return div(); + } + + let border_color = cx.theme().colors().editor_foreground.opacity(0.3); + let banner = h_flex() + .rounded_sm() + .border_1() + .border_color(border_color) + .child( + ButtonLike::new("try-git") + .child( + h_flex() + .h_full() + .items_center() + .gap_1() + .child(Icon::new(IconName::GitBranchSmall).size(IconSize::Small)) + .child( + h_flex() + .gap_0p5() + .child( + Label::new("Introducing:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new("Git Support").size(LabelSize::Small)), + ), + ) + .on_click(cx.listener(|this, _, window, cx| { + git_onboarding_event!("Banner Clicked"); + this.dismiss(cx); + window.dispatch_action( + Box::new(zed_actions::OpenGitIntegrationOnboarding), + cx, + ) + })), + ) + .child( + div().border_l_1().border_color(border_color).child( + IconButton::new("close", IconName::Close) + .icon_size(IconSize::Indicator) + .on_click(cx.listener(|this, _, _window, cx| this.dismiss(cx))) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Close Announcement Banner", + None, + "It won't show again for this feature", + window, + cx, + ) + }), + ), + ); + + div().pr_2().child(banner) + } +} diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 7b642aa7a79bec9fc2a9c31a6bd6e02c2e270c56..f5265bdc4f6b381604c1913236e930a439a6a0dd 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -48,6 +48,7 @@ util.workspace = true workspace.workspace = true zed_actions.workspace = true zeta.workspace = true +git_ui.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index de514d1339a91e4064af725d30951d142c889354..6045c7a5b17852d597818fa65fe4ef551065c7ed 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -18,6 +18,7 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore}; use feature_flags::{FeatureFlagAppExt, ZedPro}; +use git_ui::onboarding::GitBanner; use gpui::{ actions, div, px, Action, AnyElement, App, Context, Decorations, Element, Entity, InteractiveElement, Interactivity, IntoElement, MouseButton, ParentElement, Render, Stateful, @@ -126,6 +127,7 @@ pub struct TitleBar { application_menu: Option>, _subscriptions: Vec, zed_predict_banner: Entity, + git_banner: Entity, } impl Render for TitleBar { @@ -210,6 +212,7 @@ impl Render for TitleBar { ) .child(self.render_collaborator_list(window, cx)) .child(self.zed_predict_banner.clone()) + .child(self.git_banner.clone()) .child( h_flex() .gap_1() @@ -313,6 +316,7 @@ impl TitleBar { subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify())); let zed_predict_banner = cx.new(ZedPredictBanner::new); + let git_banner = cx.new(GitBanner::new); Self { platform_style, @@ -326,6 +330,7 @@ impl TitleBar { client, _subscriptions: subscriptions, zed_predict_banner, + git_banner, } } diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index d32582b5b561fe476c1b641a3005d908b7d533f2..3c6af8c8382fd9419f2438e81b42c3828ebb0258 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -262,3 +262,4 @@ pub mod outline { } actions!(zed_predict_onboarding, [OpenZedPredictOnboarding]); +actions!(git_onboarding, [OpenGitIntegrationOnboarding]);