From f6dab9fa807b811e9215aea297f5d0c4bfa89c48 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 12:11:10 +0800 Subject: [PATCH] feat: copy button on Markdown code blocks Every fenced code block gets the chat's copy button in its top-right corner, once per fence even when a long block is split across root-list items. The button and the two-second "copied" check it shows move out of the chat into widgets::copy as copy_button and CopiedMark, which the chat and MarkdownState now share, so one owner holds the feedback behaviour. --- crates/ui/src/chat/components/assistant.rs | 46 +---------- crates/ui/src/chat/components/bubble.rs | 5 +- crates/ui/src/chat/mod.rs | 30 +++---- crates/ui/src/markdown/render.rs | 42 +++++++++- crates/ui/src/markdown/state.rs | 14 +++- crates/ui/src/markdown/view.rs | 12 +++ crates/ui/src/widgets/copy.rs | 91 ++++++++++++++++++++++ crates/ui/src/widgets/mod.rs | 1 + 8 files changed, 168 insertions(+), 73 deletions(-) create mode 100644 crates/ui/src/widgets/copy.rs diff --git a/crates/ui/src/chat/components/assistant.rs b/crates/ui/src/chat/components/assistant.rs index f621c315c..cb5eb64d2 100644 --- a/crates/ui/src/chat/components/assistant.rs +++ b/crates/ui/src/chat/components/assistant.rs @@ -3,13 +3,8 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; -use crate::theme::ActiveTheme as _; -use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::copy::copy_button; use crate::widgets::menu::{ContextMenuExt as _, CopyText, PopupMenu}; -use crate::{ - icon::{Icon, IconName}, - sizing::Sizable as _, -}; use gpui::{ Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClickEvent, Context, Div, Entity, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, Styled as _, @@ -174,45 +169,6 @@ pub(crate) fn reserve_action_row( /// /// The icon carries its own muted color because a Ghost button paints /// `foreground` over any color set on the button itself. -pub(crate) fn action_button( - id: impl Into, - icon: IconName, - label: impl Into, - cx: &App, -) -> Button { - Button::new(id.into()) - .ghost() - .small() - .rounded(crate::material::radius_chip(cx)) - .icon(Icon::new(icon).text_color(cx.theme().muted_foreground)) - .tooltip(label.into()) -} - -pub(crate) fn copy_button( - key: &str, - copied: bool, - compact: bool, - on_copy: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - cx: &App, -) -> impl IntoElement { - action_button( - SharedString::from(format!("copy-{key}")), - if copied { - IconName::Check - } else { - IconName::Copy - }, - if copied { - crate::tr!("chat.copied").into_owned() - } else { - crate::tr!("chat.copy").into_owned() - }, - cx, - ) - .when(compact, |button| button.min_w(px(44.)).min_h(px(44.))) - .on_click(on_copy) -} - #[cfg(test)] mod tests { use super::MdState; diff --git a/crates/ui/src/chat/components/bubble.rs b/crates/ui/src/chat/components/bubble.rs index 3e8e96323..d4133aa0d 100644 --- a/crates/ui/src/chat/components/bubble.rs +++ b/crates/ui/src/chat/components/bubble.rs @@ -21,6 +21,7 @@ use tcode_core::session::SteeringStatus; use super::super::RewindTurn; use super::assistant; use crate::markdown::{MarkdownState, MarkdownView, MenuExtension}; +use crate::widgets::copy::{action_button, copy_button}; pub(crate) type ClickHandler = Box; pub(crate) type SharedClickHandler = Arc; @@ -116,7 +117,7 @@ pub(crate) fn native_rewind_button( return None; } - let trigger = assistant::action_button( + let trigger = action_button( SharedString::from(format!("rewind-{turn}")), IconName::Undo, if disabled { @@ -234,7 +235,7 @@ pub(crate) fn user_bubble( let group_key = SharedString::from(format!("user-{entry_id}")); let mut actions = h_flex().gap(px(2.)).items_center().justify_end(); if !visible.trim().is_empty() { - actions = actions.child(assistant::copy_button( + actions = actions.child(copy_button( &format!("user:{entry_id}"), copied, compact, diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index f519f2768..2a793b1ae 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -18,6 +18,7 @@ mod residency; use crate::overlay::OverlayExt as _; use crate::theme::ActiveTheme as _; use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::copy::CopiedMark; use crate::widgets::menu::{ContextMenuExt as _, PopupMenu, open_in_zed}; use crate::widgets::tooltip::Tooltip; use crate::{ @@ -414,10 +415,8 @@ pub struct ChatView { _tick: Option>, /// 1s ticker kept alive while an error card shows a scheduled resume. _limit_tick: Option>, - /// Which copy button is currently showing its "Copied!" confirmation (2s): - /// the copy target's key (`plan`, `user:`, `assistant:`). - copied: Option, - _copied_task: Option>, + /// Keyed `plan`, `user:`, `assistant:` or `error:`. + copied: CopiedMark, /// The live commit dialog entity while it is open (kept alive across frames). commit_dialog: Option>, /// A system scrolling screenshot is driving the timeline: where it was @@ -705,8 +704,7 @@ impl ChatView { highlighted_turn: None, _tick: None, _limit_tick: None, - copied: None, - _copied_task: None, + copied: CopiedMark::default(), commit_dialog: None, capture: None, _subscriptions: subscriptions, @@ -1471,7 +1469,7 @@ impl ChatView { |md| (Some(md.state.clone()), md.synced.clone()), ); let copy_key = format!("assistant:{}", entry.id); - let copied = self.copied.as_deref() == Some(copy_key.as_str()); + let copied = self.copied.is(©_key); let mark = copy_key; column = column.child(components::assistant::assistant( components::assistant::AssistantData { @@ -1535,7 +1533,7 @@ impl ChatView { _ => None, }; let copy_key = format!("error:{}", entry.id); - let copied = self.copied.as_deref() == Some(copy_key.as_str()); + let copied = self.copied.is(©_key); let mark = copy_key; let copy_text = message.to_string(); column = column.child(components::error_card::error_card( @@ -1760,7 +1758,7 @@ impl ChatView { .flatten(); let markdown = self.md_states.get(entry_id).map(|md| md.state.clone()); let copy_key = format!("user:{entry_id}"); - let copied = self.copied.as_deref() == Some(copy_key.as_str()); + let copied = self.copied.is(©_key); let mark = copy_key; let copy_text: Arc = Arc::from(visible); let images = attachments @@ -2244,7 +2242,7 @@ impl ChatView { let md_copy = markdown.to_string(); let md_download = markdown.to_string(); let md_save = markdown.to_string(); - let copied = self.copied.as_deref() == Some("plan"); + let copied = self.copied.is("plan"); let toggle_key = collapse_key; components::disclosure::proposed_plan_card( components::disclosure::PlanCardData { @@ -2284,17 +2282,7 @@ impl ChatView { /// Show the "Copied!" confirmation on `key` for 2s; a second copy re-arms the timer. fn mark_copied(&mut self, key: String, cx: &mut Context) { - self.copied = Some(key.clone()); - self._copied_task = Some(cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - let _ = this.update(cx, |this, cx| { - if this.copied.as_deref() == Some(key.as_str()) { - this.copied = None; - cx.notify(); - } - }); - })); - cx.notify(); + self.copied.mark(key, |this| &mut this.copied, cx); } fn render_header( diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 03fc42fc5..2349c1aa3 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -13,6 +13,7 @@ use std::{ use crate::highlight::HighlightTheme; use crate::scroll::ScrollableElement as _; use crate::theme::ActiveTheme as _; +use crate::widgets::copy::copy_button; use crate::widgets::tooltip::Tooltip; use gpui::{ AnyElement, App, AvailableSpace, Bounds, Element, ElementId, Entity, FontStyle, FontWeight, @@ -301,7 +302,7 @@ fn render_root_item( let options = RenderOptions::default().child(item.block, item.block + 1 == children.len()); let content = match (&children[item.block], item.span.clone()) { (BlockNode::CodeBlock(code), Some(span)) => { - render_code_block(code, Some(span), &options, state, cx) + render_code_block(code, Some(span), &options, state, window, cx) } ( BlockNode::List { @@ -554,7 +555,7 @@ fn render_block( cx, ), BlockNode::ListItem { .. } => render_list_item(node, 0, options, state, window, cx), - BlockNode::CodeBlock(code) => render_code_block(code, None, &options, state, cx), + BlockNode::CodeBlock(code) => render_code_block(code, None, &options, state, window, cx), BlockNode::Table(table) => render_table(table, &options, state, window, cx), BlockNode::HorizontalRule => div() .id(options.path) @@ -1024,6 +1025,7 @@ fn render_code_block( span: Option>, options: &RenderOptions, view: &Entity, + window: &Window, cx: &mut App, ) -> AnyElement { let lang = code.lang.as_deref().unwrap_or("text"); @@ -1092,6 +1094,38 @@ fn render_code_block( .unwrap_or(code_text) .to_string(); let context_view = view.clone(); + // One button per fence, on the item that paints its first line. + let copy = first.then(|| { + let copied = view.read(cx).copied.is(&options.path); + let compact = crate::window_seam::window_is_compact(window, cx); + let (view, path, code) = (view.clone(), options.path.clone(), whole_code.clone()); + let button = copy_button( + &format!("code-{}", options.path), + copied, + compact, + move |_, window, cx| { + gpui_base::TextSelection::end(window, cx); + cx.stop_propagation(); + view.update(cx, |state, cx| { + state.copy_code(path.clone(), code.clone(), cx) + }); + }, + cx, + ); + let corner = div() + .absolute() + .top_1() + .right_1() + .rounded(cx.theme().tokens.radius.md) + .bg(cx.theme().tokens.colors.muted) + .child(button); + #[cfg(test)] + let corner = { + let path = options.path.clone(); + corner.debug_selector(move || format!("markdown-code-copy-{path}")) + }; + corner + }); div() .id(span_id(&options.path, &span)) .when(last && !options.is_last, |block| block.pb(rems(1.))) @@ -1109,7 +1143,9 @@ fn render_code_block( .when(first, |block| block.pt_3().rounded_t(radius)) .when(last, |block| block.pb_3().rounded_b(radius)) .bg(cx.theme().tokens.colors.muted) - .child(v_flex().w_full().children(rendered_lines)), + .relative() + .child(v_flex().w_full().children(rendered_lines)) + .children(copy), ) .into_any_element() } diff --git a/crates/ui/src/markdown/state.rs b/crates/ui/src/markdown/state.rs index 816541e86..c4ebaf365 100644 --- a/crates/ui/src/markdown/state.rs +++ b/crates/ui/src/markdown/state.rs @@ -7,9 +7,11 @@ use std::{ rc::Rc, }; +use crate::widgets::copy::CopiedMark; use gpui::{ - Bounds, Context, FocusHandle, ImageSource, IntoElement, ListAlignment, ListState, - ParentElement as _, Pixels, Point, Render, SharedString, SharedUri, Styled as _, Window, px, + Bounds, ClipboardItem, Context, FocusHandle, ImageSource, IntoElement, ListAlignment, + ListState, ParentElement as _, Pixels, Point, Render, SharedString, SharedUri, Styled as _, + Window, px, }; use gpui_base::{ElementExt as _, v_flex}; @@ -45,6 +47,8 @@ pub struct MarkdownState { pub(super) base_dir: Option, link_targets: LinkTargetCache, pub(super) pending_context: Option, + /// Keyed by the copied code block's root path. + pub(super) copied: CopiedMark, /// Window position of the last left mouse-down that landed on a link; /// a mouse-up nearby is a click, anything farther is a drag-selection. pub(super) link_press_origin: Option>, @@ -86,6 +90,7 @@ impl MarkdownState { base_dir: None, link_targets: LinkTargetCache::default(), pending_context: None, + copied: CopiedMark::default(), link_press_origin: None, is_selecting: false, text: text.to_string(), @@ -214,6 +219,11 @@ impl MarkdownState { crate::store::host_image(path) } + pub(super) fn copy_code(&mut self, path: String, code: String, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string(code)); + self.copied.mark(path, |state| &mut state.copied, cx); + } + pub(super) fn set_pending_context( &mut self, pending_context: Option, diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index 12f78088f..3b8f8278e 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -630,6 +630,18 @@ mod tests { ); } + #[gpui::test] + fn code_block_copy_button_copies_the_whole_fence(cx: &mut TestAppContext) { + let lines = (0..100).map(|ix| format!("line {ix}")).collect::>(); + let source = format!("```text\n{}\n```", lines.join("\n")); + let (_, cx) = open_timeline_row(&source, cx); + let button = cx.debug_bounds("markdown-code-copy-root-0").unwrap(); + cx.simulate_click(button.center(), Modifiers::default()); + cx.run_until_parked(); + let copied = cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!(copied.as_deref(), Some(lines.join("\n").as_str())); + } + fn open_timeline_row<'a>( source: &str, cx: &'a mut TestAppContext, diff --git a/crates/ui/src/widgets/copy.rs b/crates/ui/src/widgets/copy.rs new file mode 100644 index 000000000..a5535e56b --- /dev/null +++ b/crates/ui/src/widgets/copy.rs @@ -0,0 +1,91 @@ +//! The copy affordance every surface shares: a ghost icon button that shows a +//! check for two seconds after it fired, and the per-entity record of which +//! button that was. + +use std::time::Duration; + +use gpui::{ + App, ClickEvent, Context, SharedString, Styled as _, Task, Window, prelude::FluentBuilder as _, + px, +}; + +use crate::{ + icon::{Icon, IconName}, + sizing::Sizable as _, + theme::ActiveTheme as _, + widgets::button::{Button, ButtonVariants as _}, +}; + +pub(crate) fn action_button( + id: impl Into, + icon: IconName, + label: impl Into, + cx: &App, +) -> Button { + Button::new(id.into()) + .ghost() + .small() + .rounded(crate::material::radius_chip(cx)) + .icon(Icon::new(icon).text_color(cx.theme().muted_foreground)) + .tooltip(label.into()) +} + +pub(crate) fn copy_button( + key: &str, + copied: bool, + compact: bool, + on_copy: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> Button { + action_button( + SharedString::from(format!("copy-{key}")), + if copied { + IconName::Check + } else { + IconName::Copy + }, + if copied { + crate::tr!("chat.copied").into_owned() + } else { + crate::tr!("chat.copy").into_owned() + }, + cx, + ) + .when(compact, |button| button.min_w(px(44.)).min_h(px(44.))) + .on_click(on_copy) +} + +/// Which copy button of an entity is showing its confirmation, by the key the +/// button was built with; it clears itself two seconds after the copy. +#[derive(Default)] +pub(crate) struct CopiedMark { + key: Option, + _reset: Option>, +} + +impl CopiedMark { + pub(crate) fn is(&self, key: &str) -> bool { + self.key.as_deref() == Some(key) + } + + /// `slot` reaches this mark inside `T` again when the reset fires. + pub(crate) fn mark( + &mut self, + key: String, + slot: impl Fn(&mut T) -> &mut CopiedMark + 'static, + cx: &mut Context, + ) { + self.key = Some(key.clone()); + self._reset = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + let _ = this.update(cx, |this, cx| { + let mark = slot(this); + if mark.key.as_deref() == Some(key.as_str()) { + mark.key = None; + cx.notify(); + } + }); + })); + cx.notify(); + } +} diff --git a/crates/ui/src/widgets/mod.rs b/crates/ui/src/widgets/mod.rs index 5c2b1c2cc..471f816de 100644 --- a/crates/ui/src/widgets/mod.rs +++ b/crates/ui/src/widgets/mod.rs @@ -3,6 +3,7 @@ pub(crate) type ToggleHandler = std::rc::Rc