ui: Improve performance in the `CopyButton` component (#47292)

Danilo Leal created

This PR improves the performance of the `CopyButton` component by
tracking the copied state locally through a `CopyButtonState` struct
instead of making an OS call every time the component re-renders. Also
pushing a slight improvement here by resetting the state after two
seconds so as to make the check mark go away after you clicked to copy.

Release Notes:

- N/A

Change summary

crates/acp_tools/src/acp_tools.rs                |   2 
crates/agent_ui/src/acp/thread_view.rs           |   4 
crates/collab_ui/src/collab_panel.rs             |   2 
crates/editor/src/hover_popover.rs               |   2 
crates/git_ui/src/blame_ui.rs                    |   2 
crates/git_ui/src/commit_tooltip.rs              |   5 
crates/markdown/src/markdown.rs                  |   2 
crates/markdown_preview/src/markdown_renderer.rs |   2 
crates/repl/src/outputs.rs                       |   3 
crates/ui/src/components/button/copy_button.rs   | 102 ++++++++++++-----
crates/workspace/src/notifications.rs            |   7 
11 files changed, 89 insertions(+), 44 deletions(-)

Detailed changes

crates/acp_tools/src/acp_tools.rs 🔗

@@ -571,7 +571,7 @@ impl Render for AcpToolsToolbarItemView {
                     .serialize_observed_messages()
                     .unwrap_or_default();
 
-                CopyButton::new(message)
+                CopyButton::new("copy-all-messages", message)
                     .tooltip_label("Copy All Messages")
                     .disabled(!has_messages)
             })

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -4357,7 +4357,7 @@ impl AcpThreadView {
                     }))
                     .child(
                         div().absolute().top_1().right_1().child(
-                            CopyButton::new(command_source.to_string())
+                            CopyButton::new("copy-command", command_source.to_string())
                                 .tooltip_label("Copy Command")
                                 .visible_on_hover(command_group),
                         ),
@@ -7794,7 +7794,7 @@ impl AcpThreadView {
     fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
         let message = message.into();
 
-        CopyButton::new(message).tooltip_label("Copy Error Message")
+        CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message")
     }
 
     fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -2541,7 +2541,7 @@ impl CollabPanel {
 
         let button = match section {
             Section::ActiveCall => channel_link.map(|channel_link| {
-                CopyButton::new(channel_link)
+                CopyButton::new("copy-channel-link", channel_link)
                     .visible_on_hover("section-header")
                     .tooltip_label("Copy Channel Link")
                     .into_any_element()

crates/editor/src/hover_popover.rs 🔗

@@ -1027,7 +1027,7 @@ impl DiagnosticPopover {
                     )
                     .child(div().absolute().top_1().right_1().child({
                         let message = self.local_diagnostic.diagnostic.message.clone();
-                        CopyButton::new(message).tooltip_label("Copy Diagnostic")
+                        CopyButton::new("copy-diagnostic", message).tooltip_label("Copy Diagnostic")
                     }))
                     .custom_scrollbars(
                         Scrollbars::for_settings::<EditorSettings>()

crates/git_ui/src/blame_ui.rs 🔗

@@ -337,7 +337,7 @@ impl BlameRenderer for GitBlameRenderer {
                                             )
                                             .child(Divider::vertical())
                                             .child(
-                                                CopyButton::new(sha.to_string())
+                                                CopyButton::new("copy-blame-sha", sha.to_string())
                                                     .tooltip_label("Copy SHA"),
                                             ),
                                     ),

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -343,7 +343,10 @@ impl Render for CommitTooltip {
                                             ),
                                         )
                                         .child(Divider::vertical())
-                                        .child(CopyButton::new(full_sha).tooltip_label("Copy SHA")),
+                                        .child(
+                                            CopyButton::new("copy-commit-sha", full_sha)
+                                                .tooltip_label("Copy SHA"),
+                                        ),
                                 ),
                         ),
                 )

crates/markdown/src/markdown.rs 🔗

@@ -1451,7 +1451,7 @@ fn render_copy_code_block_button(
 ) -> impl IntoElement {
     let id = ElementId::named_usize("copy-markdown-code", id);
 
-    CopyButton::new(code.clone()).custom_on_click({
+    CopyButton::new(id.clone(), code.clone()).custom_on_click({
         let markdown = markdown;
         move |_window, cx| {
             let id = id.clone();

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -623,7 +623,7 @@ fn render_markdown_code_block(
         StyledText::new(parsed.contents.clone())
     };
 
-    let copy_block_button = CopyButton::new(parsed.contents.clone())
+    let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone())
         .tooltip_label("Copy Codeblock")
         .visible_on_hover("markdown-block");
 

crates/repl/src/outputs.rs 🔗

@@ -246,7 +246,8 @@ impl Output {
                             let traceback_text = traceback.read(cx).full_text();
                             let full_error = format!("{}: {}\n{}", ename, evalue, traceback_text);
 
-                            CopyButton::new(full_error).tooltip_label("Copy Full Error")
+                            CopyButton::new("copy-full-error", full_error)
+                                .tooltip_label("Copy Full Error")
                         })
                         .child(
                             IconButton::new(

crates/ui/src/components/button/copy_button.rs 🔗

@@ -1,11 +1,37 @@
+use std::time::{Duration, Instant};
+
 use gpui::{
-    AnyElement, App, ClipboardItem, IntoElement, ParentElement, RenderOnce, Styled, Window,
+    AnyElement, App, ClipboardItem, Context, ElementId, Entity, IntoElement, ParentElement,
+    RenderOnce, Styled, Window,
 };
 
 use crate::{Tooltip, prelude::*};
 
+const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
+
+struct CopyButtonState {
+    copied_at: Option<Instant>,
+}
+
+impl CopyButtonState {
+    fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
+        Self { copied_at: None }
+    }
+
+    fn is_copied(&self) -> bool {
+        self.copied_at
+            .map(|t| t.elapsed() < COPIED_STATE_DURATION)
+            .unwrap_or(false)
+    }
+
+    fn mark_copied(&mut self) {
+        self.copied_at = Some(Instant::now());
+    }
+}
+
 #[derive(IntoElement, RegisterComponent)]
 pub struct CopyButton {
+    id: ElementId,
     message: SharedString,
     icon_size: IconSize,
     disabled: bool,
@@ -15,8 +41,9 @@ pub struct CopyButton {
 }
 
 impl CopyButton {
-    pub fn new(message: impl Into<SharedString>) -> Self {
+    pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
         Self {
+            id: id.into(),
             message: message.into(),
             icon_size: IconSize::Small,
             disabled: false,
@@ -56,38 +83,47 @@ impl CopyButton {
 }
 
 impl RenderOnce for CopyButton {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let id = self.id.clone();
         let message = self.message;
-        let message_clone = message.clone();
-
-        let id = format!("copy-button-{}", message_clone);
+        let custom_on_click = self.custom_on_click;
+        let visible_on_hover = self.visible_on_hover;
 
-        let copied = cx
-            .read_from_clipboard()
-            .map(|item| item.text().as_ref() == Some(&message_clone.into()))
-            .unwrap_or(false);
+        let state: Entity<CopyButtonState> =
+            window.use_keyed_state(id.clone(), cx, CopyButtonState::new);
+        let is_copied = state.read(cx).is_copied();
 
-        let (icon, color, tooltip) = if copied {
+        let (icon, color, tooltip) = if is_copied {
             (IconName::Check, Color::Success, "Copied!".into())
         } else {
             (IconName::Copy, Color::Muted, self.tooltip_label)
         };
 
-        let custom_on_click = self.custom_on_click;
-        let visible_on_hover = self.visible_on_hover;
-
         let button = IconButton::new(id, icon)
             .icon_color(color)
             .icon_size(self.icon_size)
             .disabled(self.disabled)
             .tooltip(Tooltip::text(tooltip))
             .on_click(move |_, window, cx| {
+                state.update(cx, |state, _cx| {
+                    state.mark_copied();
+                });
+
                 if let Some(custom_on_click) = custom_on_click.as_ref() {
                     (custom_on_click)(window, cx);
                 } else {
                     cx.stop_propagation();
-                    cx.write_to_clipboard(ClipboardItem::new_string(message.clone().into()));
+                    cx.write_to_clipboard(ClipboardItem::new_string(message.to_string()));
                 }
+
+                let state_id = state.entity_id();
+                cx.spawn(async move |cx| {
+                    cx.background_executor().timer(COPIED_STATE_DURATION).await;
+                    cx.update(|cx| {
+                        cx.notify(state_id);
+                    })
+                })
+                .detach();
             });
 
         if let Some(visible_on_hover) = visible_on_hover {
@@ -109,23 +145,14 @@ impl Component for CopyButton {
 
     fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
         let label_text = "Here's an example label";
-        let mut counter: usize = 0;
-
-        let mut copy_b = || {
-            counter += 1;
-            CopyButton::new(format!(
-                "Here's an example label (id for uniqueness: {} — ignore this)",
-                counter
-            ))
-        };
 
-        let example = vec![
+        let examples = vec![
             single_example(
                 "Default",
                 h_flex()
                     .gap_1()
                     .child(Label::new(label_text).size(LabelSize::Small))
-                    .child(copy_b())
+                    .child(CopyButton::new("preview-default", label_text))
                     .into_any_element(),
             ),
             single_example(
@@ -133,9 +160,15 @@ impl Component for CopyButton {
                 h_flex()
                     .gap_1()
                     .child(Label::new(label_text).size(LabelSize::Small))
-                    .child(copy_b().icon_size(IconSize::XSmall))
-                    .child(copy_b().icon_size(IconSize::Medium))
-                    .child(copy_b().icon_size(IconSize::XLarge))
+                    .child(
+                        CopyButton::new("preview-xsmall", label_text).icon_size(IconSize::XSmall),
+                    )
+                    .child(
+                        CopyButton::new("preview-medium", label_text).icon_size(IconSize::Medium),
+                    )
+                    .child(
+                        CopyButton::new("preview-xlarge", label_text).icon_size(IconSize::XLarge),
+                    )
                     .into_any_element(),
             ),
             single_example(
@@ -143,7 +176,10 @@ impl Component for CopyButton {
                 h_flex()
                     .gap_1()
                     .child(Label::new(label_text).size(LabelSize::Small))
-                    .child(copy_b().tooltip_label("Custom tooltip label"))
+                    .child(
+                        CopyButton::new("preview-tooltip", label_text)
+                            .tooltip_label("Custom tooltip label"),
+                    )
                     .into_any_element(),
             ),
             single_example(
@@ -152,11 +188,13 @@ impl Component for CopyButton {
                     .group("container")
                     .gap_1()
                     .child(Label::new(label_text).size(LabelSize::Small))
-                    .child(copy_b().visible_on_hover("container"))
+                    .child(
+                        CopyButton::new("preview-hover", label_text).visible_on_hover("container"),
+                    )
                     .into_any_element(),
             ),
         ];
 
-        Some(example_group(example).vertical().into_any_element())
+        Some(example_group(examples).vertical().into_any_element())
     }
 }

crates/workspace/src/notifications.rs 🔗

@@ -308,8 +308,11 @@ impl Render for LanguageServerPrompt {
                                 h_flex()
                                     .gap_1()
                                     .child(
-                                        CopyButton::new(request.message.clone())
-                                            .tooltip_label("Copy Description"),
+                                        CopyButton::new(
+                                            "copy-description",
+                                            request.message.clone(),
+                                        )
+                                        .tooltip_label("Copy Description"),
                                     )
                                     .child(
                                         IconButton::new(close_id, close_icon)