auto_update_ui.rs

  1use auto_update::{AutoUpdater, release_notes_url};
  2use editor::{Editor, MultiBuffer};
  3use gpui::{
  4    App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*,
  5};
  6use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
  7use release_channel::{AppVersion, ReleaseChannel};
  8use semver::Version;
  9use serde::Deserialize;
 10use smol::io::AsyncReadExt;
 11use ui::{AnnouncementToast, ListBulletItem, prelude::*};
 12use util::{ResultExt as _, maybe};
 13use workspace::{
 14    Workspace,
 15    notifications::{
 16        ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification,
 17        simple_message_notification::MessageNotification,
 18    },
 19};
 20
 21actions!(
 22    auto_update,
 23    [
 24        /// Opens the release notes for the current version in a new tab.
 25        ViewReleaseNotesLocally
 26    ]
 27);
 28
 29pub fn init(cx: &mut App) {
 30    notify_if_app_was_updated(cx);
 31    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 32        workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, window, cx| {
 33            view_release_notes_locally(workspace, window, cx);
 34        });
 35    })
 36    .detach();
 37}
 38
 39#[derive(Deserialize)]
 40struct ReleaseNotesBody {
 41    title: String,
 42    release_notes: String,
 43}
 44
 45fn notify_release_notes_failed_to_show(
 46    workspace: &mut Workspace,
 47    _window: &mut Window,
 48    cx: &mut Context<Workspace>,
 49) {
 50    struct ViewReleaseNotesError;
 51    workspace.show_notification(
 52        NotificationId::unique::<ViewReleaseNotesError>(),
 53        cx,
 54        |cx| {
 55            cx.new(move |cx| {
 56                let url = release_notes_url(cx);
 57                let mut prompt = ErrorMessagePrompt::new("Couldn't load release notes", cx);
 58                if let Some(url) = url {
 59                    prompt = prompt.with_link_button("View in Browser".to_string(), url);
 60                }
 61                prompt
 62            })
 63        },
 64    );
 65}
 66
 67fn view_release_notes_locally(
 68    workspace: &mut Workspace,
 69    window: &mut Window,
 70    cx: &mut Context<Workspace>,
 71) {
 72    let release_channel = ReleaseChannel::global(cx);
 73
 74    if matches!(
 75        release_channel,
 76        ReleaseChannel::Nightly | ReleaseChannel::Dev
 77    ) {
 78        if let Some(url) = release_notes_url(cx) {
 79            cx.open_url(&url);
 80        }
 81        return;
 82    }
 83
 84    let version = AppVersion::global(cx).to_string();
 85
 86    let client = client::Client::global(cx).http_client();
 87    let url = client.build_url(&format!(
 88        "/api/release_notes/v2/{}/{}",
 89        release_channel.dev_name(),
 90        version
 91    ));
 92
 93    let markdown = workspace
 94        .app_state()
 95        .languages
 96        .language_for_name("Markdown");
 97
 98    cx.spawn_in(window, async move |workspace, cx| {
 99        let markdown = markdown.await.log_err();
100        let response = client.get(&url, Default::default(), true).await;
101        let Some(mut response) = response.log_err() else {
102            workspace
103                .update_in(cx, notify_release_notes_failed_to_show)
104                .log_err();
105            return;
106        };
107
108        let mut body = Vec::new();
109        response.body_mut().read_to_end(&mut body).await.ok();
110
111        let body: serde_json::Result<ReleaseNotesBody> = serde_json::from_slice(body.as_slice());
112
113        let res: Option<()> = maybe!(async {
114            let body = body.ok()?;
115            let project = workspace
116                .read_with(cx, |workspace, _| workspace.project().clone())
117                .ok()?;
118            let (language_registry, buffer) = project.update(cx, |project, cx| {
119                (
120                    project.languages().clone(),
121                    project.create_buffer(markdown, false, cx),
122                )
123            });
124            let buffer = buffer.await.ok()?;
125            buffer.update(cx, |buffer, cx| {
126                buffer.edit([(0..0, body.release_notes)], None, cx)
127            });
128
129            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(body.title));
130
131            let ws_handle = workspace.clone();
132            workspace
133                .update_in(cx, |workspace, window, cx| {
134                    let editor =
135                        cx.new(|cx| Editor::for_multibuffer(buffer, Some(project), window, cx));
136                    let markdown_preview: Entity<MarkdownPreviewView> = MarkdownPreviewView::new(
137                        MarkdownPreviewMode::Default,
138                        editor,
139                        ws_handle,
140                        language_registry,
141                        window,
142                        cx,
143                    );
144                    workspace.add_item_to_active_pane(
145                        Box::new(markdown_preview),
146                        None,
147                        true,
148                        window,
149                        cx,
150                    );
151                    cx.notify();
152                })
153                .ok()
154        })
155        .await;
156        if res.is_none() {
157            workspace
158                .update_in(cx, notify_release_notes_failed_to_show)
159                .log_err();
160        }
161    })
162    .detach();
163}
164
165#[derive(Clone)]
166struct AnnouncementContent {
167    heading: SharedString,
168    description: SharedString,
169    bullet_items: Vec<SharedString>,
170    primary_action_label: SharedString,
171    primary_action_url: Option<SharedString>,
172}
173
174fn announcement_for_version(version: &Version) -> Option<AnnouncementContent> {
175    #[allow(clippy::match_single_binding)]
176    match (version.major, version.minor, version.patch) {
177        // TODO: Add real version when we have it
178        // (0, 225, 0) => Some(AnnouncementContent {
179        //     heading: "What's new in Zed 0.225".into(),
180        //     description: "This release includes some exciting improvements.".into(),
181        //     bullet_items: vec![
182        //         "Improved agent performance".into(),
183        //         "New agentic features".into(),
184        //         "Better agent capabilities".into(),
185        //     ],
186        //     primary_action_label: "Learn More".into(),
187        //     primary_action_url: Some("https://zed.dev/".into()),
188        // }),
189        _ => None,
190    }
191}
192
193struct AnnouncementToastNotification {
194    focus_handle: FocusHandle,
195    content: AnnouncementContent,
196}
197
198impl AnnouncementToastNotification {
199    fn new(content: AnnouncementContent, cx: &mut App) -> Self {
200        Self {
201            focus_handle: cx.focus_handle(),
202            content,
203        }
204    }
205}
206
207impl Focusable for AnnouncementToastNotification {
208    fn focus_handle(&self, _cx: &App) -> FocusHandle {
209        self.focus_handle.clone()
210    }
211}
212
213impl EventEmitter<DismissEvent> for AnnouncementToastNotification {}
214impl EventEmitter<SuppressEvent> for AnnouncementToastNotification {}
215impl Notification for AnnouncementToastNotification {}
216
217impl Render for AnnouncementToastNotification {
218    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
219        AnnouncementToast::new()
220            .heading(self.content.heading.clone())
221            .description(self.content.description.clone())
222            .bullet_items(
223                self.content
224                    .bullet_items
225                    .iter()
226                    .map(|item| ListBulletItem::new(item.clone())),
227            )
228            .primary_action_label(self.content.primary_action_label.clone())
229            .primary_on_click(cx.listener({
230                let url = self.content.primary_action_url.clone();
231                move |_, _, _window, cx| {
232                    if let Some(url) = &url {
233                        cx.open_url(url);
234                    }
235                    cx.emit(DismissEvent);
236                }
237            }))
238            .secondary_on_click(cx.listener({
239                let url = self.content.primary_action_url.clone();
240                move |_, _, _window, cx| {
241                    if let Some(url) = &url {
242                        cx.open_url(url);
243                    }
244                    cx.emit(DismissEvent);
245                }
246            }))
247            .dismiss_on_click(cx.listener(|_, _, _window, cx| {
248                cx.emit(DismissEvent);
249            }))
250    }
251}
252
253/// Shows a notification across all workspaces if an update was previously automatically installed
254/// and this notification had not yet been shown.
255pub fn notify_if_app_was_updated(cx: &mut App) {
256    let Some(updater) = AutoUpdater::get(cx) else {
257        return;
258    };
259
260    if let ReleaseChannel::Nightly = ReleaseChannel::global(cx) {
261        return;
262    }
263
264    struct UpdateNotification;
265
266    let should_show_notification = updater.read(cx).should_show_update_notification(cx);
267    cx.spawn(async move |cx| {
268        let should_show_notification = should_show_notification.await?;
269        // if true { // Hardcode it to true for testing it outside of the component preview
270        if should_show_notification {
271            cx.update(|cx| {
272                let mut version = updater.read(cx).current_version();
273                version.build = semver::BuildMetadata::EMPTY;
274                version.pre = semver::Prerelease::EMPTY;
275                let app_name = ReleaseChannel::global(cx).display_name();
276
277                if let Some(content) = announcement_for_version(&version) {
278                    show_app_notification(
279                        NotificationId::unique::<UpdateNotification>(),
280                        cx,
281                        move |cx| {
282                            cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx))
283                        },
284                    );
285                } else {
286                    show_app_notification(
287                        NotificationId::unique::<UpdateNotification>(),
288                        cx,
289                        move |cx| {
290                            let workspace_handle = cx.entity().downgrade();
291                            cx.new(|cx| {
292                                MessageNotification::new(
293                                    format!("Updated to {app_name} {}", version),
294                                    cx,
295                                )
296                                .primary_message("View Release Notes")
297                                .primary_on_click(move |window, cx| {
298                                    if let Some(workspace) = workspace_handle.upgrade() {
299                                        workspace.update(cx, |workspace, cx| {
300                                            crate::view_release_notes_locally(
301                                                workspace, window, cx,
302                                            );
303                                        })
304                                    }
305                                    cx.emit(DismissEvent);
306                                })
307                                .show_suppress_button(false)
308                            })
309                        },
310                    );
311                }
312
313                updater.update(cx, |updater, cx| {
314                    updater
315                        .set_should_show_update_notification(false, cx)
316                        .detach_and_log_err(cx);
317                });
318            });
319        }
320        anyhow::Ok(())
321    })
322    .detach();
323}