From aaa87a230a3910a48c92ed3b73343ba8c6a6755e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:41:16 -0300 Subject: [PATCH] auto_update_ui: Add announcement toast component (#49543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the `AnnouncementToast` component that we can use whenever a new version introduces a new and special feature that's worthy of a flashier announcement. We can pick the specific version we want to target, and instead of showing the regular "View Release Notes" toast, we'd show this new one instead. For now, everything is turned off as we're not sure yet which version we will be targeting for an upcoming release. Screenshot 2026-02-18 at 9  06@2x Release Notes: - N/A --- Cargo.lock | 1 + crates/auto_update_ui/Cargo.toml | 1 + crates/auto_update_ui/src/auto_update_ui.rs | 164 ++++++++++-- crates/ui/src/components/notification.rs | 2 + .../notification/announcement_toast.rs | 233 ++++++++++++++++++ 5 files changed, 374 insertions(+), 27 deletions(-) create mode 100644 crates/ui/src/components/notification/announcement_toast.rs diff --git a/Cargo.lock b/Cargo.lock index fda16c167163f303c9f2fa55858c8b0f7b4bf61b..e6b8178878125457b679c5e86f5a8f4693492bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,6 +1366,7 @@ dependencies = [ "serde", "serde_json", "smol", + "ui", "util", "workspace", ] diff --git a/crates/auto_update_ui/Cargo.toml b/crates/auto_update_ui/Cargo.toml index 847ea1d441c3e1c5ef30817a964dd113e5d97e42..76d2c7210f68711646758e809825bca73dccdc1d 100644 --- a/crates/auto_update_ui/Cargo.toml +++ b/crates/auto_update_ui/Cargo.toml @@ -23,5 +23,6 @@ semver.workspace = true serde.workspace = true serde_json.workspace = true smol.workspace = true +ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index 6d3f90a2d5efe0ed46179277c3e2141001e03355..853ce4e7a332c5d93cc97ed8596c961ee8ed08a0 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -1,15 +1,22 @@ use auto_update::{AutoUpdater, release_notes_url}; use editor::{Editor, MultiBuffer}; -use gpui::{App, Context, DismissEvent, Entity, Window, actions, prelude::*}; +use gpui::{ + App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*, +}; use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView}; use release_channel::{AppVersion, ReleaseChannel}; +use semver::Version; use serde::Deserialize; use smol::io::AsyncReadExt; +use ui::{AnnouncementToast, ListBulletItem, prelude::*}; use util::{ResultExt as _, maybe}; -use workspace::Workspace; -use workspace::notifications::ErrorMessagePrompt; -use workspace::notifications::simple_message_notification::MessageNotification; -use workspace::notifications::{NotificationId, show_app_notification}; +use workspace::{ + Workspace, + notifications::{ + ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification, + simple_message_notification::MessageNotification, + }, +}; actions!( auto_update, @@ -155,6 +162,94 @@ fn view_release_notes_locally( .detach(); } +#[derive(Clone)] +struct AnnouncementContent { + heading: SharedString, + description: SharedString, + bullet_items: Vec, + primary_action_label: SharedString, + primary_action_url: Option, +} + +fn announcement_for_version(version: &Version) -> Option { + #[allow(clippy::match_single_binding)] + match (version.major, version.minor, version.patch) { + // TODO: Add real version when we have it + // (0, 225, 0) => Some(AnnouncementContent { + // heading: "What's new in Zed 0.225".into(), + // description: "This release includes some exciting improvements.".into(), + // bullet_items: vec![ + // "Improved agent performance".into(), + // "New agentic features".into(), + // "Better agent capabilities".into(), + // ], + // primary_action_label: "Learn More".into(), + // primary_action_url: Some("https://zed.dev/".into()), + // }), + _ => None, + } +} + +struct AnnouncementToastNotification { + focus_handle: FocusHandle, + content: AnnouncementContent, +} + +impl AnnouncementToastNotification { + fn new(content: AnnouncementContent, cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + content, + } + } +} + +impl Focusable for AnnouncementToastNotification { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for AnnouncementToastNotification {} +impl EventEmitter for AnnouncementToastNotification {} +impl Notification for AnnouncementToastNotification {} + +impl Render for AnnouncementToastNotification { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + AnnouncementToast::new() + .heading(self.content.heading.clone()) + .description(self.content.description.clone()) + .bullet_items( + self.content + .bullet_items + .iter() + .map(|item| ListBulletItem::new(item.clone())), + ) + .primary_action_label(self.content.primary_action_label.clone()) + .primary_on_click(cx.listener({ + let url = self.content.primary_action_url.clone(); + move |_, _, _window, cx| { + if let Some(url) = &url { + cx.open_url(url); + } + cx.emit(DismissEvent); + } + })) + .secondary_on_click(cx.listener({ + let url = self.content.primary_action_url.clone(); + move |_, _, _window, cx| { + if let Some(url) = &url { + cx.open_url(url); + } + cx.emit(DismissEvent); + } + })) + .dismiss_on_click(cx.listener(|_, _, _window, cx| { + cx.emit(DismissEvent); + })) + } +} + /// Shows a notification across all workspaces if an update was previously automatically installed /// and this notification had not yet been shown. pub fn notify_if_app_was_updated(cx: &mut App) { @@ -171,35 +266,50 @@ pub fn notify_if_app_was_updated(cx: &mut App) { let should_show_notification = updater.read(cx).should_show_update_notification(cx); cx.spawn(async move |cx| { let should_show_notification = should_show_notification.await?; + // if true { // Hardcode it to true for testing it outside of the component preview if should_show_notification { cx.update(|cx| { let mut version = updater.read(cx).current_version(); version.build = semver::BuildMetadata::EMPTY; version.pre = semver::Prerelease::EMPTY; let app_name = ReleaseChannel::global(cx).display_name(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - let workspace_handle = cx.entity().downgrade(); - cx.new(|cx| { - MessageNotification::new( - format!("Updated to {app_name} {}", version), - cx, - ) - .primary_message("View Release Notes") - .primary_on_click(move |window, cx| { - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(cx, |workspace, cx| { - crate::view_release_notes_locally(workspace, window, cx); - }) - } - cx.emit(DismissEvent); + + if let Some(content) = announcement_for_version(&version) { + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx)) + }, + ); + } else { + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + let workspace_handle = cx.entity().downgrade(); + cx.new(|cx| { + MessageNotification::new( + format!("Updated to {app_name} {}", version), + cx, + ) + .primary_message("View Release Notes") + .primary_on_click(move |window, cx| { + if let Some(workspace) = workspace_handle.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::view_release_notes_locally( + workspace, window, cx, + ); + }) + } + cx.emit(DismissEvent); + }) + .show_suppress_button(false) }) - .show_suppress_button(false) - }) - }, - ); + }, + ); + } + updater.update(cx, |updater, cx| { updater .set_should_show_update_notification(false, cx) diff --git a/crates/ui/src/components/notification.rs b/crates/ui/src/components/notification.rs index 61109550f7d396b23b87f5edd2c3ee12ac044d03..961e8c50a140f23ed2d7e8007399ffa094b07607 100644 --- a/crates/ui/src/components/notification.rs +++ b/crates/ui/src/components/notification.rs @@ -1,3 +1,5 @@ mod alert_modal; +mod announcement_toast; pub use alert_modal::*; +pub use announcement_toast::*; diff --git a/crates/ui/src/components/notification/announcement_toast.rs b/crates/ui/src/components/notification/announcement_toast.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec8495851658f8d1e8b1ac1219c90f645506535f --- /dev/null +++ b/crates/ui/src/components/notification/announcement_toast.rs @@ -0,0 +1,233 @@ +use crate::{ListBulletItem, Vector, VectorName, prelude::*}; +use component::{Component, ComponentScope, example_group, single_example}; +use gpui::{ + AnyElement, ClickEvent, IntoElement, ParentElement, SharedString, linear_color_stop, + linear_gradient, +}; +use smallvec::SmallVec; + +#[derive(IntoElement, RegisterComponent)] +pub struct AnnouncementToast { + illustration: Option, + heading: Option, + description: Option, + bullet_items: SmallVec<[AnyElement; 6]>, + primary_action_label: SharedString, + primary_on_click: Box, + secondary_action_label: SharedString, + secondary_on_click: Box, + dismiss_on_click: Box, +} + +impl AnnouncementToast { + pub fn new() -> Self { + Self { + illustration: None, + heading: None, + description: None, + bullet_items: SmallVec::new(), + primary_action_label: "Learn More".into(), + primary_on_click: Box::new(|_, _, _| {}), + secondary_action_label: "View Release Notes".into(), + secondary_on_click: Box::new(|_, _, _| {}), + dismiss_on_click: Box::new(|_, _, _| {}), + } + } + + pub fn illustration(mut self, illustration: impl IntoElement) -> Self { + self.illustration = Some(illustration.into_any_element()); + self + } + + pub fn heading(mut self, heading: impl Into) -> Self { + self.heading = Some(heading.into()); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn bullet_item(mut self, item: impl IntoElement) -> Self { + self.bullet_items.push(item.into_any_element()); + self + } + + pub fn bullet_items(mut self, items: impl IntoIterator) -> Self { + self.bullet_items + .extend(items.into_iter().map(IntoElement::into_any_element)); + self + } + + pub fn primary_action_label(mut self, primary_action_label: impl Into) -> Self { + self.primary_action_label = primary_action_label.into(); + self + } + + pub fn primary_on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.primary_on_click = Box::new(handler); + self + } + + pub fn secondary_action_label( + mut self, + secondary_action_label: impl Into, + ) -> Self { + self.secondary_action_label = secondary_action_label.into(); + self + } + + pub fn secondary_on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.secondary_on_click = Box::new(handler); + self + } + + pub fn dismiss_on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.dismiss_on_click = Box::new(handler); + self + } +} + +impl RenderOnce for AnnouncementToast { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let has_illustration = self.illustration.is_some(); + let illustration = self.illustration; + + v_flex() + .relative() + .w_full() + .elevation_3(cx) + .when_some(illustration, |this, i| this.child(i)) + .child( + v_flex() + .p_4() + .gap_4() + .when(has_illustration, |s| { + s.border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + v_flex() + .min_w_0() + .when_some(self.heading, |this, heading| { + this.child(Headline::new(heading).size(HeadlineSize::Small)) + }) + .when_some(self.description, |this, description| { + this.child(Label::new(description).color(Color::Muted)) + }), + ) + .when(!self.bullet_items.is_empty(), |this| { + this.child(v_flex().min_w_0().gap_1().children(self.bullet_items)) + }) + .child( + v_flex() + .gap_1() + .child( + Button::new("try-now", self.primary_action_label) + .style(ButtonStyle::Outlined) + .full_width() + .on_click(self.primary_on_click), + ) + .child( + Button::new("release-notes", self.secondary_action_label) + .full_width() + .on_click(self.secondary_on_click), + ), + ), + ) + .child( + div().absolute().top_1().right_1().child( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .on_click(self.dismiss_on_click), + ), + ) + } +} + +impl Component for AnnouncementToast { + fn scope() -> ComponentScope { + ComponentScope::Notification + } + + fn description() -> Option<&'static str> { + Some("A special toast for announcing new and exciting features.") + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(257.), + rems_from_px(47.), + ) + .color(Color::Custom(cx.theme().colors().text.opacity(0.8))), + ); + + let examples = vec![single_example( + "Basic", + div().w_80().child( + AnnouncementToast::new() + .illustration(illustration) + .heading("What's new in Zed") + .description( + "This version comes in with some changes to the workspace for a better experience.", + ) + .bullet_item(ListBulletItem::new("Improved agent performance")) + .bullet_item(ListBulletItem::new("New agentic features")) + .bullet_item(ListBulletItem::new("Better agent capabilities")) + + ) + .into_any_element(), + )]; + + Some( + v_flex() + .gap_6() + .child(example_group(examples).vertical()) + .into_any_element(), + ) + } +}