Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 1 addition & 45 deletions crates/ui/src/chat/components/assistant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _,
Expand Down Expand Up @@ -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<SharedString>,
icon: IconName,
label: impl Into<SharedString>,
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;
Expand Down
5 changes: 3 additions & 2 deletions crates/ui/src/chat/components/bubble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
pub(crate) type SharedClickHandler = Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 9 additions & 21 deletions crates/ui/src/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -414,10 +415,8 @@ pub struct ChatView {
_tick: Option<Task<()>>,
/// 1s ticker kept alive while an error card shows a scheduled resume.
_limit_tick: Option<Task<()>>,
/// Which copy button is currently showing its "Copied!" confirmation (2s):
/// the copy target's key (`plan`, `user:<id>`, `assistant:<id>`).
copied: Option<String>,
_copied_task: Option<Task<()>>,
/// Keyed `plan`, `user:<id>`, `assistant:<id>` or `error:<id>`.
copied: CopiedMark,
/// The live commit dialog entity while it is open (kept alive across frames).
commit_dialog: Option<Entity<CommitDialog>>,
/// A system scrolling screenshot is driving the timeline: where it was
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(&copy_key);
let mark = copy_key;
column = column.child(components::assistant::assistant(
components::assistant::AssistantData {
Expand Down Expand Up @@ -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(&copy_key);
let mark = copy_key;
let copy_text = message.to_string();
column = column.child(components::error_card::error_card(
Expand Down Expand Up @@ -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(&copy_key);
let mark = copy_key;
let copy_text: Arc<str> = Arc::from(visible);
let images = attachments
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>) {
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(
Expand Down
42 changes: 39 additions & 3 deletions crates/ui/src/markdown/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1024,6 +1025,7 @@ fn render_code_block(
span: Option<Range<usize>>,
options: &RenderOptions,
view: &Entity<MarkdownState>,
window: &Window,
cx: &mut App,
) -> AnyElement {
let lang = code.lang.as_deref().unwrap_or("text");
Expand Down Expand Up @@ -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.)))
Expand All @@ -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()
}
Expand Down
14 changes: 12 additions & 2 deletions crates/ui/src/markdown/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -45,6 +47,8 @@ pub struct MarkdownState {
pub(super) base_dir: Option<PathBuf>,
link_targets: LinkTargetCache,
pub(super) pending_context: Option<PendingContextTarget>,
/// 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<Point<Pixels>>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Self>) {
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<PendingContextTarget>,
Expand Down
12 changes: 12 additions & 0 deletions crates/ui/src/markdown/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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,
Expand Down
Loading
Loading