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(), + ) + } +}