From b306a0221b018e0756cd0317e19d30c6df05ff8f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 7 Apr 2025 17:56:24 -0600 Subject: [PATCH] agent: Add headers for code blocks (#28253) image Release Notes: - N/A --------- Co-authored-by: Danilo Leal Co-authored-by: Antonio Scandurra --- Cargo.lock | 3 +- crates/agent/src/active_thread.rs | 249 +++++++++++++++-- crates/editor/src/code_context_menus.rs | 4 +- crates/editor/src/hover_popover.rs | 7 +- crates/markdown/Cargo.toml | 3 +- crates/markdown/src/markdown.rs | 346 +++++++++++------------- crates/markdown/src/parser.rs | 13 +- crates/markdown/src/path_range.rs | 76 +++--- 8 files changed, 439 insertions(+), 262 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a063fd7536..e648ab5f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8354,7 +8354,6 @@ dependencies = [ "anyhow", "assets", "env_logger 0.11.8", - "file_icons", "gpui", "language", "languages", @@ -8363,10 +8362,10 @@ dependencies = [ "node_runtime", "pulldown-cmark 0.12.2", "settings", + "sum_tree", "theme", "ui", "util", - "workspace", "workspace-hack", ] diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 81b8b03cc0..08e8e3e8da 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -10,21 +10,24 @@ use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus}; use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill}; use anyhow::Context as _; use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use editor::scroll::Autoscroll; use editor::{Editor, MultiBuffer}; use gpui::{ - AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, - EdgesRefinement, Empty, Entity, Focusable, Hsla, Length, ListAlignment, ListState, MouseButton, - PlatformDisplay, ScrollHandle, Stateful, StyleRefinement, Subscription, Task, + AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardItem, + DefiniteLength, EdgesRefinement, Empty, Entity, Focusable, Hsla, ListAlignment, ListState, + MouseButton, PlatformDisplay, ScrollHandle, Stateful, StyleRefinement, Subscription, Task, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage, pulsating_between, }; use language::{Buffer, LanguageRegistry}; use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelToolUseId, Role}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use markdown::parser::CodeBlockKind; +use markdown::{Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown, without_fences}; use project::ProjectItem as _; use settings::{Settings as _, update_settings_file}; +use std::ops::Range; +use std::path::Path; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -55,6 +58,7 @@ pub struct ActiveThread { expanded_thinking_segments: HashMap<(MessageId, usize), bool>, last_error: Option, notifications: Vec>, + copied_code_block_ids: HashSet, _subscriptions: Vec, notification_subscriptions: HashMap, Vec>, feedback_message_editor: Option>, @@ -100,7 +104,7 @@ impl RenderedMessage { scroll_handle.scroll_to_bottom(); } else { self.segments.push(RenderedMessageSegment::Thinking { - content: render_markdown(text.into(), self.language_registry.clone(), cx), + content: parse_markdown(text.into(), self.language_registry.clone(), cx), scroll_handle: ScrollHandle::default(), }); } @@ -111,7 +115,7 @@ impl RenderedMessage { markdown.update(cx, |markdown, cx| markdown.append(text, cx)); } else { self.segments - .push(RenderedMessageSegment::Text(render_markdown( + .push(RenderedMessageSegment::Text(parse_markdown( SharedString::from(text), self.language_registry.clone(), cx, @@ -122,10 +126,10 @@ impl RenderedMessage { fn push_segment(&mut self, segment: &MessageSegment, cx: &mut App) { let rendered_segment = match segment { MessageSegment::Thinking(text) => RenderedMessageSegment::Thinking { - content: render_markdown(text.into(), self.language_registry.clone(), cx), + content: parse_markdown(text.into(), self.language_registry.clone(), cx), scroll_handle: ScrollHandle::default(), }, - MessageSegment::Text(text) => RenderedMessageSegment::Text(render_markdown( + MessageSegment::Text(text) => RenderedMessageSegment::Text(parse_markdown( text.into(), self.language_registry.clone(), cx, @@ -143,7 +147,7 @@ enum RenderedMessageSegment { Text(Entity), } -fn render_markdown( +fn parse_markdown( text: SharedString, language_registry: Arc, cx: &mut App, @@ -174,12 +178,6 @@ fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { code_block_overflow_x_scroll: true, table_overflow_x_scroll: true, code_block: StyleRefinement { - margin: EdgesRefinement { - top: Some(Length::Definite(rems(0.).into())), - left: Some(Length::Definite(rems(0.).into())), - right: Some(Length::Definite(rems(0.).into())), - bottom: Some(Length::Definite(rems(0.5).into())), - }, padding: EdgesRefinement { top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), @@ -187,13 +185,6 @@ fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), }, background: Some(colors.editor_background.into()), - border_color: Some(colors.border_variant), - border_widths: EdgesRefinement { - top: Some(AbsoluteLength::Pixels(Pixels(1.))), - left: Some(AbsoluteLength::Pixels(Pixels(1.))), - right: Some(AbsoluteLength::Pixels(Pixels(1.))), - bottom: Some(AbsoluteLength::Pixels(Pixels(1.))), - }, text: Some(TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), @@ -297,6 +288,197 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle { } } +fn render_markdown_code_block( + id: usize, + kind: &CodeBlockKind, + parsed_markdown: &ParsedMarkdown, + codeblock_range: Range, + active_thread: Entity, + workspace: WeakEntity, + _window: &mut Window, + cx: &App, +) -> Div { + let label = match kind { + CodeBlockKind::Indented => None, + CodeBlockKind::Fenced => Some( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Code) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .child(Label::new("untitled").size(LabelSize::Small)) + .into_any_element(), + ), + CodeBlockKind::FencedLang(raw_language_name) => Some( + h_flex() + .gap_1() + .children( + parsed_markdown + .languages_by_name + .get(raw_language_name) + .and_then(|language| { + language + .config() + .matcher + .path_suffixes + .iter() + .find_map(|extension| { + file_icons::FileIcons::get_icon(Path::new(extension), cx) + }) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + }), + ) + .child( + Label::new( + parsed_markdown + .languages_by_name + .get(raw_language_name) + .map(|language| language.name().into()) + .clone() + .unwrap_or_else(|| raw_language_name.clone()), + ) + .size(LabelSize::Small), + ) + .into_any_element(), + ), + CodeBlockKind::FencedSrc(path_range) => path_range.path.file_name().map(|file_name| { + let content = if let Some(parent) = path_range.path.parent() { + h_flex() + .ml_1() + .gap_1() + .child( + Label::new(file_name.to_string_lossy().to_string()).size(LabelSize::Small), + ) + .child( + Label::new(parent.to_string_lossy().to_string()) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } else { + Label::new(path_range.path.to_string_lossy().to_string()) + .size(LabelSize::Small) + .ml_1() + .into_any_element() + }; + + h_flex() + .id(("code-block-header-label", id)) + .w_full() + .max_w_full() + .px_1() + .gap_0p5() + .cursor_pointer() + .rounded_sm() + .hover(|item| item.bg(cx.theme().colors().element_hover.opacity(0.5))) + .tooltip(Tooltip::text("Jump to file")) + .children( + file_icons::FileIcons::get_icon(&path_range.path, cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)), + ) + .child(content) + .child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Ignored), + ) + .on_click({ + let path_range = path_range.clone(); + move |_, window, cx| { + workspace + .update(cx, { + |workspace, cx| { + if let Some(project_path) = workspace + .project() + .read(cx) + .find_project_path(&path_range.path, cx) + { + workspace + .open_path(project_path, None, true, window, cx) + .detach_and_log_err(cx); + } + } + }) + .ok(); + } + }) + .into_any_element() + }), + }; + + let codeblock_header_bg = cx + .theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.01)); + + let codeblock_was_copied = active_thread.read(cx).copied_code_block_ids.contains(&id); + + let codeblock_header = h_flex() + .p_1() + .gap_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .bg(codeblock_header_bg) + .rounded_t_md() + .children(label) + .child( + IconButton::new( + ("copy-markdown-code", id), + if codeblock_was_copied { + IconName::Check + } else { + IconName::Copy + }, + ) + .icon_color(Color::Muted) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Copy Code")) + .on_click({ + let active_thread = active_thread.clone(); + let parsed_markdown = parsed_markdown.clone(); + move |_event, _window, cx| { + active_thread.update(cx, |this, cx| { + this.copied_code_block_ids.insert(id); + + let code = + without_fences(&parsed_markdown.source()[codeblock_range.clone()]) + .to_string(); + + cx.write_to_clipboard(ClipboardItem::new_string(code.clone())); + + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + this.copied_code_block_ids.remove(&id); + cx.notify(); + }) + }) + .ok(); + }) + .detach(); + }); + } + }), + ); + + v_flex() + .mb_2() + .relative() + .overflow_hidden() + .rounded_lg() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child(codeblock_header) +} + fn open_markdown_link( text: SharedString, workspace: WeakEntity, @@ -410,6 +592,7 @@ impl ActiveThread { hide_scrollbar_task: None, editing_message: None, last_error: None, + copied_code_block_ids: HashSet::default(), notifications: Vec::new(), _subscriptions: subscriptions, notification_subscriptions: HashMap::default(), @@ -1128,6 +1311,7 @@ impl ActiveThread { message_id, rendered_message, has_tool_uses, + workspace.clone(), window, cx, )) @@ -1465,6 +1649,7 @@ impl ActiveThread { message_id: MessageId, rendered_message: &RenderedMessage, has_tool_uses: bool, + workspace: WeakEntity, window: &Window, cx: &Context, ) -> impl IntoElement { @@ -1508,6 +1693,24 @@ impl ActiveThread { markdown.clone(), default_markdown_style(window, cx), ) + .code_block_renderer(markdown::CodeBlockRenderer::Custom { + render: Arc::new({ + let workspace = workspace.clone(); + let active_thread = cx.entity(); + move |id, kind, parsed_markdown, range, window, cx| { + render_markdown_code_block( + id, + kind, + parsed_markdown, + range, + active_thread.clone(), + workspace.clone(), + window, + cx, + ) + } + }), + }) .on_url_click({ let workspace = self.workspace.clone(); move |text, window, cx| { diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 6e836abcc6..6dd5029700 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -623,7 +623,6 @@ impl CompletionsMenu { .language_at(self.initial_position, cx) .map(|l| l.name().to_proto()); Markdown::new(SharedString::default(), languages, language, cx) - .copy_code_block_buttons(false) }) }); markdown.update(cx, |markdown, cx| { @@ -631,6 +630,9 @@ impl CompletionsMenu { }); div().child( MarkdownElement::new(markdown.clone(), hover_markdown_style(window, cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + }) .on_url_click(open_markdown_url), ) } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 00391d865f..b0f39a21d9 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -546,7 +546,6 @@ async fn parse_blocks( fallback_language_name, cx, ) - .copy_code_block_buttons(false) }) .ok(); @@ -787,6 +786,9 @@ impl InfoPopover { markdown.clone(), hover_markdown_style(window, cx), ) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + }) .on_url_click(open_markdown_url), ), ) @@ -885,6 +887,9 @@ impl DiagnosticPopover { markdown_div = markdown_div.child( MarkdownElement::new(markdown.clone(), markdown_style) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + }) .on_url_click(open_markdown_url), ); } diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index d4c9b87938..5e83049498 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -20,16 +20,15 @@ test-support = [ [dependencies] anyhow.workspace = true -file_icons.workspace = true gpui.workspace = true language.workspace = true linkify.workspace = true log.workspace = true pulldown-cmark.workspace = true +sum_tree.workspace = true theme.workspace = true ui.workspace = true util.workspace = true -workspace.workspace = true workspace-hack.workspace = true [dev-dependencies] diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 5aa70c0cba..7ac8cddaff 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1,12 +1,11 @@ pub mod parser; mod path_range; -use file_icons::FileIcons; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::iter; use std::mem; use std::ops::Range; -use std::path::PathBuf; +use std::path::Path; use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -21,10 +20,10 @@ use gpui::{ use language::{Language, LanguageRegistry, Rope}; use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown}; use pulldown_cmark::Alignment; +use sum_tree::TreeMap; use theme::SyntaxTheme; -use ui::{ButtonLike, Tooltip, prelude::*}; +use ui::{Tooltip, prelude::*}; use util::{ResultExt, TryFutureExt}; -use workspace::Workspace; use crate::parser::CodeBlockKind; @@ -84,12 +83,18 @@ pub struct Markdown { copied_code_blocks: HashSet, } -#[derive(Debug)] struct Options { parse_links_only: bool, - copy_code_block_buttons: bool, } +pub enum CodeBlockRenderer { + Default { copy_button: bool }, + Custom { render: CodeBlockRenderFn }, +} + +pub type CodeBlockRenderFn = + Arc, &mut Window, &App) -> Div>; + actions!(markdown, [Copy, CopyAsMarkdown]); impl Markdown { @@ -113,7 +118,6 @@ impl Markdown { fallback_code_block_language, options: Options { parse_links_only: false, - copy_code_block_buttons: true, }, copied_code_blocks: HashSet::new(), }; @@ -136,7 +140,6 @@ impl Markdown { fallback_code_block_language: None, options: Options { parse_links_only: true, - copy_code_block_buttons: true, }, copied_code_blocks: HashSet::new(), }; @@ -205,19 +208,19 @@ impl Markdown { return anyhow::Ok(ParsedMarkdown { events: Arc::from(parse_links_only(source.as_ref())), source, - languages_by_name: HashMap::default(), - languages_by_path: HashMap::default(), + languages_by_name: TreeMap::default(), + languages_by_path: TreeMap::default(), }); } let (events, language_names, paths) = parse_markdown(&source); - let mut languages_by_name = HashMap::with_capacity(language_names.len()); - let mut languages_by_path = HashMap::with_capacity(paths.len()); + let mut languages_by_name = TreeMap::default(); + let mut languages_by_path = TreeMap::default(); if let Some(registry) = language_registry.as_ref() { for name in language_names { let language = if !name.is_empty() { - registry.language_for_name(&name) + registry.language_for_name_or_extension(&name) } else if let Some(fallback) = &fallback { - registry.language_for_name(fallback) + registry.language_for_name_or_extension(fallback) } else { continue; }; @@ -259,11 +262,6 @@ impl Markdown { .await })); } - - pub fn copy_code_block_buttons(mut self, should_copy: bool) -> Self { - self.options.copy_code_block_buttons = should_copy; - self - } } impl Focusable for Markdown { @@ -302,12 +300,12 @@ impl Selection { } } -#[derive(Default)] +#[derive(Clone, Default)] pub struct ParsedMarkdown { - source: SharedString, - events: Arc<[(Range, MarkdownEvent)]>, - languages_by_name: HashMap>, - languages_by_path: HashMap>, + pub source: SharedString, + pub events: Arc<[(Range, MarkdownEvent)]>, + pub languages_by_name: TreeMap>, + pub languages_by_path: TreeMap, Arc>, } impl ParsedMarkdown { @@ -323,6 +321,7 @@ impl ParsedMarkdown { pub struct MarkdownElement { markdown: Entity, style: MarkdownStyle, + code_block_renderer: CodeBlockRenderer, on_url_click: Option>, } @@ -331,10 +330,16 @@ impl MarkdownElement { Self { markdown, style, + code_block_renderer: CodeBlockRenderer::Default { copy_button: true }, on_url_click: None, } } + pub fn code_block_renderer(mut self, variant: CodeBlockRenderer) -> Self { + self.code_block_renderer = variant; + self + } + pub fn on_url_click( mut self, handler: impl Fn(SharedString, &mut Window, &mut App) + 'static, @@ -589,7 +594,6 @@ impl Element for MarkdownElement { 0 }; - let code_citation_id = SharedString::from("code-citation-link"); for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { match event { MarkdownEvent::Start(tag) => { @@ -634,123 +638,80 @@ impl Element for MarkdownElement { CodeBlockKind::FencedLang(language) => { parsed_markdown.languages_by_name.get(language).cloned() } - CodeBlockKind::FencedSrc(path_range) => { - // If the path actually exists in the project, render a link to it. - if let Some(project_path) = - window.root::().flatten().and_then(|workspace| { - if path_range.path.is_absolute() { - return None; - } - - workspace - .read(cx) - .project() - .read(cx) - .find_project_path(&path_range.path, cx) - }) - { - builder.flush_text(); - - builder.push_div( - div().relative().w_full(), - range, - markdown_end, - ); - - builder.modify_current_div(|el| { - let file_icon = - FileIcons::get_icon(&project_path.path, cx) - .map(|path| { - Icon::from_path(path) - .color(Color::Muted) - .into_any_element() - }) - .unwrap_or_else(|| { - IconButton::new( - "file-path-icon", - IconName::File, - ) - .shape(ui::IconButtonShape::Square) - .into_any_element() - }); - - el.child( - ButtonLike::new(ElementId::NamedInteger( - code_citation_id.clone(), - index, - )) - .child( - div() - .mb_1() - .flex() - .items_center() - .gap_1() - .child(file_icon) - .child( - Label::new( - project_path - .path - .display() - .to_string(), - ) - .color(Color::Muted) - .underline(), - ), - ) - .on_click({ - let click_path = project_path.clone(); - move |_, window, cx| { - if let Some(workspace) = - window.root::().flatten() - { - workspace.update(cx, |workspace, cx| { - workspace - .open_path( - click_path.clone(), - None, - true, - window, - cx, - ) - .detach_and_log_err(cx); - }) - } - } - }), - ) - }); - - builder.pop_div(); - } - - parsed_markdown - .languages_by_path - .get(&path_range.path) - .cloned() - } + CodeBlockKind::FencedSrc(path_range) => parsed_markdown + .languages_by_path + .get(&path_range.path) + .cloned(), _ => None, }; - // This is a parent container that we can position the copy button inside. - builder.push_div(div().relative().w_full(), range, markdown_end); + let is_indented = matches!(kind, CodeBlockKind::Indented); - let mut code_block = div() - .id(("code-block", range.start)) - .rounded_lg() - .map(|mut code_block| { - if self.style.code_block_overflow_x_scroll { - code_block.style().restrict_scroll_to_axis = Some(true); - code_block.flex().overflow_x_scroll() - } else { - code_block.w_full() + match (&self.code_block_renderer, is_indented) { + (CodeBlockRenderer::Default { .. }, _) | (_, true) => { + // This is a parent container that we can position the copy button inside. + builder.push_div( + div().relative().w_full(), + range, + markdown_end, + ); + + let mut code_block = div() + .id(("code-block", range.start)) + .rounded_lg() + .map(|mut code_block| { + if self.style.code_block_overflow_x_scroll { + code_block.style().restrict_scroll_to_axis = + Some(true); + code_block.flex().overflow_x_scroll() + } else { + code_block.w_full() + } + }); + code_block.style().refine(&self.style.code_block); + if let Some(code_block_text_style) = &self.style.code_block.text + { + builder.push_text_style(code_block_text_style.to_owned()); } - }); - code_block.style().refine(&self.style.code_block); - if let Some(code_block_text_style) = &self.style.code_block.text { - builder.push_text_style(code_block_text_style.to_owned()); + builder.push_code_block(language); + builder.push_div(code_block, range, markdown_end); + } + (CodeBlockRenderer::Custom { render }, _) => { + let parent_container = render( + index, + kind, + &parsed_markdown, + range.clone(), + window, + cx, + ); + + builder.push_div(parent_container, range, markdown_end); + + let mut code_block = div() + .id(("code-block", range.start)) + .rounded_b_lg() + .map(|mut code_block| { + if self.style.code_block_overflow_x_scroll { + code_block.style().restrict_scroll_to_axis = + Some(true); + code_block.flex().overflow_x_scroll() + } else { + code_block.w_full() + } + }); + + code_block.style().refine(&self.style.code_block); + + if let Some(code_block_text_style) = &self.style.code_block.text + { + builder.push_text_style(code_block_text_style.to_owned()); + } + + builder.push_code_block(language); + builder.push_div(code_block, range, markdown_end); + } } - builder.push_code_block(language); - builder.push_div(code_block, range, markdown_end); } MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), MarkdownTag::List(bullet_index) => { @@ -885,61 +846,22 @@ impl Element for MarkdownElement { builder.pop_text_style(); } - if self.markdown.read(cx).options.copy_code_block_buttons { + if matches!( + &self.code_block_renderer, + CodeBlockRenderer::Default { copy_button: true } + ) { builder.flush_text(); builder.modify_current_div(|el| { - let id = - ElementId::NamedInteger("copy-markdown-code".into(), range.end); - let was_copied = - self.markdown.read(cx).copied_code_blocks.contains(&id); - let copy_button = div().absolute().top_1().right_1().w_5().child( - IconButton::new( - id.clone(), - if was_copied { - IconName::Check - } else { - IconName::Copy - }, - ) - .icon_color(Color::Muted) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) - .on_click({ - let id = id.clone(); - let markdown = self.markdown.clone(); - let code = without_fences( - parsed_markdown.source()[range.clone()].trim(), - ) + let code = + without_fences(parsed_markdown.source()[range.clone()].trim()) .to_string(); - move |_event, _window, cx| { - let id = id.clone(); - markdown.update(cx, |this, cx| { - this.copied_code_blocks.insert(id.clone()); - - cx.write_to_clipboard(ClipboardItem::new_string( - code.clone(), - )); - - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_secs(2)) - .await; - - cx.update(|cx| { - this.update(cx, |this, cx| { - this.copied_code_blocks.remove(&id); - cx.notify(); - }) - }) - .ok(); - }) - .detach(); - }); - } - }), + let codeblock = render_copy_code_block_button( + range.end, + code, + self.markdown.clone(), + cx, ); - - el.child(copy_button) + el.child(div().absolute().top_1().right_1().w_5().child(codeblock)) }); } @@ -1073,6 +995,52 @@ impl Element for MarkdownElement { } } +fn render_copy_code_block_button( + id: usize, + code: String, + markdown: Entity, + cx: &App, +) -> impl IntoElement { + let id = ElementId::NamedInteger("copy-markdown-code".into(), id); + let was_copied = markdown.read(cx).copied_code_blocks.contains(&id); + IconButton::new( + id.clone(), + if was_copied { + IconName::Check + } else { + IconName::Copy + }, + ) + .icon_color(Color::Muted) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Copy Code")) + .on_click({ + let id = id.clone(); + let markdown = markdown.clone(); + move |_event, _window, cx| { + let id = id.clone(); + markdown.update(cx, |this, cx| { + this.copied_code_blocks.insert(id.clone()); + + cx.write_to_clipboard(ClipboardItem::new_string(code.clone())); + + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + cx.update(|cx| { + this.update(cx, |this, cx| { + this.copied_code_blocks.remove(&id); + cx.notify(); + }) + }) + .ok(); + }) + .detach(); + }); + } + }) +} + impl IntoElement for MarkdownElement { type Element = Self; @@ -1529,7 +1497,7 @@ impl RenderedText { /// Some markdown blocks are indented, and others have e.g. ```rust … ``` around them. /// If this block is fenced with backticks, strip them off (and the language name). /// We use this when copying code blocks to the clipboard. -fn without_fences(mut markdown: &str) -> &str { +pub fn without_fences(mut markdown: &str) -> &str { if let Some(opening_backticks) = markdown.find("```") { markdown = &markdown[opening_backticks..]; diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index f649920be0..5d80b58041 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -7,10 +7,11 @@ use pulldown_cmark::{ use std::{ collections::HashSet, ops::{Deref, Range}, - path::PathBuf, + path::Path, + sync::Arc, }; -use crate::path_range::PathRange; +use crate::path_range::PathWithRange; const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_FOOTNOTES) @@ -27,7 +28,7 @@ pub fn parse_markdown( ) -> ( Vec<(Range, MarkdownEvent)>, HashSet, - HashSet, + HashSet>, ) { let mut events = Vec::new(); let mut language_names = HashSet::new(); @@ -73,7 +74,7 @@ pub fn parse_markdown( // Languages should never contain a slash, and PathRanges always should. // (Models are told to specify them relative to a workspace root.) } else if info.contains('/') { - let path_range = PathRange::new(info); + let path_range = PathWithRange::new(info); language_paths.insert(path_range.path.clone()); CodeBlockKind::FencedSrc(path_range) } else { @@ -332,7 +333,7 @@ pub enum CodeBlockKind { /// e.g. ```path/to/foo.rs#L123-456 instead of ```rust Fenced, FencedLang(SharedString), - FencedSrc(PathRange), + FencedSrc(PathWithRange), } impl From> for MarkdownTag { @@ -378,7 +379,7 @@ impl From> for MarkdownTag { } else if info.contains('/') { // Languages should never contain a slash, and PathRanges always should. // (Models are told to specify them relative to a workspace root.) - CodeBlockKind::FencedSrc(PathRange::new(info)) + CodeBlockKind::FencedSrc(PathWithRange::new(info)) } else { CodeBlockKind::FencedLang(SharedString::from(info.to_string())) }) diff --git a/crates/markdown/src/path_range.rs b/crates/markdown/src/path_range.rs index 7e7700a58f..2bb757b389 100644 --- a/crates/markdown/src/path_range.rs +++ b/crates/markdown/src/path_range.rs @@ -1,8 +1,8 @@ -use std::{ops::Range, path::PathBuf}; +use std::{ops::Range, path::Path, sync::Arc}; #[derive(Debug, Clone, PartialEq)] -pub struct PathRange { - pub path: PathBuf, +pub struct PathWithRange { + pub path: Arc, pub range: Option>, } @@ -31,7 +31,7 @@ impl LineCol { } } -impl PathRange { +impl PathWithRange { pub fn new(str: impl AsRef) -> Self { let str = str.as_ref(); // Sometimes the model will include a language at the start, @@ -55,12 +55,12 @@ impl PathRange { }; Self { - path: PathBuf::from(path), + path: Path::new(path).into(), range, } } None => Self { - path: str.into(), + path: Path::new(str).into(), range: None, }, } @@ -99,8 +99,8 @@ mod tests { #[test] fn test_pathrange_parsing() { - let path_range = PathRange::new("file.rs#L10-L20"); - assert_eq!(path_range.path, PathBuf::from("file.rs")); + let path_range = PathWithRange::new("file.rs#L10-L20"); + assert_eq!(path_range.path.as_ref(), Path::new("file.rs")); assert!(path_range.range.is_some()); if let Some(range) = path_range.range { assert_eq!(range.start.line, 10); @@ -109,78 +109,78 @@ mod tests { assert_eq!(range.end.col, None); } - let single_line = PathRange::new("file.rs#L15"); - assert_eq!(single_line.path, PathBuf::from("file.rs")); + let single_line = PathWithRange::new("file.rs#L15"); + assert_eq!(single_line.path.as_ref(), Path::new("file.rs")); assert!(single_line.range.is_some()); if let Some(range) = single_line.range { assert_eq!(range.start.line, 15); assert_eq!(range.end.line, 15); } - let no_range = PathRange::new("file.rs"); - assert_eq!(no_range.path, PathBuf::from("file.rs")); + let no_range = PathWithRange::new("file.rs"); + assert_eq!(no_range.path.as_ref(), Path::new("file.rs")); assert!(no_range.range.is_none()); - let lowercase = PathRange::new("file.rs#l5-l10"); - assert_eq!(lowercase.path, PathBuf::from("file.rs")); + let lowercase = PathWithRange::new("file.rs#l5-l10"); + assert_eq!(lowercase.path.as_ref(), Path::new("file.rs")); assert!(lowercase.range.is_some()); if let Some(range) = lowercase.range { assert_eq!(range.start.line, 5); assert_eq!(range.end.line, 10); } - let complex = PathRange::new("src/path/to/file.rs#L100"); - assert_eq!(complex.path, PathBuf::from("src/path/to/file.rs")); + let complex = PathWithRange::new("src/path/to/file.rs#L100"); + assert_eq!(complex.path.as_ref(), Path::new("src/path/to/file.rs")); assert!(complex.range.is_some()); } #[test] fn test_pathrange_from_str() { - let with_range = PathRange::new("file.rs#L10-L20"); + let with_range = PathWithRange::new("file.rs#L10-L20"); assert!(with_range.range.is_some()); - assert_eq!(with_range.path, PathBuf::from("file.rs")); + assert_eq!(with_range.path.as_ref(), Path::new("file.rs")); - let without_range = PathRange::new("file.rs"); + let without_range = PathWithRange::new("file.rs"); assert!(without_range.range.is_none()); - let single_line = PathRange::new("file.rs#L15"); + let single_line = PathWithRange::new("file.rs#L15"); assert!(single_line.range.is_some()); } #[test] fn test_pathrange_leading_text_trimming() { - let with_language = PathRange::new("```rust file.rs#L10"); - assert_eq!(with_language.path, PathBuf::from("file.rs")); + let with_language = PathWithRange::new("```rust file.rs#L10"); + assert_eq!(with_language.path.as_ref(), Path::new("file.rs")); assert!(with_language.range.is_some()); if let Some(range) = with_language.range { assert_eq!(range.start.line, 10); } - let with_spaces = PathRange::new("``` file.rs#L10-L20"); - assert_eq!(with_spaces.path, PathBuf::from("file.rs")); + let with_spaces = PathWithRange::new("``` file.rs#L10-L20"); + assert_eq!(with_spaces.path.as_ref(), Path::new("file.rs")); assert!(with_spaces.range.is_some()); - let with_words = PathRange::new("```rust code example file.rs#L15:10"); - assert_eq!(with_words.path, PathBuf::from("file.rs")); + let with_words = PathWithRange::new("```rust code example file.rs#L15:10"); + assert_eq!(with_words.path.as_ref(), Path::new("file.rs")); assert!(with_words.range.is_some()); if let Some(range) = with_words.range { assert_eq!(range.start.line, 15); assert_eq!(range.start.col, Some(10)); } - let with_whitespace = PathRange::new(" file.rs#L5"); - assert_eq!(with_whitespace.path, PathBuf::from("file.rs")); + let with_whitespace = PathWithRange::new(" file.rs#L5"); + assert_eq!(with_whitespace.path.as_ref(), Path::new("file.rs")); assert!(with_whitespace.range.is_some()); - let no_leading = PathRange::new("file.rs#L10"); - assert_eq!(no_leading.path, PathBuf::from("file.rs")); + let no_leading = PathWithRange::new("file.rs#L10"); + assert_eq!(no_leading.path.as_ref(), Path::new("file.rs")); assert!(no_leading.range.is_some()); } #[test] fn test_pathrange_with_line_and_column() { - let line_and_col = PathRange::new("file.rs#L10:5"); - assert_eq!(line_and_col.path, PathBuf::from("file.rs")); + let line_and_col = PathWithRange::new("file.rs#L10:5"); + assert_eq!(line_and_col.path.as_ref(), Path::new("file.rs")); assert!(line_and_col.range.is_some()); if let Some(range) = line_and_col.range { assert_eq!(range.start.line, 10); @@ -189,8 +189,8 @@ mod tests { assert_eq!(range.end.col, Some(5)); } - let full_range = PathRange::new("file.rs#L10:5-L20:15"); - assert_eq!(full_range.path, PathBuf::from("file.rs")); + let full_range = PathWithRange::new("file.rs#L10:5-L20:15"); + assert_eq!(full_range.path.as_ref(), Path::new("file.rs")); assert!(full_range.range.is_some()); if let Some(range) = full_range.range { assert_eq!(range.start.line, 10); @@ -199,8 +199,8 @@ mod tests { assert_eq!(range.end.col, Some(15)); } - let mixed_range1 = PathRange::new("file.rs#L10:5-L20"); - assert_eq!(mixed_range1.path, PathBuf::from("file.rs")); + let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20"); + assert_eq!(mixed_range1.path.as_ref(), Path::new("file.rs")); assert!(mixed_range1.range.is_some()); if let Some(range) = mixed_range1.range { assert_eq!(range.start.line, 10); @@ -209,8 +209,8 @@ mod tests { assert_eq!(range.end.col, None); } - let mixed_range2 = PathRange::new("file.rs#L10-L20:15"); - assert_eq!(mixed_range2.path, PathBuf::from("file.rs")); + let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15"); + assert_eq!(mixed_range2.path.as_ref(), Path::new("file.rs")); assert!(mixed_range2.range.is_some()); if let Some(range) = mixed_range2.range { assert_eq!(range.start.line, 10);