Cargo.lock 🔗
@@ -1366,6 +1366,7 @@ dependencies = [
"serde",
"serde_json",
"smol",
+ "ui",
"util",
"workspace",
]
Danilo Leal created
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.
<img width="550" height="1964" alt="Screenshot 2026-02-18 at 9 06@2x"
src="https://github.com/user-attachments/assets/19930b15-261b-416f-992e-6e28447beb27"
/>
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
crates/ui/src/components/notification/announcement_toast.rs | 233 +++++++
5 files changed, 374 insertions(+), 27 deletions(-)
@@ -1366,6 +1366,7 @@ dependencies = [
"serde",
"serde_json",
"smol",
+ "ui",
"util",
"workspace",
]
@@ -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
@@ -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<SharedString>,
+ primary_action_label: SharedString,
+ primary_action_url: Option<SharedString>,
+}
+
+fn announcement_for_version(version: &Version) -> Option<AnnouncementContent> {
+ #[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<DismissEvent> for AnnouncementToastNotification {}
+impl EventEmitter<SuppressEvent> for AnnouncementToastNotification {}
+impl Notification for AnnouncementToastNotification {}
+
+impl Render for AnnouncementToastNotification {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<UpdateNotification>(),
- 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::<UpdateNotification>(),
+ cx,
+ move |cx| {
+ cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx))
+ },
+ );
+ } else {
+ show_app_notification(
+ NotificationId::unique::<UpdateNotification>(),
+ 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)
@@ -1,3 +1,5 @@
mod alert_modal;
+mod announcement_toast;
pub use alert_modal::*;
+pub use announcement_toast::*;
@@ -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<AnyElement>,
+ heading: Option<SharedString>,
+ description: Option<SharedString>,
+ bullet_items: SmallVec<[AnyElement; 6]>,
+ primary_action_label: SharedString,
+ primary_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
+ secondary_action_label: SharedString,
+ secondary_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
+ dismiss_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
+}
+
+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<SharedString>) -> Self {
+ self.heading = Some(heading.into());
+ self
+ }
+
+ pub fn description(mut self, description: impl Into<SharedString>) -> 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<Item = impl IntoElement>) -> 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<SharedString>) -> 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<SharedString>,
+ ) -> 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<AnyElement> {
+ 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(),
+ )
+ }
+}