1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use agent_settings::{AgentSettings, WindowLayout};
6use auto_update::{AutoUpdater, release_notes_url};
7use db::kvp::Dismissable;
8use editor::{Editor, MultiBuffer};
9use fs::Fs;
10use gpui::{
11 App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Window, actions,
12 prelude::*,
13};
14use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
15use release_channel::{AppVersion, ReleaseChannel};
16use semver::Version;
17use serde::Deserialize;
18use settings::SettingsStore;
19use smol::io::AsyncReadExt;
20use ui::{AnnouncementToast, ListBulletItem, ParallelAgentsIllustration, prelude::*};
21use util::{ResultExt as _, maybe};
22use workspace::{
23 FocusWorkspaceSidebar, Workspace,
24 notifications::{
25 ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification,
26 simple_message_notification::MessageNotification,
27 },
28};
29
30actions!(
31 auto_update,
32 [
33 /// Opens the release notes for the current version in a new tab.
34 ViewReleaseNotesLocally
35 ]
36);
37
38pub fn init(cx: &mut App) {
39 notify_if_app_was_updated(cx);
40 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
41 workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, window, cx| {
42 view_release_notes_locally(workspace, window, cx);
43 });
44 })
45 .detach();
46}
47
48#[derive(Deserialize)]
49struct ReleaseNotesBody {
50 title: String,
51 release_notes: String,
52}
53
54fn notify_release_notes_failed_to_show(
55 workspace: &mut Workspace,
56 _window: &mut Window,
57 cx: &mut Context<Workspace>,
58) {
59 struct ViewReleaseNotesError;
60 workspace.show_notification(
61 NotificationId::unique::<ViewReleaseNotesError>(),
62 cx,
63 |cx| {
64 cx.new(move |cx| {
65 let url = release_notes_url(cx);
66 let mut prompt = ErrorMessagePrompt::new("Couldn't load release notes", cx);
67 if let Some(url) = url {
68 prompt = prompt.with_link_button("View in Browser".to_string(), url);
69 }
70 prompt
71 })
72 },
73 );
74}
75
76fn view_release_notes_locally(
77 workspace: &mut Workspace,
78 window: &mut Window,
79 cx: &mut Context<Workspace>,
80) {
81 let release_channel = ReleaseChannel::global(cx);
82
83 if matches!(
84 release_channel,
85 ReleaseChannel::Nightly | ReleaseChannel::Dev
86 ) {
87 if let Some(url) = release_notes_url(cx) {
88 cx.open_url(&url);
89 }
90 return;
91 }
92
93 let version = AppVersion::global(cx).to_string();
94
95 let client = client::Client::global(cx).http_client();
96 let url = client.build_url(&format!(
97 "/api/release_notes/v2/{}/{}",
98 release_channel.dev_name(),
99 version
100 ));
101
102 let markdown = workspace
103 .app_state()
104 .languages
105 .language_for_name("Markdown");
106
107 cx.spawn_in(window, async move |workspace, cx| {
108 let markdown = markdown.await.log_err();
109 let response = client.get(&url, Default::default(), true).await;
110 let Some(mut response) = response.log_err() else {
111 workspace
112 .update_in(cx, notify_release_notes_failed_to_show)
113 .log_err();
114 return;
115 };
116
117 let mut body = Vec::new();
118 response.body_mut().read_to_end(&mut body).await.ok();
119
120 let body: serde_json::Result<ReleaseNotesBody> = serde_json::from_slice(body.as_slice());
121
122 let res: Option<()> = maybe!(async {
123 let body = body.ok()?;
124 let project = workspace
125 .read_with(cx, |workspace, _| workspace.project().clone())
126 .ok()?;
127 let (language_registry, buffer) = project.update(cx, |project, cx| {
128 (
129 project.languages().clone(),
130 project.create_buffer(markdown, false, cx),
131 )
132 });
133 let buffer = buffer.await.ok()?;
134 buffer.update(cx, |buffer, cx| {
135 buffer.edit([(0..0, body.release_notes)], None, cx)
136 });
137
138 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(body.title));
139
140 let ws_handle = workspace.clone();
141 workspace
142 .update_in(cx, |workspace, window, cx| {
143 let editor =
144 cx.new(|cx| Editor::for_multibuffer(buffer, Some(project), window, cx));
145 let markdown_preview: Entity<MarkdownPreviewView> = MarkdownPreviewView::new(
146 MarkdownPreviewMode::Default,
147 editor,
148 ws_handle,
149 language_registry,
150 window,
151 cx,
152 );
153 workspace.add_item_to_active_pane(
154 Box::new(markdown_preview),
155 None,
156 true,
157 window,
158 cx,
159 );
160 cx.notify();
161 })
162 .ok()
163 })
164 .await;
165 if res.is_none() {
166 workspace
167 .update_in(cx, notify_release_notes_failed_to_show)
168 .log_err();
169 }
170 })
171 .detach();
172}
173
174#[derive(Clone)]
175struct AnnouncementContent {
176 heading: SharedString,
177 description: SharedString,
178 bullet_items: Vec<SharedString>,
179 primary_action_label: SharedString,
180 primary_action_url: Option<SharedString>,
181 primary_action_callback: Option<Arc<dyn Fn(&mut Window, &mut App) + Send + Sync>>,
182 secondary_action_url: Option<SharedString>,
183 on_dismiss: Option<Arc<dyn Fn(&mut App) + Send + Sync>>,
184}
185
186struct ParallelAgentAnnouncement;
187
188impl Dismissable for ParallelAgentAnnouncement {
189 const KEY: &'static str = "parallel-agent-announcement";
190}
191
192fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementContent> {
193 match (version.major, version.minor, version.patch) {
194 (0, 232, _) => {
195 if ParallelAgentAnnouncement::dismissed(cx) {
196 None
197 } else {
198 let fs = <dyn Fs>::global(cx);
199 Some(AnnouncementContent {
200 heading: "Introducing Parallel Agents".into(),
201 description: "Run multiple agent threads simultaneously across projects."
202 .into(),
203 bullet_items: vec![
204 "Use your favorite agents in parallel".into(),
205 "Optionally isolate agents using worktrees".into(),
206 "Combine multiple projects in one window".into(),
207 ],
208 primary_action_label: "Try Now".into(),
209 primary_action_url: None,
210 primary_action_callback: Some(Arc::new(move |window, cx| {
211 let already_agent_layout =
212 matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
213
214 window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
215
216 if already_agent_layout {
217 window
218 .dispatch_action(Box::new(zed_actions::assistant::ToggleFocus), cx);
219 } else {
220 AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
221
222 // Focus the agent panel after the layout change takes
223 // effect. `set_layout` writes to the settings file
224 // asynchronously; dispatching ToggleFocus before the
225 // file is reloaded would open the agent panel in its
226 // old dock position and the subsequent panel migration
227 // can leave the wrong panel visible. Global observers
228 // fire in registration order, so by the time this
229 // observer runs the panel-migration observers (registered
230 // at workspace creation) have already moved every panel.
231 let window_handle = window.window_handle();
232 let subscription = Rc::new(RefCell::new(None::<Subscription>));
233 let subscription_ref = subscription.clone();
234 *subscription.borrow_mut() =
235 Some(cx.observe_global::<SettingsStore>(move |cx| {
236 if matches!(
237 AgentSettings::get_layout(cx),
238 WindowLayout::Agent(_)
239 ) {
240 cx.update_window(window_handle, |_, window, cx| {
241 window.dispatch_action(
242 Box::new(zed_actions::assistant::ToggleFocus),
243 cx,
244 );
245 })
246 .ok();
247 subscription_ref.borrow_mut().take();
248 }
249 }));
250 }
251 })),
252 on_dismiss: Some(Arc::new(|cx| {
253 ParallelAgentAnnouncement::set_dismissed(true, cx)
254 })),
255 secondary_action_url: Some("https://zed.dev/blog/".into()),
256 })
257 }
258 }
259 _ => None,
260 }
261}
262
263struct AnnouncementToastNotification {
264 focus_handle: FocusHandle,
265 content: AnnouncementContent,
266}
267
268impl AnnouncementToastNotification {
269 fn new(content: AnnouncementContent, cx: &mut App) -> Self {
270 Self {
271 focus_handle: cx.focus_handle(),
272 content,
273 }
274 }
275
276 fn dismiss(&mut self, cx: &mut Context<Self>) {
277 cx.emit(DismissEvent);
278 if let Some(on_dismiss) = &self.content.on_dismiss {
279 on_dismiss(cx);
280 }
281 }
282}
283
284impl Focusable for AnnouncementToastNotification {
285 fn focus_handle(&self, _cx: &App) -> FocusHandle {
286 self.focus_handle.clone()
287 }
288}
289
290impl EventEmitter<DismissEvent> for AnnouncementToastNotification {}
291impl EventEmitter<SuppressEvent> for AnnouncementToastNotification {}
292impl Notification for AnnouncementToastNotification {}
293
294impl Render for AnnouncementToastNotification {
295 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
296 AnnouncementToast::new()
297 .illustration(ParallelAgentsIllustration::new())
298 .heading(self.content.heading.clone())
299 .description(self.content.description.clone())
300 .bullet_items(
301 self.content
302 .bullet_items
303 .iter()
304 .map(|item| ListBulletItem::new(item.clone())),
305 )
306 .primary_action_label(self.content.primary_action_label.clone())
307 .primary_on_click(cx.listener({
308 let url = self.content.primary_action_url.clone();
309 let callback = self.content.primary_action_callback.clone();
310 move |this, _, window, cx| {
311 telemetry::event!("Parallel Agent Announcement Main Click");
312 if let Some(callback) = &callback {
313 callback(window, cx);
314 }
315 if let Some(url) = &url {
316 cx.open_url(url);
317 }
318 this.dismiss(cx);
319 }
320 }))
321 .secondary_on_click(cx.listener({
322 let url = self.content.secondary_action_url.clone();
323 move |_, _, _window, cx| {
324 telemetry::event!("Parallel Agent Announcement Secondary Click");
325 if let Some(url) = &url {
326 cx.open_url(url);
327 }
328 }
329 }))
330 .dismiss_on_click(cx.listener(|this, _, _window, cx| {
331 telemetry::event!("Parallel Agent Announcement Dismiss");
332 this.dismiss(cx);
333 }))
334 }
335}
336
337/// Shows a notification across all workspaces if an update was previously automatically installed
338/// and this notification had not yet been shown.
339pub fn notify_if_app_was_updated(cx: &mut App) {
340 let Some(updater) = AutoUpdater::get(cx) else {
341 return;
342 };
343
344 if let ReleaseChannel::Nightly = ReleaseChannel::global(cx) {
345 return;
346 }
347
348 struct UpdateNotification;
349
350 let should_show_notification = updater.read(cx).should_show_update_notification(cx);
351 cx.spawn(async move |cx| {
352 let should_show_notification = should_show_notification.await?;
353 // if true { // Hardcode it to true for testing it outside of the component preview
354 if should_show_notification {
355 cx.update(|cx| {
356 let mut version = updater.read(cx).current_version();
357 version.pre = semver::Prerelease::EMPTY;
358 version.build = semver::BuildMetadata::EMPTY;
359 let app_name = ReleaseChannel::global(cx).display_name();
360
361 if let Some(content) = announcement_for_version(&version, cx) {
362 show_app_notification(
363 NotificationId::unique::<UpdateNotification>(),
364 cx,
365 move |cx| {
366 cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx))
367 },
368 );
369 } else {
370 show_app_notification(
371 NotificationId::unique::<UpdateNotification>(),
372 cx,
373 move |cx| {
374 let workspace_handle = cx.entity().downgrade();
375 cx.new(|cx| {
376 MessageNotification::new(
377 format!("Updated to {app_name} {}", version),
378 cx,
379 )
380 .primary_message("View Release Notes")
381 .primary_on_click(move |window, cx| {
382 if let Some(workspace) = workspace_handle.upgrade() {
383 workspace.update(cx, |workspace, cx| {
384 crate::view_release_notes_locally(
385 workspace, window, cx,
386 );
387 })
388 }
389 cx.emit(DismissEvent);
390 })
391 .show_suppress_button(false)
392 })
393 },
394 );
395 }
396
397 updater.update(cx, |updater, cx| {
398 updater
399 .set_should_show_update_notification(false, cx)
400 .detach_and_log_err(cx);
401 });
402 });
403 }
404 anyhow::Ok(())
405 })
406 .detach();
407}