activity_indicator: Show extension installation and updates (#37374)

Finn Evers created

This PR fixes an issue where extension operations would never show in
the activity indicator despite this being implemented for ages. This
happened because we were always returning `None` whenever the app has a
global auto updater, which is always the case, so the code path for
showing extension updates in the indicator could never be hit despite
existing prior. Also slightly improves the messages shown for ongoing
extension operations, as these were previously context unaware.

While I was at this, I also quickly took a stab at cleaning up some
remotely related stuff, namely:
- The `AnimationExt` trait is now by default only implemented for
anything that also implements `IntoElement`. This prevents
`with_animation` from showing up for e.g. `u32` within the suggestions
(finally).
- Commonly used animations are now implemented in the
`CommonAnimationExt` trait within the `ui` crate so the needed code does
not always need to be copied and element IDs for the animations are
truly unique.

Relevant change here regarding the original issue is the change from the
`return match` to just a `match` within the activitiy indicator, which
solved the issue at hand.

If we find this to be too noisy at some point, we can easily revisit,
but I think this holds important enough information to be shown in the
activity indicator, especially whilst developing extensions.

Release Notes:

- Extension installation and updates will now be shown in the activity
indicator.

Change summary

crates/activity_indicator/src/activity_indicator.rs                       | 100 
crates/agent_ui/src/acp/thread_view.rs                                    |  43 
crates/agent_ui/src/active_thread.rs                                      |  45 
crates/agent_ui/src/agent_configuration.rs                                |  19 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs |  16 
crates/agent_ui/src/agent_diff.rs                                         |  14 
crates/agent_ui/src/text_thread_editor.rs                                 |  24 
crates/ai_onboarding/src/ai_upsell_card.rs                                |  15 
crates/assistant_tools/src/edit_file_tool.rs                              |  10 
crates/assistant_tools/src/terminal_tool.rs                               |  12 
crates/debugger_ui/src/dropdown_menus.rs                                  |  12 
crates/git_ui/src/git_panel.rs                                            |  22 
crates/gpui/src/elements/animation.rs                                     |   2 
crates/language_models/src/provider/copilot_chat.rs                       |  14 
crates/recent_projects/src/remote_connections.rs                          |  20 
crates/repl/src/outputs.rs                                                |  17 
crates/ui/src/components/icon.rs                                          |   5 
crates/ui/src/components/image.rs                                         |   5 
crates/ui/src/traits.rs                                                   |   2 
crates/ui/src/traits/animation_ext.rs                                     |  42 
crates/ui/src/traits/transformable.rs                                     |   7 
crates/ui/src/ui.rs                                                       |   1 
crates/zed/src/zed/quick_action_bar/repl_menu.rs                          |  14 
23 files changed, 203 insertions(+), 258 deletions(-)

Detailed changes

crates/activity_indicator/src/activity_indicator.rs ๐Ÿ”—

@@ -1,11 +1,10 @@
 use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType};
 use editor::Editor;
-use extension_host::ExtensionStore;
+use extension_host::{ExtensionOperation, ExtensionStore};
 use futures::StreamExt;
 use gpui::{
-    Animation, AnimationExt as _, App, Context, CursorStyle, Entity, EventEmitter,
-    InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
-    Styled, Transformation, Window, actions, percentage,
+    App, Context, CursorStyle, Entity, EventEmitter, InteractiveElement as _, ParentElement as _,
+    Render, SharedString, StatefulInteractiveElement, Styled, Window, actions,
 };
 use language::{
     BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
@@ -25,7 +24,10 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
+use ui::{
+    ButtonLike, CommonAnimationExt, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip,
+    prelude::*,
+};
 use util::truncate_and_trailoff;
 use workspace::{StatusItemView, Workspace, item::ItemHandle};
 
@@ -405,13 +407,7 @@ impl ActivityIndicator {
                     icon: Some(
                         Icon::new(IconName::ArrowCircle)
                             .size(IconSize::Small)
-                            .with_animation(
-                                "arrow-circle",
-                                Animation::new(Duration::from_secs(2)).repeat(),
-                                |icon, delta| {
-                                    icon.transform(Transformation::rotate(percentage(delta)))
-                                },
-                            )
+                            .with_rotate_animation(2)
                             .into_any_element(),
                     ),
                     message,
@@ -433,11 +429,7 @@ impl ActivityIndicator {
                 icon: Some(
                     Icon::new(IconName::ArrowCircle)
                         .size(IconSize::Small)
-                        .with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(2)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        )
+                        .with_rotate_animation(2)
                         .into_any_element(),
                 ),
                 message: format!("Debug: {}", session.read(cx).adapter()),
@@ -460,11 +452,7 @@ impl ActivityIndicator {
                 icon: Some(
                     Icon::new(IconName::ArrowCircle)
                         .size(IconSize::Small)
-                        .with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(2)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        )
+                        .with_rotate_animation(2)
                         .into_any_element(),
                 ),
                 message: job_info.message.into(),
@@ -671,8 +659,9 @@ impl ActivityIndicator {
         }
 
         // Show any application auto-update info.
-        if let Some(updater) = &self.auto_updater {
-            return match &updater.read(cx).status() {
+        self.auto_updater
+            .as_ref()
+            .and_then(|updater| match &updater.read(cx).status() {
                 AutoUpdateStatus::Checking => Some(Content {
                     icon: Some(
                         Icon::new(IconName::Download)
@@ -728,28 +717,49 @@ impl ActivityIndicator {
                     tooltip_message: None,
                 }),
                 AutoUpdateStatus::Idle => None,
-            };
-        }
-
-        if let Some(extension_store) =
-            ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
-            && let Some(extension_id) = extension_store.outstanding_operations().keys().next()
-        {
-            return Some(Content {
-                icon: Some(
-                    Icon::new(IconName::Download)
-                        .size(IconSize::Small)
-                        .into_any_element(),
-                ),
-                message: format!("Updating {extension_id} extensionโ€ฆ"),
-                on_click: Some(Arc::new(|this, window, cx| {
-                    this.dismiss_error_message(&DismissErrorMessage, window, cx)
-                })),
-                tooltip_message: 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,
+                        ),
+                    };
 
-        None
+                    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_error_message(&Default::default(), window, cx)
+                        })),
+                        tooltip_message: None,
+                    })
+                } else {
+                    None
+                }
+            })
     }
 
     fn version_tooltip_message(version: &VersionCheckType) -> String {

crates/agent_ui/src/acp/thread_view.rs ๐Ÿ”—

@@ -23,9 +23,9 @@ use gpui::{
     Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
     CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
     ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
-    Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
-    Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
-    point, prelude::*, pulsating_between,
+    Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
+    WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*,
+    pulsating_between,
 };
 use language::Buffer;
 
@@ -45,8 +45,8 @@ use terminal_view::terminal_panel::TerminalPanel;
 use text::Anchor;
 use theme::ThemeSettings;
 use ui::{
-    Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
-    Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
+    Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
+    PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
 };
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
@@ -2515,13 +2515,7 @@ impl AcpThreadView {
                         Icon::new(IconName::ArrowCircle)
                             .size(IconSize::XSmall)
                             .color(Color::Info)
-                            .with_animation(
-                                "arrow-circle",
-                                Animation::new(Duration::from_secs(2)).repeat(),
-                                |icon, delta| {
-                                    icon.transform(Transformation::rotate(percentage(delta)))
-                                },
-                            ),
+                            .with_rotate_animation(2)
                     )
             })
             .child(
@@ -2948,16 +2942,7 @@ impl AcpThreadView {
                                 Icon::new(IconName::ArrowCircle)
                                     .size(IconSize::Small)
                                     .color(Color::Muted)
-                                    .with_animation(
-                                        "arrow-circle",
-                                        Animation::new(Duration::from_secs(2)).repeat(),
-                                        |icon, delta| {
-                                            icon.transform(Transformation::rotate(percentage(
-                                                delta,
-                                            )))
-                                        },
-                                    )
-                                    .into_any_element(),
+                                    .with_rotate_animation(2)
                             )
                             .child(Label::new("Authenticatingโ€ฆ").size(LabelSize::Small)),
                     )
@@ -3270,13 +3255,7 @@ impl AcpThreadView {
                             acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
                                 .size(IconSize::Small)
                                 .color(Color::Accent)
-                                .with_animation(
-                                    "running",
-                                    Animation::new(Duration::from_secs(2)).repeat(),
-                                    |icon, delta| {
-                                        icon.transform(Transformation::rotate(percentage(delta)))
-                                    },
-                                )
+                                .with_rotate_animation(2)
                                 .into_any_element(),
                             acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
                                 .size(IconSize::Small)
@@ -5000,11 +4979,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement {
     Icon::new(IconName::LoadCircle)
         .size(size)
         .color(Color::Accent)
-        .with_animation(
-            "load_context_circle",
-            Animation::new(Duration::from_secs(3)).repeat(),
-            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-        )
+        .with_rotate_animation(3)
         .into_any_element()
 }
 

crates/agent_ui/src/active_thread.rs ๐Ÿ”—

@@ -23,9 +23,8 @@ use gpui::{
     AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry,
     ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla,
     ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful,
-    StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation,
-    UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage,
-    pulsating_between,
+    StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle,
+    WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, pulsating_between,
 };
 use language::{Buffer, Language, LanguageRegistry};
 use language_model::{
@@ -46,8 +45,8 @@ use std::time::Duration;
 use text::ToPoint;
 use theme::ThemeSettings;
 use ui::{
-    Banner, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize,
-    Tooltip, prelude::*,
+    Banner, CommonAnimationExt, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar,
+    ScrollbarState, TextSize, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
 use util::markdown::MarkdownCodeBlock;
@@ -2647,15 +2646,7 @@ impl ActiveThread {
                                         Icon::new(IconName::ArrowCircle)
                                             .color(Color::Accent)
                                             .size(IconSize::Small)
-                                            .with_animation(
-                                                "arrow-circle",
-                                                Animation::new(Duration::from_secs(2)).repeat(),
-                                                |icon, delta| {
-                                                    icon.transform(Transformation::rotate(
-                                                        percentage(delta),
-                                                    ))
-                                                },
-                                            )
+                                            .with_rotate_animation(2)
                                     }),
                             ),
                     )
@@ -2831,17 +2822,11 @@ impl ActiveThread {
             }
             ToolUseStatus::Pending
             | ToolUseStatus::InputStillStreaming
-            | ToolUseStatus::Running => {
-                let icon = Icon::new(IconName::ArrowCircle)
-                    .color(Color::Accent)
-                    .size(IconSize::Small);
-                icon.with_animation(
-                    "arrow-circle",
-                    Animation::new(Duration::from_secs(2)).repeat(),
-                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                )
-                .into_any_element()
-            }
+            | ToolUseStatus::Running => Icon::new(IconName::ArrowCircle)
+                .color(Color::Accent)
+                .size(IconSize::Small)
+                .with_rotate_animation(2)
+                .into_any_element(),
             ToolUseStatus::Finished(_) => div().w_0().into_any_element(),
             ToolUseStatus::Error(_) => {
                 let icon = Icon::new(IconName::Close)
@@ -2930,15 +2915,7 @@ impl ActiveThread {
                                     Icon::new(IconName::ArrowCircle)
                                         .size(IconSize::Small)
                                         .color(Color::Accent)
-                                        .with_animation(
-                                            "arrow-circle",
-                                            Animation::new(Duration::from_secs(2)).repeat(),
-                                            |icon, delta| {
-                                                icon.transform(Transformation::rotate(percentage(
-                                                    delta,
-                                                )))
-                                            },
-                                        ),
+                                        .with_rotate_animation(2),
                                 )
                                 .child(
                                     Label::new("Runningโ€ฆ")

crates/agent_ui/src/agent_configuration.rs ๐Ÿ”—

@@ -3,7 +3,7 @@ mod configure_context_server_modal;
 mod manage_profiles_modal;
 mod tool_picker;
 
-use std::{ops::Range, sync::Arc, time::Duration};
+use std::{ops::Range, sync::Arc};
 
 use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
 use agent_settings::AgentSettings;
@@ -17,9 +17,8 @@ use extension::ExtensionManifest;
 use extension_host::ExtensionStore;
 use fs::Fs;
 use gpui::{
-    Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity,
-    EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation,
-    WeakEntity, percentage,
+    Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable,
+    Hsla, ScrollHandle, Subscription, Task, WeakEntity,
 };
 use language::LanguageRegistry;
 use language_model::{
@@ -32,8 +31,9 @@ use project::{
 };
 use settings::{Settings, SettingsStore, update_settings_file};
 use ui::{
-    Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
-    Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
+    Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex,
+    Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip,
+    prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{Workspace, create_and_open_local_file};
@@ -670,10 +670,9 @@ impl AgentConfiguration {
                 Icon::new(IconName::LoadCircle)
                     .size(IconSize::XSmall)
                     .color(Color::Accent)
-                    .with_animation(
-                        SharedString::from(format!("{}-starting", context_server_id.0,)),
-                        Animation::new(Duration::from_secs(3)).repeat(),
-                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    .with_keyed_rotate_animation(
+                        SharedString::from(format!("{}-starting", context_server_id.0)),
+                        3,
                     )
                     .into_any_element(),
                 "Server is starting.",

crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs ๐Ÿ”—

@@ -1,16 +1,14 @@
 use std::{
     path::PathBuf,
     sync::{Arc, Mutex},
-    time::Duration,
 };
 
 use anyhow::{Context as _, Result};
 use context_server::{ContextServerCommand, ContextServerId};
 use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{
-    Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
-    FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle,
-    WeakEntity, percentage, prelude::*,
+    AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
+    TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
 };
 use language::{Language, LanguageRegistry};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
@@ -24,7 +22,9 @@ use project::{
 };
 use settings::{Settings as _, update_settings_file};
 use theme::ThemeSettings;
-use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
+use ui::{
+    CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*,
+};
 use util::ResultExt as _;
 use workspace::{ModalView, Workspace};
 
@@ -638,11 +638,7 @@ impl ConfigureContextServerModal {
                 Icon::new(IconName::ArrowCircle)
                     .size(IconSize::XSmall)
                     .color(Color::Info)
-                    .with_animation(
-                        "arrow-circle",
-                        Animation::new(Duration::from_secs(2)).repeat(),
-                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                    )
+                    .with_rotate_animation(2)
                     .into_any_element(),
             )
             .child(

crates/agent_ui/src/agent_diff.rs ๐Ÿ”—

@@ -14,9 +14,8 @@ use editor::{
     scroll::Autoscroll,
 };
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Empty, Entity,
-    EventEmitter, FocusHandle, Focusable, Global, SharedString, Subscription, Task, Transformation,
-    WeakEntity, Window, percentage, prelude::*,
+    Action, AnyElement, AnyView, App, AppContext, Empty, Entity, EventEmitter, FocusHandle,
+    Focusable, Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
 };
 
 use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
@@ -29,9 +28,8 @@ use std::{
     collections::hash_map::Entry,
     ops::Range,
     sync::Arc,
-    time::Duration,
 };
-use ui::{IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
+use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider};
 use util::ResultExt;
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
@@ -1084,11 +1082,7 @@ impl Render for AgentDiffToolbar {
                 Icon::new(IconName::LoadCircle)
                     .size(IconSize::Small)
                     .color(Color::Accent)
-                    .with_animation(
-                        "load_circle",
-                        Animation::new(Duration::from_secs(3)).repeat(),
-                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                    ),
+                    .with_rotate_animation(3),
             )
             .into_any();
 

crates/agent_ui/src/text_thread_editor.rs ๐Ÿ”—

@@ -25,8 +25,8 @@ use gpui::{
     Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem,
     Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement,
     IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size,
-    StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions,
-    div, img, percentage, point, prelude::*, pulsating_between, size,
+    StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, actions, div, img, point,
+    prelude::*, pulsating_between, size,
 };
 use language::{
     BufferSnapshot, LspAdapterDelegate, ToOffset,
@@ -53,8 +53,8 @@ use std::{
 };
 use text::SelectionGoal;
 use ui::{
-    ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip,
-    prelude::*,
+    ButtonLike, CommonAnimationExt, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle,
+    TintColor, Tooltip, prelude::*,
 };
 use util::{ResultExt, maybe};
 use workspace::{
@@ -1061,15 +1061,7 @@ impl TextThreadEditor {
                                         Icon::new(IconName::ArrowCircle)
                                             .size(IconSize::XSmall)
                                             .color(Color::Info)
-                                            .with_animation(
-                                                "arrow-circle",
-                                                Animation::new(Duration::from_secs(2)).repeat(),
-                                                |icon, delta| {
-                                                    icon.transform(Transformation::rotate(
-                                                        percentage(delta),
-                                                    ))
-                                                },
-                                            )
+                                            .with_rotate_animation(2)
                                             .into_any_element(),
                                     );
                                     note = Some(Self::esc_kbd(cx).into_any_element());
@@ -2790,11 +2782,7 @@ fn invoked_slash_command_fold_placeholder(
                 .child(Label::new(format!("/{}", command.name)))
                 .map(|parent| match &command.status {
                     InvokedSlashCommandStatus::Running(_) => {
-                        parent.child(Icon::new(IconName::ArrowCircle).with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(4)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        ))
+                        parent.child(Icon::new(IconName::ArrowCircle).with_rotate_animation(4))
                     }
                     InvokedSlashCommandStatus::Error(message) => parent.child(
                         Label::new(format!("error: {message}"))

crates/ai_onboarding/src/ai_upsell_card.rs ๐Ÿ”—

@@ -1,12 +1,9 @@
-use std::{sync::Arc, time::Duration};
+use std::sync::Arc;
 
 use client::{Client, UserStore, zed_urls};
 use cloud_llm_client::Plan;
-use gpui::{
-    Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation,
-    Window, percentage,
-};
-use ui::{Divider, Vector, VectorName, prelude::*};
+use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
+use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
 
 use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions};
 
@@ -147,11 +144,7 @@ impl RenderOnce for AiUpsellCard {
                     rems_from_px(72.),
                 )
                 .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3)))
-                .with_animation(
-                    "loading_stamp",
-                    Animation::new(Duration::from_secs(10)).repeat(),
-                    |this, delta| this.transform(Transformation::rotate(percentage(delta))),
-                ),
+                .with_rotate_animation(10),
             );
 
         let pro_trial_stamp = div()

crates/assistant_tools/src/edit_file_tool.rs ๐Ÿ”—

@@ -17,7 +17,7 @@ use editor::{
 use futures::StreamExt;
 use gpui::{
     Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
-    TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px,
+    TextStyleRefinement, WeakEntity, pulsating_between, px,
 };
 use indoc::formatdoc;
 use language::{
@@ -44,7 +44,7 @@ use std::{
     time::Duration,
 };
 use theme::ThemeSettings;
-use ui::{Disclosure, Tooltip, prelude::*};
+use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -939,11 +939,7 @@ impl ToolCard for EditFileToolCard {
                     Icon::new(IconName::ArrowCircle)
                         .size(IconSize::XSmall)
                         .color(Color::Info)
-                        .with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(2)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        ),
+                        .with_rotate_animation(2),
                 )
             })
             .when_some(error_message, |header, error_message| {

crates/assistant_tools/src/terminal_tool.rs ๐Ÿ”—

@@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus};
 use futures::{FutureExt as _, future::Shared};
 use gpui::{
-    Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
-    TextStyleRefinement, Transformation, WeakEntity, Window, percentage,
+    AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement,
+    WeakEntity, Window,
 };
 use language::LineEnding;
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -28,7 +28,7 @@ use std::{
 };
 use terminal_view::TerminalView;
 use theme::ThemeSettings;
-use ui::{Disclosure, Tooltip, prelude::*};
+use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
 use util::{
     ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
     time::duration_alt_display,
@@ -522,11 +522,7 @@ impl ToolCard for TerminalToolCard {
                     Icon::new(IconName::ArrowCircle)
                         .size(IconSize::XSmall)
                         .color(Color::Info)
-                        .with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(2)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        ),
+                        .with_rotate_animation(2),
                 )
             })
             .when(tool_failed || command_failed, |header| {

crates/debugger_ui/src/dropdown_menus.rs ๐Ÿ”—

@@ -1,9 +1,9 @@
-use std::{rc::Rc, time::Duration};
+use std::rc::Rc;
 
 use collections::HashMap;
-use gpui::{Animation, AnimationExt as _, Entity, Transformation, WeakEntity, percentage};
+use gpui::{Entity, WeakEntity};
 use project::debugger::session::{ThreadId, ThreadStatus};
-use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
+use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
 use util::{maybe, truncate_and_trailoff};
 
 use crate::{
@@ -152,11 +152,7 @@ impl DebugPanel {
             Icon::new(IconName::ArrowCircle)
                 .size(IconSize::Small)
                 .color(Color::Muted)
-                .with_animation(
-                    "arrow-circle",
-                    Animation::new(Duration::from_secs(2)).repeat(),
-                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                )
+                .with_rotate_animation(2)
                 .into_any_element()
         } else {
             match running_state.thread_status(cx).unwrap_or_default() {

crates/git_ui/src/git_panel.rs ๐Ÿ”—

@@ -31,11 +31,11 @@ use git::{
     UnstageAll,
 };
 use gpui::{
-    Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
-    ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
-    PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
-    WeakEntity, actions, anchored, deferred, percentage, uniform_list,
+    Action, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity,
+    EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
+    ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
+    Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
+    uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -63,8 +63,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
 use ui::{
-    Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
-    ScrollbarState, SplitButton, Tooltip, prelude::*,
+    Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize,
+    PopoverMenu, Scrollbar, ScrollbarState, SplitButton, Tooltip, prelude::*,
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::SERIALIZATION_THROTTLE_TIME;
@@ -3088,13 +3088,7 @@ impl GitPanel {
                         Icon::new(IconName::ArrowCircle)
                             .size(IconSize::XSmall)
                             .color(Color::Info)
-                            .with_animation(
-                                "arrow-circle",
-                                Animation::new(Duration::from_secs(2)).repeat(),
-                                |icon, delta| {
-                                    icon.transform(Transformation::rotate(percentage(delta)))
-                                },
-                            ),
+                            .with_rotate_animation(2),
                     )
                     .child(
                         Label::new("Generating Commit...")

crates/gpui/src/elements/animation.rs ๐Ÿ”—

@@ -87,7 +87,7 @@ pub trait AnimationExt {
     }
 }
 
-impl<E> AnimationExt for E {}
+impl<E: IntoElement + 'static> AnimationExt for E {}
 
 /// A GPUI element that applies an animation to another element
 pub struct AnimationElement<E> {

crates/language_models/src/provider/copilot_chat.rs ๐Ÿ”—

@@ -14,10 +14,7 @@ use copilot::{Copilot, Status};
 use futures::future::BoxFuture;
 use futures::stream::BoxStream;
 use futures::{FutureExt, Stream, StreamExt};
-use gpui::{
-    Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription, Task,
-    Transformation, percentage, svg,
-};
+use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg};
 use language::language_settings::all_language_settings;
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
@@ -28,8 +25,7 @@ use language_model::{
     StopReason, TokenUsage,
 };
 use settings::SettingsStore;
-use std::time::Duration;
-use ui::prelude::*;
+use ui::{CommonAnimationExt, prelude::*};
 use util::debug_panic;
 
 use crate::provider::x_ai::count_xai_tokens;
@@ -672,11 +668,7 @@ impl Render for ConfigurationView {
                         }),
                 )
         } else {
-            let loading_icon = Icon::new(IconName::ArrowCircle).with_animation(
-                "arrow-circle",
-                Animation::new(Duration::from_secs(4)).repeat(),
-                |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-            );
+            let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4);
 
             const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider.";
 

crates/recent_projects/src/remote_connections.rs ๐Ÿ”—

@@ -1,5 +1,5 @@
 use std::collections::BTreeSet;
-use std::{path::PathBuf, sync::Arc, time::Duration};
+use std::{path::PathBuf, sync::Arc};
 
 use anyhow::{Context as _, Result};
 use auto_update::AutoUpdater;
@@ -7,9 +7,9 @@ use editor::Editor;
 use extension_host::ExtensionStore;
 use futures::channel::oneshot;
 use gpui::{
-    Animation, AnimationExt, AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter,
-    Focusable, FontFeatures, ParentElement as _, PromptLevel, Render, SemanticVersion,
-    SharedString, Task, TextStyleRefinement, Transformation, WeakEntity, percentage,
+    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
+    ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
+    TextStyleRefinement, WeakEntity,
 };
 
 use language::CursorShape;
@@ -24,8 +24,8 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources, SettingsUi};
 use theme::ThemeSettings;
 use ui::{
-    ActiveTheme, Color, Context, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
-    LabelCommon, Styled, Window, prelude::*,
+    ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
+    IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
 };
 use util::serde::default_true;
 use workspace::{AppState, ModalView, Workspace};
@@ -268,13 +268,7 @@ impl Render for RemoteConnectionPrompt {
                         .child(
                             Icon::new(IconName::ArrowCircle)
                                 .size(IconSize::Medium)
-                                .with_animation(
-                                    "arrow-circle",
-                                    Animation::new(Duration::from_secs(2)).repeat(),
-                                    |icon, delta| {
-                                        icon.transform(Transformation::rotate(percentage(delta)))
-                                    },
-                                ),
+                                .with_rotate_animation(2),
                         )
                         .child(
                             div()

crates/repl/src/outputs.rs ๐Ÿ”—

@@ -33,16 +33,13 @@
 //! This module is designed to work with Jupyter message protocols,
 //! interpreting and displaying various types of Jupyter output.
 
-use std::time::Duration;
-
 use editor::{Editor, MultiBuffer};
-use gpui::{
-    Animation, AnimationExt, AnyElement, ClipboardItem, Entity, Render, Transformation, WeakEntity,
-    percentage,
-};
+use gpui::{AnyElement, ClipboardItem, Entity, Render, WeakEntity};
 use language::Buffer;
 use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
-use ui::{Context, IntoElement, Styled, Tooltip, Window, div, prelude::*, v_flex};
+use ui::{
+    CommonAnimationExt, Context, IntoElement, Styled, Tooltip, Window, div, prelude::*, v_flex,
+};
 
 mod image;
 use image::ImageView;
@@ -481,11 +478,7 @@ impl Render for ExecutionView {
                     Icon::new(IconName::ArrowCircle)
                         .size(IconSize::Small)
                         .color(Color::Muted)
-                        .with_animation(
-                            "arrow-circle",
-                            Animation::new(Duration::from_secs(3)).repeat(),
-                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                        ),
+                        .with_rotate_animation(3),
                 )
                 .child(Label::new("Executing...").color(Color::Muted))
                 .into_any_element(),

crates/ui/src/components/icon.rs ๐Ÿ”—

@@ -9,6 +9,7 @@ use gpui::{AnimationElement, AnyElement, Hsla, IntoElement, Rems, Transformation
 pub use icon_decoration::*;
 pub use icons::*;
 
+use crate::traits::transformable::Transformable;
 use crate::{Indicator, prelude::*};
 
 #[derive(IntoElement)]
@@ -180,8 +181,10 @@ impl Icon {
         self.size = size;
         self
     }
+}
 
-    pub fn transform(mut self, transformation: Transformation) -> Self {
+impl Transformable for Icon {
+    fn transform(mut self, transformation: Transformation) -> Self {
         self.transformation = transformation;
         self
     }

crates/ui/src/components/image.rs ๐Ÿ”—

@@ -7,6 +7,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr};
 
 use crate::Color;
 use crate::prelude::*;
+use crate::traits::transformable::Transformable;
 
 #[derive(
     Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString, IntoStaticStr, Serialize, Deserialize,
@@ -74,8 +75,10 @@ impl Vector {
         self.size = size;
         self
     }
+}
 
-    pub fn transform(mut self, transformation: Transformation) -> Self {
+impl Transformable for Vector {
+    fn transform(mut self, transformation: Transformation) -> Self {
         self.transformation = transformation;
         self
     }

crates/ui/src/traits.rs ๐Ÿ”—

@@ -1,6 +1,8 @@
+pub mod animation_ext;
 pub mod clickable;
 pub mod disableable;
 pub mod fixed;
 pub mod styled_ext;
 pub mod toggleable;
+pub mod transformable;
 pub mod visible_on_hover;

crates/ui/src/traits/animation_ext.rs ๐Ÿ”—

@@ -0,0 +1,42 @@
+use std::time::Duration;
+
+use gpui::{Animation, AnimationElement, AnimationExt, Transformation, percentage};
+
+use crate::{prelude::*, traits::transformable::Transformable};
+
+/// An extension trait for adding common animations to animatable components.
+pub trait CommonAnimationExt: AnimationExt {
+    /// Render this component as rotating over the given duration.
+    ///
+    /// NOTE: This method uses the location of the caller to generate an ID for this state.
+    ///       If this is not sufficient to identify your state (e.g. you're rendering a list item),
+    ///       you can provide a custom ElementID using the `use_keyed_rotate_animation` method.
+    #[track_caller]
+    fn with_rotate_animation(self, duration: u64) -> AnimationElement<Self>
+    where
+        Self: Transformable + Sized,
+    {
+        self.with_keyed_rotate_animation(
+            ElementId::CodeLocation(*std::panic::Location::caller()),
+            duration,
+        )
+    }
+
+    /// Render this component as rotating with the given element ID over the given duration.
+    fn with_keyed_rotate_animation(
+        self,
+        id: impl Into<ElementId>,
+        duration: u64,
+    ) -> AnimationElement<Self>
+    where
+        Self: Transformable + Sized,
+    {
+        self.with_animation(
+            id,
+            Animation::new(Duration::from_secs(duration)).repeat(),
+            |component, delta| component.transform(Transformation::rotate(percentage(delta))),
+        )
+    }
+}
+
+impl<T: AnimationExt> CommonAnimationExt for T {}

crates/ui/src/traits/transformable.rs ๐Ÿ”—

@@ -0,0 +1,7 @@
+use gpui::Transformation;
+
+/// A trait for components that can be transformed.
+pub trait Transformable {
+    /// Sets the transformation for the element.
+    fn transform(self, transformation: Transformation) -> Self;
+}

crates/ui/src/ui.rs ๐Ÿ”—

@@ -17,3 +17,4 @@ pub mod utils;
 pub use components::*;
 pub use prelude::*;
 pub use styles::*;
+pub use traits::animation_ext::*;

crates/zed/src/zed/quick_action_bar/repl_menu.rs ๐Ÿ”—

@@ -1,7 +1,5 @@
-use std::time::Duration;
-
 use gpui::ElementId;
-use gpui::{Animation, AnimationExt, AnyElement, Entity, Transformation, percentage};
+use gpui::{AnyElement, Entity};
 use picker::Picker;
 use repl::{
     ExecutionState, JupyterSettings, Kernel, KernelSpecification, KernelStatus, Session,
@@ -10,8 +8,8 @@ use repl::{
     worktree_id_for_editor,
 };
 use ui::{
-    ButtonLike, ContextMenu, IconWithIndicator, Indicator, IntoElement, PopoverMenu,
-    PopoverMenuHandle, Tooltip, prelude::*,
+    ButtonLike, CommonAnimationExt, ContextMenu, IconWithIndicator, Indicator, IntoElement,
+    PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
 };
 use util::ResultExt;
 
@@ -224,11 +222,7 @@ impl QuickActionBar {
             .child(if menu_state.icon_is_animating {
                 Icon::new(menu_state.icon)
                     .color(menu_state.icon_color)
-                    .with_animation(
-                        "arrow-circle",
-                        Animation::new(Duration::from_secs(5)).repeat(),
-                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
-                    )
+                    .with_rotate_animation(5)
                     .into_any_element()
             } else {
                 IconWithIndicator::new(