Make a single re-usable banner component (#27412)

Mikayla Maki created

Release Notes:

- Fixed an issue where both the predict edit and git onboarding banners
would both show at the same time.

Change summary

Cargo.lock                           |   7 
crates/git_ui/Cargo.toml             |   1 
crates/git_ui/src/git_ui.rs          |   4 
crates/git_ui/src/onboarding.rs      | 134 ----------------------------
crates/title_bar/Cargo.toml          |   4 
crates/title_bar/src/banner.rs       | 132 ++++++++++++++++++++++++++++
crates/title_bar/src/title_bar.rs    |  44 +++++++--
crates/workspace/src/workspace.rs    |   1 
crates/zed/Cargo.toml                |   1 
crates/zed/src/zed.rs                |   4 
crates/zeta/Cargo.toml               |   1 
crates/zeta/src/init.rs              |   2 
crates/zeta/src/onboarding_banner.rs | 138 ------------------------------
crates/zeta/src/zeta.rs              |   2 
14 files changed, 181 insertions(+), 294 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5636,7 +5636,6 @@ dependencies = [
  "askpass",
  "assistant_settings",
  "buffer_diff",
- "chrono",
  "collections",
  "command_palette_hooks",
  "component",
@@ -14195,10 +14194,11 @@ version = "0.1.0"
 dependencies = [
  "auto_update",
  "call",
+ "chrono",
  "client",
  "collections",
+ "db",
  "feature_flags",
- "git_ui",
  "gpui",
  "http_client",
  "notifications",
@@ -14219,7 +14219,6 @@ dependencies = [
  "windows 0.61.1",
  "workspace",
  "zed_actions",
- "zeta",
 ]
 
 [[package]]
@@ -17371,6 +17370,7 @@ dependencies = [
  "theme_extension",
  "theme_selector",
  "time",
+ "title_bar",
  "toolchain_selector",
  "tree-sitter-md",
  "tree-sitter-rust",
@@ -17629,7 +17629,6 @@ dependencies = [
  "anyhow",
  "arrayvec",
  "call",
- "chrono",
  "client",
  "clock",
  "collections",

crates/git_ui/Cargo.toml 🔗

@@ -21,7 +21,6 @@ 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

crates/git_ui/src/git_ui.rs 🔗

@@ -9,7 +9,7 @@ use git::{
 };
 use git_panel_settings::GitPanelSettings;
 use gpui::{actions, App, FocusHandle};
-use onboarding::{clear_dismissed, GitOnboardingModal};
+use onboarding::GitOnboardingModal;
 use project_diff::ProjectDiff;
 use ui::prelude::*;
 use workspace::Workspace;
@@ -103,7 +103,7 @@ pub fn init(cx: &mut App) {
             },
         );
         workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
-            clear_dismissed(cx);
+            cx.dispatch_action(&workspace::RestoreBanner);
             window.refresh();
         });
         workspace.register_action(|workspace, _action: &git::Init, window, cx| {

crates/git_ui/src/onboarding.rs 🔗

@@ -1,9 +1,8 @@
 use gpui::{
-    svg, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
-    MouseDownEvent, Render,
+    svg, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
+    Render,
 };
-use ui::{prelude::*, ButtonLike, TintColor, Tooltip};
-use util::ResultExt;
+use ui::{prelude::*, TintColor};
 use workspace::{ModalView, Workspace};
 
 use crate::git_panel::GitPanel;
@@ -144,130 +143,3 @@ impl Render for GitOnboardingModal {
         )
     }
 }
-
-/// Prompts the user to try Zed's git features
-pub struct GitBanner {
-    dismissed: bool,
-}
-
-#[derive(Clone)]
-struct GitBannerGlobal(Entity<GitBanner>);
-impl Global for GitBannerGlobal {}
-
-impl GitBanner {
-    pub fn new(cx: &mut Context<Self>) -> 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<Self>) {
-        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(async |_| {
-        let time = chrono::Utc::now().to_rfc3339();
-        db::kvp::KEY_VALUE_STORE
-            .write_kvp(DISMISSED_AT_KEY.into(), time)
-            .await
-    })
-    .detach_and_log_err(cx);
-}
-
-pub(crate) fn clear_dismissed(cx: &mut App) {
-    cx.defer(|cx| {
-        cx.global::<GitBannerGlobal>()
-            .clone()
-            .0
-            .update(cx, |this, cx| {
-                this.dismissed = false;
-                cx.notify();
-            });
-    });
-
-    cx.spawn(async |_| {
-        db::kvp::KEY_VALUE_STORE
-            .delete_kvp(DISMISSED_AT_KEY.into())
-            .await
-    })
-    .detach_and_log_err(cx);
-}
-
-impl Render for GitBanner {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)
-    }
-}

crates/title_bar/Cargo.toml 🔗

@@ -29,7 +29,9 @@ test-support = [
 [dependencies]
 auto_update.workspace = true
 call.workspace = true
+chrono.workspace = true
 client.workspace = true
+db.workspace = true
 feature_flags.workspace = true
 gpui.workspace = true
 notifications.workspace = true
@@ -47,8 +49,6 @@ ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
-zeta.workspace = true
-git_ui.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/title_bar/src/banner.rs 🔗

@@ -0,0 +1,132 @@
+use gpui::{Action, Entity, Global, Render};
+use ui::{prelude::*, ButtonLike, Tooltip};
+use util::ResultExt;
+
+/// Prompts the user to try Zed's features
+pub struct Banner {
+    dismissed: bool,
+    source: String,
+    details: BannerDetails,
+}
+
+#[derive(Clone)]
+struct BannerGlobal {
+    entity: Entity<Banner>,
+}
+impl Global for BannerGlobal {}
+
+pub struct BannerDetails {
+    pub action: Box<dyn Action>,
+    pub banner_label: Box<dyn Fn(&Window, &mut Context<Banner>) -> AnyElement>,
+}
+
+impl Banner {
+    pub fn new(source: &str, details: BannerDetails, cx: &mut Context<Self>) -> Self {
+        cx.set_global(BannerGlobal {
+            entity: cx.entity(),
+        });
+        Self {
+            source: source.to_string(),
+            details,
+            dismissed: get_dismissed(source),
+        }
+    }
+
+    fn should_show(&self, _cx: &mut App) -> bool {
+        !self.dismissed
+    }
+
+    fn dismiss(&mut self, cx: &mut Context<Self>) {
+        telemetry::event!("Banner Dismissed", source = self.source);
+        persist_dismissed(&self.source, cx);
+        self.dismissed = true;
+        cx.notify();
+    }
+}
+
+fn dismissed_at_key(source: &str) -> String {
+    format!(
+        "{}_{}",
+        "_banner_dismissed_at",
+        source.to_lowercase().trim().replace(" ", "_")
+    )
+}
+
+fn get_dismissed(source: &str) -> bool {
+    let dismissed_at = if source == "Git Onboarding" {
+        "zed_git_banner_dismissed_at".to_string()
+    } else {
+        dismissed_at_key(source)
+    };
+    db::kvp::KEY_VALUE_STORE
+        .read_kvp(&dismissed_at)
+        .log_err()
+        .map_or(false, |dismissed| dismissed.is_some())
+}
+
+fn persist_dismissed(source: &str, cx: &mut App) {
+    let dismissed_at = dismissed_at_key(source);
+    cx.spawn(async |_| {
+        let time = chrono::Utc::now().to_rfc3339();
+        db::kvp::KEY_VALUE_STORE.write_kvp(dismissed_at, time).await
+    })
+    .detach_and_log_err(cx);
+}
+
+pub fn restore_banner(cx: &mut App) {
+    cx.defer(|cx| {
+        cx.global::<BannerGlobal>()
+            .entity
+            .clone()
+            .update(cx, |this, cx| {
+                this.dismissed = false;
+                cx.notify();
+            });
+    });
+
+    let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
+    let dismissed_at = dismissed_at_key(source);
+    cx.spawn(async |_| db::kvp::KEY_VALUE_STORE.delete_kvp(dismissed_at).await)
+        .detach_and_log_err(cx);
+}
+
+impl Render for Banner {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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-a-feature")
+                    .child((self.details.banner_label)(window, cx))
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        telemetry::event!("Banner Clicked", source = this.source);
+                        this.dismiss(cx);
+                        window.dispatch_action(this.details.action.boxed_clone(), 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)
+    }
+}

crates/title_bar/src/title_bar.rs 🔗

@@ -1,4 +1,5 @@
 mod application_menu;
+mod banner;
 mod collab;
 mod platforms;
 mod window_controls;
@@ -15,10 +16,10 @@ use crate::application_menu::{
 
 use crate::platforms::{platform_linux, platform_mac, platform_windows};
 use auto_update::AutoUpdateStatus;
+use banner::{Banner, BannerDetails};
 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,
@@ -37,7 +38,8 @@ use ui::{
 use util::ResultExt;
 use workspace::{notifications::NotifyResultExt, Workspace};
 use zed_actions::{OpenBrowser, OpenRecent, OpenRemote};
-use zeta::ZedPredictBanner;
+
+pub use banner::restore_banner;
 
 #[cfg(feature = "stories")]
 pub use stories::*;
@@ -126,8 +128,7 @@ pub struct TitleBar {
     should_move: bool,
     application_menu: Option<Entity<ApplicationMenu>>,
     _subscriptions: Vec<Subscription>,
-    zed_predict_banner: Entity<ZedPredictBanner>,
-    git_banner: Entity<GitBanner>,
+    banner: Entity<Banner>,
 }
 
 impl Render for TitleBar {
@@ -211,8 +212,7 @@ impl Render for TitleBar {
                             .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()),
                     )
                     .child(self.render_collaborator_list(window, cx))
-                    .child(self.zed_predict_banner.clone())
-                    .child(self.git_banner.clone())
+                    .child(self.banner.clone())
                     .child(
                         h_flex()
                             .gap_1()
@@ -315,8 +315,33 @@ impl TitleBar {
         subscriptions.push(cx.observe_window_activation(window, Self::window_activation_changed));
         subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
 
-        let zed_predict_banner = cx.new(ZedPredictBanner::new);
-        let git_banner = cx.new(GitBanner::new);
+        let banner = cx.new(|cx| {
+            Banner::new(
+                "Git Onboarding",
+                BannerDetails {
+                    action: zed_actions::OpenGitIntegrationOnboarding.boxed_clone(),
+                    banner_label: Box::new(|_, _| {
+                        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)),
+                            )
+                            .into_any_element()
+                    }),
+                },
+                cx,
+            )
+        });
 
         Self {
             platform_style,
@@ -329,8 +354,7 @@ impl TitleBar {
             user_store,
             client,
             _subscriptions: subscriptions,
-            zed_predict_banner,
-            git_banner,
+            banner,
         }
     }
 

crates/zed/Cargo.toml 🔗

@@ -122,6 +122,7 @@ theme.workspace = true
 theme_extension.workspace = true
 theme_selector.workspace = true
 time.workspace = true
+title_bar.workspace = true
 toolchain_selector.workspace = true
 ui.workspace = true
 ui_prompt.workspace = true

crates/zed/src/zed.rs 🔗

@@ -65,12 +65,12 @@ use uuid::Uuid;
 use vim_mode_setting::VimModeSetting;
 use welcome::{BaseKeymap, MultibufferHint};
 use workspace::notifications::{dismiss_app_notification, show_app_notification, NotificationId};
-use workspace::CloseIntent;
 use workspace::{
     create_and_open_local_file, notifications::simple_message_notification::MessageNotification,
     open_new, AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings,
 };
 use workspace::{notifications::DetachAndPromptErr, Pane};
+use workspace::{CloseIntent, RestoreBanner};
 use zed_actions::{
     OpenAccountSettings, OpenBrowser, OpenServerSettings, OpenSettings, OpenZedUrl, Quit,
 };
@@ -105,6 +105,8 @@ pub fn init(cx: &mut App) {
     cx.on_action(|_: &ShowAll, cx| cx.unhide_other_apps());
     cx.on_action(quit);
 
+    cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
+
     if ReleaseChannel::global(cx) == ReleaseChannel::Dev {
         cx.on_action(test_panic);
     }

crates/zeta/Cargo.toml 🔗

@@ -19,7 +19,6 @@ test-support = []
 [dependencies]
 anyhow.workspace = true
 arrayvec.workspace = true
-chrono.workspace = true
 client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true

crates/zeta/src/init.rs 🔗

@@ -43,8 +43,6 @@ pub fn init(cx: &mut App) {
                         .edit_prediction_provider = Some(EditPredictionProvider::None)
                 },
             );
-
-            crate::onboarding_banner::clear_dismissed(cx);
         });
     })
     .detach();

crates/zeta/src/onboarding_banner.rs 🔗

@@ -1,138 +0,0 @@
-use chrono::Utc;
-use feature_flags::{FeatureFlagAppExt as _, PredictEditsFeatureFlag};
-use gpui::Subscription;
-use language::language_settings::{all_language_settings, EditPredictionProvider};
-use settings::SettingsStore;
-use ui::{prelude::*, ButtonLike, Tooltip};
-use util::ResultExt;
-
-use crate::onboarding_event;
-
-/// Prompts the user to try Zed's Edit Prediction feature
-pub struct ZedPredictBanner {
-    dismissed: bool,
-    provider: EditPredictionProvider,
-    _subscription: Subscription,
-}
-
-impl ZedPredictBanner {
-    pub fn new(cx: &mut Context<Self>) -> Self {
-        Self {
-            dismissed: get_dismissed(),
-            provider: all_language_settings(None, cx).edit_predictions.provider,
-            _subscription: cx.observe_global::<SettingsStore>(Self::handle_settings_changed),
-        }
-    }
-
-    fn should_show(&self, cx: &mut App) -> bool {
-        cx.has_flag::<PredictEditsFeatureFlag>() && !self.dismissed && !self.provider.is_zed()
-    }
-
-    fn handle_settings_changed(&mut self, cx: &mut Context<Self>) {
-        let new_provider = all_language_settings(None, cx).edit_predictions.provider;
-
-        if new_provider == self.provider {
-            return;
-        }
-
-        if new_provider.is_zed() {
-            self.dismiss(cx);
-        } else {
-            self.dismissed = get_dismissed();
-        }
-
-        self.provider = new_provider;
-        cx.notify();
-    }
-
-    fn dismiss(&mut self, cx: &mut Context<Self>) {
-        onboarding_event!("Banner Dismissed");
-        persist_dismissed(cx);
-        self.dismissed = true;
-        cx.notify();
-    }
-}
-
-const DISMISSED_AT_KEY: &str = "zed_predict_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(async |_| {
-        let time = Utc::now().to_rfc3339();
-        db::kvp::KEY_VALUE_STORE
-            .write_kvp(DISMISSED_AT_KEY.into(), time)
-            .await
-    })
-    .detach_and_log_err(cx);
-}
-
-pub(crate) fn clear_dismissed(cx: &mut App) {
-    cx.spawn(async |_| {
-        db::kvp::KEY_VALUE_STORE
-            .delete_kvp(DISMISSED_AT_KEY.into())
-            .await
-    })
-    .detach_and_log_err(cx);
-}
-
-impl Render for ZedPredictBanner {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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-zed-predict")
-                    .child(
-                        h_flex()
-                            .h_full()
-                            .items_center()
-                            .gap_1p5()
-                            .child(Icon::new(IconName::ZedPredict).size(IconSize::Small))
-                            .child(
-                                h_flex()
-                                    .gap_0p5()
-                                    .child(
-                                        Label::new("Introducing:")
-                                            .size(LabelSize::Small)
-                                            .color(Color::Muted),
-                                    )
-                                    .child(Label::new("Edit Prediction").size(LabelSize::Small)),
-                            ),
-                    )
-                    .on_click(|_, window, cx| {
-                        onboarding_event!("Banner Clicked");
-                        window.dispatch_action(Box::new(zed_actions::OpenZedPredictOnboarding), 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)
-    }
-}

crates/zeta/src/zeta.rs 🔗

@@ -2,7 +2,6 @@ mod completion_diff_element;
 mod init;
 mod input_excerpt;
 mod license_detection;
-mod onboarding_banner;
 mod onboarding_modal;
 mod onboarding_telemetry;
 mod rate_completion_modal;
@@ -13,7 +12,6 @@ pub use init::*;
 use inline_completion::DataCollectionState;
 pub use license_detection::is_license_eligible_for_data_collection;
 use license_detection::LICENSE_FILES_TO_CHECK;
-pub use onboarding_banner::*;
 pub use rate_completion_modal::*;
 
 use anyhow::{anyhow, Context as _, Result};