From 3ef8a9910d0da47f53be239bd1e5473a2f6410ce Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Date: Tue, 20 Feb 2024 05:49:47 +0100 Subject: [PATCH] chat: auto detect links (#8028) @ConradIrwin here's our current implementation for auto detecting links in the chat. We also fixed an edge case where the close reply to preview button was cut off (rendered off screen). Release Notes: - Added auto detection for links in the chat panel. --------- Co-authored-by: Remco Smits <62463826+RemcoSmitsDev@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + crates/collab_ui/src/chat_panel.rs | 116 +++++++++++++++++++++++++++-- crates/editor/Cargo.toml | 2 +- crates/rich_text/Cargo.toml | 1 + crates/rich_text/src/rich_text.rs | 46 ++++++++++-- 6 files changed, 155 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce5ea35821..1125f84052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7555,6 +7555,7 @@ dependencies = [ "gpui", "language", "lazy_static", + "linkify", "pulldown-cmark", "smallvec", "smol", diff --git a/Cargo.toml b/Cargo.toml index 77f91e3e0c..84e585d980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -199,6 +199,7 @@ indoc = "1" # We explicitly disable a http2 support in isahc. isahc = { version = "1.7.2", default-features = false, features = ["static-curl", "text-decoding"] } lazy_static = "1.4.0" +linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } ordered-float = "2.1.1" parking_lot = "0.11.1" diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index afbfe6a476..5c961e4535 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -371,9 +371,9 @@ impl ChatPanel { .px_1() .py_0p5() .mb_1() - .overflow_hidden() .child( div() + .overflow_hidden() .max_h_12() .child(reply_to_message_body.element(body_element_id, cx)), ), @@ -840,18 +840,21 @@ impl Render for ChatPanel { el.when_some(reply_message, |el, reply_message| { el.child( - div() + h_flex() .when(!self.is_scrolled_to_bottom, |el| { el.border_t_1().border_color(cx.theme().colors().border) }) - .flex() - .w_full() - .items_start() + .justify_between() .overflow_hidden() + .items_start() .py_1() .px_2() .bg(cx.theme().colors().background) - .child(self.render_replied_to_message(None, &reply_message, cx)) + .child( + div().flex_shrink().overflow_hidden().child( + self.render_replied_to_message(None, &reply_message, cx), + ), + ) .child( IconButton::new("close-reply-preview", IconName::Close) .shape(ui::IconButtonShape::Square) @@ -1094,6 +1097,107 @@ mod tests { ); } + #[gpui::test] + fn test_render_markdown_with_auto_detect_links() { + let language_registry = Arc::new(LanguageRegistry::test()); + let message = channel::ChannelMessage { + id: ChannelMessageId::Saved(0), + body: "Here is a link https://zed.dev to zeds website".to_string(), + timestamp: OffsetDateTime::now_utc(), + sender: Arc::new(client::User { + github_login: "fgh".into(), + avatar_uri: "avatar_fgh".into(), + id: 103, + }), + nonce: 5, + mentions: Vec::new(), + reply_to_message_id: None, + }; + + let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); + + // Note that the "'" was replaced with ’ due to smart punctuation. + let (body, ranges) = + marked_text_ranges("Here is a link «https://zed.dev» to zeds website", false); + assert_eq!(message.text, body); + assert_eq!(1, ranges.len()); + assert_eq!( + message.highlights, + vec![( + ranges[0].clone(), + HighlightStyle { + underline: Some(gpui::UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + } + .into() + ),] + ); + } + + #[gpui::test] + fn test_render_markdown_with_auto_detect_links_and_additional_formatting() { + let language_registry = Arc::new(LanguageRegistry::test()); + let message = channel::ChannelMessage { + id: ChannelMessageId::Saved(0), + body: "**Here is a link https://zed.dev to zeds website**".to_string(), + timestamp: OffsetDateTime::now_utc(), + sender: Arc::new(client::User { + github_login: "fgh".into(), + avatar_uri: "avatar_fgh".into(), + id: 103, + }), + nonce: 5, + mentions: Vec::new(), + reply_to_message_id: None, + }; + + let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message); + + // Note that the "'" was replaced with ’ due to smart punctuation. + let (body, ranges) = marked_text_ranges( + "«Here is a link »«https://zed.dev»« to zeds website»", + false, + ); + assert_eq!(message.text, body); + assert_eq!(3, ranges.len()); + assert_eq!( + message.highlights, + vec![ + ( + ranges[0].clone(), + HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + } + .into() + ), + ( + ranges[1].clone(), + HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + underline: Some(gpui::UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + } + .into() + ), + ( + ranges[2].clone(), + HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + } + .into() + ), + ] + ); + } + #[test] fn test_format_locale() { let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0); diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index a838dd6572..2e2143567d 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -40,7 +40,7 @@ indoc = "1.0.4" itertools = "0.10" language.workspace = true lazy_static.workspace = true -linkify = "0.10.0" +linkify.workspace = true log.workspace = true lsp.workspace = true multi_buffer.workspace = true diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml index b9f765d123..6576b5ec4c 100644 --- a/crates/rich_text/Cargo.toml +++ b/crates/rich_text/Cargo.toml @@ -22,6 +22,7 @@ futures.workspace = true gpui.workspace = true language.workspace = true lazy_static.workspace = true +linkify.workspace = true pulldown-cmark.workspace = true smallvec.workspace = true smol.workspace = true diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs index 1063d89db3..4f61cd5b9a 100644 --- a/crates/rich_text/src/rich_text.rs +++ b/crates/rich_text/src/rich_text.rs @@ -175,19 +175,54 @@ pub fn render_markdown_mut( if italic_depth > 0 { style.font_style = Some(FontStyle::Italic); } - if let Some(link_url) = link_url.clone() { + let last_run_len = if let Some(link_url) = link_url.clone() { link_ranges.push(prev_len..text.len()); link_urls.push(link_url); style.underline = Some(UnderlineStyle { thickness: 1.0.into(), ..Default::default() }); - } + prev_len + } else { + // Manually scan for links + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + let mut last_link_len = prev_len; + for link in finder.links(&t) { + let start = link.start(); + let end = link.end(); + let range = (prev_len + start)..(prev_len + end); + link_ranges.push(range.clone()); + link_urls.push(link.as_str().to_string()); - if style != HighlightStyle::default() { + // If there is a style before we match a link, we have to add this to the highlighted ranges + if style != HighlightStyle::default() && last_link_len < link.start() { + highlights.push(( + last_link_len..link.start(), + Highlight::Highlight(style), + )); + } + + highlights.push(( + range, + Highlight::Highlight(HighlightStyle { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..style + }), + )); + + last_link_len = end; + } + last_link_len + }; + + if style != HighlightStyle::default() && last_run_len < text.len() { let mut new_highlight = true; if let Some((last_range, last_style)) = highlights.last_mut() { - if last_range.end == prev_len + if last_range.end == last_run_len && last_style == &Highlight::Highlight(style) { last_range.end = text.len(); @@ -195,7 +230,8 @@ pub fn render_markdown_mut( } } if new_highlight { - highlights.push((prev_len..text.len(), Highlight::Highlight(style))); + highlights + .push((last_run_len..text.len(), Highlight::Highlight(style))); } } }