workspace: Move the update Zed button to the title bar (#48467)

Danilo Leal and Conrad Irwin created

Currently, whenever Zed has a new version available for download, the
update button shows up in the status bar. Problem is: that status bar
slot can also display other buttons/information, such as problems with
your language server or general errors. In case the two things exist (a
problem and a new version), just one of them would be displayed, which
is not great; you should be able to see both. Additionally, given we
ship new versions pretty often, I've frequently saw feedback about
wanting to hide away the new version button... at least temporarily
while there's no immediate interest in upgrading.

So, this PR tackles all of that. The button to update a new version is
moved up to the title bar, nearby your avatar, and you have the ability
to dismiss, which effectively just moves the button from the title bar
to inside your user menu.


https://github.com/user-attachments/assets/e3f1d76d-9b85-4bee-a70f-e22dd5e7fdb3

Release Notes:

- Moved the update Zed button to the title bar and allowed it to be
dismissed.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

Cargo.lock                                          |   3 
crates/activity_indicator/Cargo.toml                |   1 
crates/activity_indicator/src/activity_indicator.rs | 194 ++-----------
crates/auto_update/src/auto_update.rs               |  14 +
crates/title_bar/Cargo.toml                         |   3 
crates/title_bar/src/title_bar.rs                   |  70 ++++
crates/title_bar/src/update_version.rs              | 148 ++++++++++
crates/ui/src/components/collab.rs                  |   2 
crates/ui/src/components/collab/update_button.rs    | 202 +++++++++++++++
9 files changed, 478 insertions(+), 159 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -108,7 +108,6 @@ dependencies = [
  "project",
  "proto",
  "release_channel",
- "semver",
  "smallvec",
  "ui",
  "util",
@@ -17247,9 +17246,11 @@ dependencies = [
  "pretty_assertions",
  "project",
  "recent_projects",
+ "release_channel",
  "remote",
  "rpc",
  "schemars",
+ "semver",
  "serde",
  "settings",
  "smallvec",

crates/activity_indicator/Cargo.toml 🔗

@@ -23,7 +23,6 @@ gpui.workspace = true
 language.workspace = true
 project.workspace = true
 proto.workspace = true
-semver.workspace = true
 smallvec.workspace = true
 ui.workspace = true
 util.workspace = true

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -1,4 +1,4 @@
-use auto_update::{AutoUpdateStatus, AutoUpdater, DismissMessage, VersionCheckType};
+use auto_update::DismissMessage;
 use editor::Editor;
 use extension_host::{ExtensionOperation, ExtensionStore};
 use futures::StreamExt;
@@ -49,7 +49,6 @@ pub enum Event {
 pub struct ActivityIndicator {
     statuses: Vec<ServerStatus>,
     project: Entity<Project>,
-    auto_updater: Option<Entity<AutoUpdater>>,
     context_menu_handle: PopoverMenuHandle<ContextMenu>,
     fs_jobs: Vec<fs::JobInfo>,
 }
@@ -82,7 +81,6 @@ impl ActivityIndicator {
         cx: &mut Context<Workspace>,
     ) -> Entity<ActivityIndicator> {
         let project = workspace.project().clone();
-        let auto_updater = AutoUpdater::get(cx);
         let this = cx.new(|cx| {
             let mut status_events = languages.language_server_binary_statuses();
             cx.spawn(async move |this, cx| {
@@ -215,14 +213,9 @@ impl ActivityIndicator {
             )
             .detach();
 
-            if let Some(auto_updater) = auto_updater.as_ref() {
-                cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
-            }
-
             Self {
                 statuses: Vec::new(),
                 project: project.clone(),
-                auto_updater,
                 context_menu_handle: PopoverMenuHandle::default(),
                 fs_jobs: Vec::new(),
             }
@@ -302,15 +295,6 @@ impl ActivityIndicator {
     }
 
     fn dismiss_message(&mut self, _: &DismissMessage, _: &mut Window, cx: &mut Context<Self>) {
-        let dismissed = if let Some(updater) = &self.auto_updater {
-            updater.update(cx, |updater, cx| updater.dismiss(cx))
-        } else {
-            false
-        };
-        if dismissed {
-            return;
-        }
-
         self.project.update(cx, |project, cx| {
             if project.last_formatting_failure(cx).is_some() {
                 project.reset_last_formatting_failure(cx);
@@ -669,122 +653,47 @@ impl ActivityIndicator {
             });
         }
 
-        // Show any application auto-update info.
-        self.auto_updater
-            .as_ref()
-            .and_then(|updater| match &updater.read(cx).status() {
-                AutoUpdateStatus::Checking => Some(Content {
-                    icon: Some(
-                        Icon::new(IconName::LoadCircle)
-                            .size(IconSize::Small)
-                            .with_rotate_animation(3)
-                            .into_any_element(),
-                    ),
-                    message: "Checking for Zed updates…".to_string(),
-                    on_click: Some(Arc::new(|this, window, cx| {
-                        this.dismiss_message(&DismissMessage, window, cx)
-                    })),
-                    tooltip_message: None,
-                }),
-                AutoUpdateStatus::Downloading { version } => Some(Content {
-                    icon: Some(
-                        Icon::new(IconName::Download)
-                            .size(IconSize::Small)
-                            .into_any_element(),
-                    ),
-                    message: "Downloading Zed update…".to_string(),
-                    on_click: Some(Arc::new(|this, window, cx| {
-                        this.dismiss_message(&DismissMessage, window, cx)
-                    })),
-                    tooltip_message: Some(Self::version_tooltip_message(version)),
-                }),
-                AutoUpdateStatus::Installing { version } => Some(Content {
-                    icon: Some(
-                        Icon::new(IconName::LoadCircle)
-                            .size(IconSize::Small)
-                            .with_rotate_animation(3)
-                            .into_any_element(),
-                    ),
-                    message: "Installing Zed update…".to_string(),
-                    on_click: Some(Arc::new(|this, window, cx| {
-                        this.dismiss_message(&DismissMessage, window, cx)
-                    })),
-                    tooltip_message: Some(Self::version_tooltip_message(version)),
-                }),
-                AutoUpdateStatus::Updated { version } => Some(Content {
-                    icon: None,
-                    message: "Click to restart and update Zed".to_string(),
-                    on_click: Some(Arc::new(move |_, _, cx| workspace::reload(cx))),
-                    tooltip_message: Some(Self::version_tooltip_message(version)),
-                }),
-                AutoUpdateStatus::Errored { error } => Some(Content {
-                    icon: Some(
-                        Icon::new(IconName::Warning)
-                            .size(IconSize::Small)
-                            .into_any_element(),
-                    ),
-                    message: "Failed to update Zed".to_string(),
-                    on_click: Some(Arc::new(|this, window, cx| {
-                        window.dispatch_action(Box::new(workspace::OpenLog), cx);
-                        this.dismiss_message(&DismissMessage, window, cx);
-                    })),
-                    tooltip_message: Some(format!("{error}")),
-                }),
-                AutoUpdateStatus::Idle => None,
-            })
-            .or_else(|| {
-                if let Some(extension_store) =
-                    ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
-                    && let Some((extension_id, operation)) =
-                        extension_store.outstanding_operations().iter().next()
-                {
-                    let (message, icon, rotate) = match operation {
-                        ExtensionOperation::Install => (
-                            format!("Installing {extension_id} extension…"),
-                            IconName::LoadCircle,
-                            true,
-                        ),
-                        ExtensionOperation::Upgrade => (
-                            format!("Updating {extension_id} extension…"),
-                            IconName::Download,
-                            false,
-                        ),
-                        ExtensionOperation::Remove => (
-                            format!("Removing {extension_id} extension…"),
-                            IconName::LoadCircle,
-                            true,
-                        ),
-                    };
+        // Show any extension installation info.
+        if let Some(extension_store) =
+            ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
+            && let Some((extension_id, operation)) =
+                extension_store.outstanding_operations().iter().next()
+        {
+            let (message, icon, rotate) = match operation {
+                ExtensionOperation::Install => (
+                    format!("Installing {extension_id} extension…"),
+                    IconName::LoadCircle,
+                    true,
+                ),
+                ExtensionOperation::Upgrade => (
+                    format!("Updating {extension_id} extension…"),
+                    IconName::Download,
+                    false,
+                ),
+                ExtensionOperation::Remove => (
+                    format!("Removing {extension_id} extension…"),
+                    IconName::LoadCircle,
+                    true,
+                ),
+            };
 
-                    Some(Content {
-                        icon: Some(Icon::new(icon).size(IconSize::Small).map(|this| {
-                            if rotate {
-                                this.with_rotate_animation(3).into_any_element()
-                            } else {
-                                this.into_any_element()
-                            }
-                        })),
-                        message,
-                        on_click: Some(Arc::new(|this, window, cx| {
-                            this.dismiss_message(&Default::default(), window, cx)
-                        })),
-                        tooltip_message: None,
-                    })
-                } else {
-                    None
-                }
-            })
-    }
+            return Some(Content {
+                icon: Some(Icon::new(icon).size(IconSize::Small).map(|this| {
+                    if rotate {
+                        this.with_rotate_animation(3).into_any_element()
+                    } else {
+                        this.into_any_element()
+                    }
+                })),
+                message,
+                on_click: Some(Arc::new(|this, window, cx| {
+                    this.dismiss_message(&Default::default(), window, cx)
+                })),
+                tooltip_message: None,
+            });
+        }
 
-    fn version_tooltip_message(version: &VersionCheckType) -> String {
-        format!("Version: {}", {
-            match version {
-                auto_update::VersionCheckType::Sha(sha) => format!("{}…", sha.short()),
-                auto_update::VersionCheckType::Semantic(semantic_version) => {
-                    semantic_version.to_string()
-                }
-            }
-        })
+        None
     }
 
     fn toggle_language_server_work_context_menu(
@@ -922,26 +831,3 @@ impl StatusItemView for ActivityIndicator {
     ) {
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use release_channel::AppCommitSha;
-    use semver::Version;
-
-    use super::*;
-
-    #[test]
-    fn test_version_tooltip_message() {
-        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Semantic(
-            Version::new(1, 0, 0),
-        ));
-
-        assert_eq!(message, "Version: 1.0.0");
-
-        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Sha(
-            AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
-        ));
-
-        assert_eq!(message, "Version: 14d9a41…");
-    }
-}

crates/auto_update/src/auto_update.rs 🔗

@@ -108,6 +108,7 @@ pub struct AutoUpdater {
     client: Arc<Client>,
     pending_poll: Option<Task<Option<()>>>,
     quit_subscription: Option<gpui::Subscription>,
+    update_check_type: UpdateCheckType,
 }
 
 #[derive(Deserialize, Serialize, Clone, Debug)]
@@ -318,11 +319,18 @@ impl InstallerDir {
     }
 }
 
+#[derive(Clone, Copy, Debug, PartialEq)]
 pub enum UpdateCheckType {
     Automatic,
     Manual,
 }
 
+impl UpdateCheckType {
+    pub fn is_manual(self) -> bool {
+        self == Self::Manual
+    }
+}
+
 impl AutoUpdater {
     pub fn get(cx: &mut App) -> Option<Entity<Self>> {
         cx.default_global::<GlobalAutoUpdate>().0.clone()
@@ -352,6 +360,7 @@ impl AutoUpdater {
             client,
             pending_poll: None,
             quit_subscription,
+            update_check_type: UpdateCheckType::Automatic,
         }
     }
 
@@ -373,10 +382,15 @@ impl AutoUpdater {
         })
     }
 
+    pub fn update_check_type(&self) -> UpdateCheckType {
+        self.update_check_type
+    }
+
     pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context<Self>) {
         if self.pending_poll.is_some() {
             return;
         }
+        self.update_check_type = check_type;
 
         cx.notify();
 

crates/title_bar/Cargo.toml 🔗

@@ -46,6 +46,7 @@ project.workspace = true
 recent_projects.workspace = true
 remote.workspace = true
 rpc.workspace = true
+semver.workspace = true
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true
@@ -70,8 +71,10 @@ http_client = { workspace = true, features = ["test-support"] }
 notifications = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
+release_channel.workspace = true
 remote = { workspace = true, features = ["test-support"] }
 rpc = { workspace = true, features = ["test-support"] }
+semver.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 tree-sitter-md.workspace = true
 util = { workspace = true, features = ["test-support"] }

crates/title_bar/src/title_bar.rs 🔗

@@ -3,6 +3,7 @@ pub mod collab;
 mod onboarding_banner;
 mod project_dropdown;
 mod title_bar_settings;
+mod update_version;
 
 #[cfg(feature = "stories")]
 mod stories;
@@ -40,6 +41,7 @@ use ui::{
     Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
     PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 };
+use update_version::UpdateVersion;
 use util::ResultExt;
 use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
 use zed_actions::OpenRemote;
@@ -61,7 +63,9 @@ actions!(
         /// Toggles the project menu dropdown.
         ToggleProjectMenu,
         /// Switches to a different git branch.
-        SwitchBranch
+        SwitchBranch,
+        /// A debug action to simulate an update being available to test the update banner UI.
+        SimulateUpdateAvailable
     ]
 );
 
@@ -75,6 +79,17 @@ pub fn init(cx: &mut App) {
         let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx));
         workspace.set_titlebar_item(item.into(), window, cx);
 
+        workspace.register_action(|workspace, _: &SimulateUpdateAvailable, _window, cx| {
+            if let Some(titlebar) = workspace
+                .titlebar_item()
+                .and_then(|item| item.downcast::<TitleBar>().ok())
+            {
+                titlebar.update(cx, |titlebar, cx| {
+                    titlebar.toggle_update_simulation(cx);
+                });
+            }
+        });
+
         workspace.register_action(|workspace, _: &SwitchProject, window, cx| {
             if let Some(titlebar) = workspace
                 .titlebar_item()
@@ -144,6 +159,7 @@ pub struct TitleBar {
     application_menu: Option<Entity<ApplicationMenu>>,
     _subscriptions: Vec<Subscription>,
     banner: Entity<OnboardingBanner>,
+    update_version: Entity<UpdateVersion>,
     screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
     project_dropdown_handle: PopoverMenuHandle<ProjectDropdown>,
 }
@@ -213,6 +229,7 @@ impl Render for TitleBar {
                 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
                 .children(self.render_call_controls(window, cx))
                 .children(self.render_connection_status(status, cx))
+                .child(self.update_version.clone())
                 .when(
                     user.is_none() && TitleBarSettings::get_global(cx).show_sign_in,
                     |this| this.child(self.render_sign_in_button(cx)),
@@ -338,6 +355,7 @@ impl TitleBar {
             .visible_when(|cx| !project::DisableAiSettings::get_global(cx).disable_ai)
         });
 
+        let update_version = cx.new(|cx| UpdateVersion::new(cx));
         let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
 
         Self {
@@ -349,6 +367,7 @@ impl TitleBar {
             client,
             _subscriptions: subscriptions,
             banner,
+            update_version,
             screen_share_popover_handle: PopoverMenuHandle::default(),
             project_dropdown_handle: PopoverMenuHandle::default(),
         }
@@ -358,6 +377,12 @@ impl TitleBar {
         self.project.read(cx).visible_worktrees(cx).count()
     }
 
+    fn toggle_update_simulation(&mut self, cx: &mut Context<Self>) {
+        self.update_version
+            .update(cx, |banner, cx| banner.update_simulation(cx));
+        cx.notify();
+    }
+
     pub fn show_project_dropdown(&self, window: &mut Window, cx: &mut App) {
         if self.worktree_count(cx) > 1 {
             self.project_dropdown_handle.show(window, cx);
@@ -927,6 +952,8 @@ impl TitleBar {
     }
 
     pub fn render_user_menu_button(&mut self, cx: &mut Context<Self>) -> impl Element {
+        let show_update_badge = self.update_version.read(cx).show_update_in_menu_bar();
+
         let user_store = self.user_store.read(cx);
         let user = user_store.current_user();
 
@@ -990,6 +1017,27 @@ impl TitleBar {
                         )
                         .separator()
                     })
+                    .when(show_update_badge, |this| {
+                        this.custom_entry(
+                            move |_window, _cx| {
+                                h_flex()
+                                    .w_full()
+                                    .gap_1()
+                                    .justify_between()
+                                    .child(Label::new("Restart to update Zed").color(Color::Accent))
+                                    .child(
+                                        Icon::new(IconName::Download)
+                                            .size(IconSize::Small)
+                                            .color(Color::Accent),
+                                    )
+                                    .into_any_element()
+                            },
+                            move |_, cx| {
+                                workspace::reload(cx);
+                            },
+                        )
+                        .separator()
+                    })
                     .action("Settings", zed_actions::OpenSettings.boxed_clone())
                     .action("Keymap", Box::new(zed_actions::OpenKeymap))
                     .action(
@@ -1013,9 +1061,25 @@ impl TitleBar {
             })
             .map(|this| {
                 if is_signed_in && TitleBarSettings::get_global(cx).show_user_picture {
+                    let avatar =
+                        user_avatar
+                            .clone()
+                            .map(|avatar| Avatar::new(avatar))
+                            .map(|avatar| {
+                                if show_update_badge {
+                                    avatar.indicator(
+                                        div()
+                                            .absolute()
+                                            .bottom_0()
+                                            .right_0()
+                                            .child(Indicator::dot().color(Color::Accent)),
+                                    )
+                                } else {
+                                    avatar
+                                }
+                            });
                     this.trigger_with_tooltip(
-                        ButtonLike::new("user-menu")
-                            .children(user_avatar.clone().map(|avatar| Avatar::new(avatar))),
+                        ButtonLike::new("user-menu").children(avatar),
                         Tooltip::text("Toggle User Menu"),
                     )
                 } else {

crates/title_bar/src/update_version.rs 🔗

@@ -0,0 +1,148 @@
+use std::sync::Arc;
+
+use anyhow::anyhow;
+use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType, VersionCheckType};
+use gpui::{Empty, Render};
+use semver::Version;
+use ui::{UpdateButton, prelude::*};
+
+pub struct UpdateVersion {
+    status: AutoUpdateStatus,
+    update_check_type: UpdateCheckType,
+    dismissed: bool,
+}
+
+impl UpdateVersion {
+    pub fn new(cx: &mut Context<Self>) -> Self {
+        if let Some(auto_updater) = AutoUpdater::get(cx) {
+            cx.observe(&auto_updater, |this, auto_update, cx| {
+                this.status = auto_update.read(cx).status();
+                this.update_check_type = auto_update.read(cx).update_check_type();
+                if this.status.is_updated() {
+                    this.dismissed = false;
+                }
+            })
+            .detach();
+            Self {
+                status: auto_updater.read(cx).status(),
+                update_check_type: UpdateCheckType::Automatic,
+                dismissed: false,
+            }
+        } else {
+            Self {
+                status: AutoUpdateStatus::Idle,
+                update_check_type: UpdateCheckType::Automatic,
+                dismissed: false,
+            }
+        }
+    }
+
+    pub fn update_simulation(&mut self, cx: &mut Context<Self>) {
+        let next_state = match self.status {
+            AutoUpdateStatus::Idle => AutoUpdateStatus::Checking,
+            AutoUpdateStatus::Checking => AutoUpdateStatus::Downloading {
+                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+            },
+            AutoUpdateStatus::Downloading { .. } => AutoUpdateStatus::Installing {
+                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+            },
+            AutoUpdateStatus::Installing { .. } => AutoUpdateStatus::Updated {
+                version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
+            },
+            AutoUpdateStatus::Updated { .. } => AutoUpdateStatus::Errored {
+                error: Arc::new(anyhow!("Network timeout")),
+            },
+            AutoUpdateStatus::Errored { .. } => AutoUpdateStatus::Idle,
+        };
+
+        self.status = next_state;
+        self.update_check_type = UpdateCheckType::Manual;
+        self.dismissed = false;
+        cx.notify()
+    }
+
+    pub fn show_update_in_menu_bar(&self) -> bool {
+        self.dismissed && self.status.is_updated()
+    }
+
+    fn version_tooltip_message(version: &VersionCheckType) -> String {
+        format!("Version: {}", {
+            match version {
+                VersionCheckType::Sha(sha) => format!("{}…", sha.short()),
+                VersionCheckType::Semantic(semantic_version) => semantic_version.to_string(),
+            }
+        })
+    }
+}
+
+impl Render for UpdateVersion {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if self.dismissed {
+            return Empty.into_any_element();
+        }
+        match &self.status {
+            AutoUpdateStatus::Checking if self.update_check_type.is_manual() => {
+                UpdateButton::checking().into_any_element()
+            }
+            AutoUpdateStatus::Downloading { version } if self.update_check_type.is_manual() => {
+                let tooltip = Self::version_tooltip_message(&version);
+                UpdateButton::downloading(tooltip).into_any_element()
+            }
+            AutoUpdateStatus::Installing { version } if self.update_check_type.is_manual() => {
+                let tooltip = Self::version_tooltip_message(&version);
+                UpdateButton::installing(tooltip).into_any_element()
+            }
+            AutoUpdateStatus::Updated { version } => {
+                let tooltip = Self::version_tooltip_message(&version);
+                UpdateButton::updated(tooltip)
+                    .on_click(|_, _, cx| {
+                        workspace::reload(cx);
+                    })
+                    .on_dismiss(cx.listener(|this, _, _window, cx| {
+                        this.dismissed = true;
+                        cx.notify()
+                    }))
+                    .into_any_element()
+            }
+            AutoUpdateStatus::Errored { error } => {
+                let error_str = error.to_string();
+                UpdateButton::errored(error_str)
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(workspace::OpenLog), cx);
+                    })
+                    .on_dismiss(cx.listener(|this, _, _window, cx| {
+                        this.dismissed = true;
+                        cx.notify()
+                    }))
+                    .into_any_element()
+            }
+            AutoUpdateStatus::Idle
+            | AutoUpdateStatus::Checking { .. }
+            | AutoUpdateStatus::Downloading { .. }
+            | AutoUpdateStatus::Installing { .. } => Empty.into_any_element(),
+        }
+    }
+}
+#[cfg(test)]
+mod tests {
+    use auto_update::VersionCheckType;
+    use release_channel::AppCommitSha;
+    use semver::Version;
+
+    use super::*;
+
+    #[test]
+    fn test_version_tooltip_message() {
+        let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Semantic(
+            Version::new(1, 0, 0),
+        ));
+
+        assert_eq!(message, "Version: 1.0.0");
+
+        let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Sha(
+            AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
+        ));
+
+        assert_eq!(message, "Version: 14d9a41…");
+    }
+}

crates/ui/src/components/collab/update_button.rs 🔗

@@ -0,0 +1,202 @@
+use gpui::{AnyElement, ClickEvent, prelude::*};
+
+use crate::{ButtonLike, CommonAnimationExt, Tooltip, prelude::*};
+
+/// A button component displayed in the title bar to show auto-update status.
+#[derive(IntoElement, RegisterComponent)]
+pub struct UpdateButton {
+    icon: IconName,
+    icon_animate: bool,
+    icon_color: Option<Color>,
+    message: SharedString,
+    tooltip: Option<SharedString>,
+    show_dismiss: bool,
+    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+    on_dismiss: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+}
+
+impl UpdateButton {
+    pub fn new(icon: IconName, message: impl Into<SharedString>) -> Self {
+        Self {
+            icon,
+            icon_animate: false,
+            icon_color: None,
+            message: message.into(),
+            tooltip: None,
+            show_dismiss: false,
+            on_click: None,
+            on_dismiss: None,
+        }
+    }
+
+    /// Sets whether the icon should have a rotation animation (for progress states).
+    pub fn icon_animate(mut self, animate: bool) -> Self {
+        self.icon_animate = animate;
+        self
+    }
+
+    /// Sets the icon color (e.g., for warning/error states).
+    pub fn icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
+        self.icon_color = color.into();
+        self
+    }
+
+    /// Sets the tooltip text shown on hover.
+    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
+        self.tooltip = Some(tooltip.into());
+        self
+    }
+
+    /// Shows a dismiss button on the right side.
+    pub fn with_dismiss(mut self) -> Self {
+        self.show_dismiss = true;
+        self
+    }
+
+    /// Sets the click handler for the main button area.
+    pub fn on_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_click = Some(Box::new(handler));
+        self
+    }
+
+    /// Sets the click handler for the dismiss button.
+    pub fn on_dismiss(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_dismiss = Some(Box::new(handler));
+        self
+    }
+
+    pub fn checking() -> Self {
+        Self::new(IconName::ArrowCircle, "Checking for Zed updates…").icon_animate(true)
+    }
+
+    pub fn downloading(version: impl Into<SharedString>) -> Self {
+        Self::new(IconName::Download, "Downloading Zed update…").tooltip(version)
+    }
+
+    pub fn installing(version: impl Into<SharedString>) -> Self {
+        Self::new(IconName::ArrowCircle, "Installing Zed update…")
+            .icon_animate(true)
+            .tooltip(version)
+    }
+
+    pub fn updated(version: impl Into<SharedString>) -> Self {
+        Self::new(IconName::Download, "Click to restart and update Zed")
+            .tooltip(version)
+            .with_dismiss()
+    }
+
+    pub fn errored(error: impl Into<SharedString>) -> Self {
+        Self::new(IconName::Warning, "Failed to update Zed")
+            .icon_color(Color::Warning)
+            .tooltip(error)
+            .with_dismiss()
+    }
+}
+
+impl RenderOnce for UpdateButton {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let border_color = cx.theme().colors().border;
+
+        let icon = Icon::new(self.icon)
+            .size(IconSize::XSmall)
+            .when_some(self.icon_color, |this, color| this.color(color));
+        let icon_element = if self.icon_animate {
+            icon.with_rotate_animation(3).into_any_element()
+        } else {
+            icon.into_any_element()
+        };
+
+        let tooltip = self.tooltip.clone();
+
+        h_flex()
+            .mr_2()
+            .rounded_sm()
+            .border_1()
+            .border_color(border_color)
+            .child(
+                ButtonLike::new("update-button")
+                    .child(
+                        h_flex()
+                            .h_full()
+                            .gap_1()
+                            .child(icon_element)
+                            .child(Label::new(self.message).size(LabelSize::Small)),
+                    )
+                    .when_some(tooltip, |this, tooltip| {
+                        this.tooltip(Tooltip::text(tooltip))
+                    })
+                    .when_some(self.on_click, |this, handler| this.on_click(handler)),
+            )
+            .when(self.show_dismiss, |this| {
+                this.child(
+                    div().border_l_1().border_color(border_color).child(
+                        IconButton::new("dismiss-update-button", IconName::Close)
+                            .icon_size(IconSize::Indicator)
+                            .when_some(self.on_dismiss, |this, handler| this.on_click(handler))
+                            .tooltip(Tooltip::text("Dismiss")),
+                    ),
+                )
+            })
+    }
+}
+
+impl Component for UpdateButton {
+    fn scope() -> ComponentScope {
+        ComponentScope::Collaboration
+    }
+
+    fn name() -> &'static str {
+        "UpdateButton"
+    }
+
+    fn description() -> Option<&'static str> {
+        Some(
+            "A button component displayed in the title bar to show auto-update status and allow users to restart Zed.",
+        )
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        let version = "1.99.0";
+
+        Some(
+            v_flex()
+                .gap_6()
+                .children(vec![
+                    example_group_with_title(
+                        "Progress States",
+                        vec![
+                            single_example("Checking", UpdateButton::checking().into_any_element()),
+                            single_example(
+                                "Downloading",
+                                UpdateButton::downloading(version).into_any_element(),
+                            ),
+                            single_example(
+                                "Installing",
+                                UpdateButton::installing(version).into_any_element(),
+                            ),
+                        ],
+                    ),
+                    example_group_with_title(
+                        "Actionable States",
+                        vec![
+                            single_example(
+                                "Ready to Update",
+                                UpdateButton::updated(version).into_any_element(),
+                            ),
+                            single_example(
+                                "Error",
+                                UpdateButton::errored("Network timeout").into_any_element(),
+                            ),
+                        ],
+                    ),
+                ])
+                .into_any_element(),
+        )
+    }
+}