diff --git a/Cargo.lock b/Cargo.lock index b5c1dee79d..3eebf5e3d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,6 +1558,7 @@ dependencies = [ "gpui", "language", "log", + "markdown_element", "menu", "picker", "postage", @@ -4323,6 +4324,24 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown_element" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "futures 0.3.28", + "gpui", + "language", + "lazy_static", + "pulldown-cmark", + "smallvec", + "smol", + "sum_tree", + "theme", + "util", +] + [[package]] name = "matchers" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 05a013a4e0..bc04baa17c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "crates/lsp", "crates/media", "crates/menu", + "crates/markdown_element", "crates/node_runtime", "crates/outline", "crates/picker", diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 29a260ea7e..734182886b 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -36,7 +36,7 @@ pub struct ChannelMessage { pub nonce: u128, } -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ChannelMessageId { Saved(u64), Pending(usize), diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index b6e45471f1..613ad4c7f5 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -37,6 +37,7 @@ fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } language = { path = "../language" } menu = { path = "../menu" } +markdown_element = { path = "../markdown_element" } picker = { path = "../picker" } project = { path = "../project" } recent_projects = {path = "../recent_projects"} diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 41bc5fbd08..0dbcde31ed 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -3,6 +3,7 @@ use anyhow::Result; use call::ActiveCall; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; @@ -15,7 +16,8 @@ use gpui::{ AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; -use language::language_settings::SoftWrap; +use language::{language_settings::SoftWrap, LanguageRegistry}; +use markdown_element::{MarkdownData, MarkdownElement}; use menu::Confirm; use project::Fs; use serde::{Deserialize, Serialize}; @@ -35,6 +37,7 @@ const CHAT_PANEL_KEY: &'static str = "ChatPanel"; pub struct ChatPanel { client: Arc, channel_store: ModelHandle, + languages: Arc, active_chat: Option<(ModelHandle, Subscription)>, message_list: ListState, input_editor: ViewHandle, @@ -47,6 +50,7 @@ pub struct ChatPanel { subscriptions: Vec, workspace: WeakViewHandle, has_focus: bool, + markdown_data: HashMap>, } #[derive(Serialize, Deserialize)] @@ -78,6 +82,7 @@ impl ChatPanel { let fs = workspace.app_state().fs.clone(); let client = workspace.app_state().client.clone(); let channel_store = workspace.app_state().channel_store.clone(); + let languages = workspace.app_state().languages.clone(); let input_editor = cx.add_view(|cx| { let mut editor = Editor::auto_height( @@ -130,6 +135,7 @@ impl ChatPanel { fs, client, channel_store, + languages, active_chat: Default::default(), pending_serialization: Task::ready(None), @@ -142,6 +148,7 @@ impl ChatPanel { workspace: workspace_handle, active: false, width: None, + markdown_data: Default::default(), }; let mut old_dock_position = this.position(cx); @@ -178,6 +185,25 @@ impl ChatPanel { }) .detach(); + let markdown = this.languages.language_for_name("Markdown"); + cx.spawn(|this, mut cx| async move { + let markdown = markdown.await?; + + this.update(&mut cx, |this, cx| { + this.input_editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multi_buffer, cx| { + multi_buffer + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)) + }) + }) + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + this }) } @@ -328,7 +354,7 @@ impl ChatPanel { messages.flex(1., true).into_any() } - fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { let (message, is_continuation, is_last) = { let active_chat = self.active_chat.as_ref().unwrap().0.read(cx); let last_message = active_chat.message(ix.saturating_sub(1)); @@ -343,6 +369,13 @@ impl ChatPanel { ) }; + let markdown = self.markdown_data.entry(message.id).or_insert_with(|| { + Arc::new(markdown_element::render_markdown( + message.body.clone(), + &self.languages, + )) + }); + let now = OffsetDateTime::now_utc(); let theme = theme::current(cx); let style = if message.is_pending() { @@ -363,10 +396,14 @@ impl ChatPanel { enum DeleteMessage {} - let body = message.body.clone(); if is_continuation { Flex::row() - .with_child(Text::new(body, style.body.clone())) + .with_child(MarkdownElement::new( + markdown.clone(), + style.body.clone(), + theme.editor.syntax.clone(), + theme.editor.document_highlight_read_background, + )) .with_children(message_id_to_remove.map(|id| { MouseEventHandler::new::(id as usize, cx, |mouse_state, _| { let button_style = theme.chat_panel.icon_button.style_for(mouse_state); @@ -451,7 +488,12 @@ impl ChatPanel { })) .align_children_center(), ) - .with_child(Text::new(body, style.body.clone())) + .with_child(MarkdownElement::new( + markdown.clone(), + style.body.clone(), + theme.editor.syntax.clone(), + theme.editor.document_highlight_read_background, + )) .contained() .with_style(style.container) .with_margin_bottom(if is_last { @@ -634,6 +676,7 @@ impl ChatPanel { cx.spawn(|this, mut cx| async move { let chat = open_chat.await?; this.update(&mut cx, |this, cx| { + this.markdown_data = Default::default(); this.set_active_chat(chat, cx); }) }) diff --git a/crates/markdown_element/Cargo.toml b/crates/markdown_element/Cargo.toml new file mode 100644 index 0000000000..920a3c3005 --- /dev/null +++ b/crates/markdown_element/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "markdown_element" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/markdown_element.rs" +doctest = false + +[features] +test-support = [ + "gpui/test-support", + "util/test-support", +] + + +[dependencies] +collections = { path = "../collections" } +gpui = { path = "../gpui" } +sum_tree = { path = "../sum_tree" } +theme = { path = "../theme" } +language = { path = "../language" } +util = { path = "../util" } +anyhow.workspace = true +futures.workspace = true +lazy_static.workspace = true +pulldown-cmark = { version = "0.9.2", default-features = false } +smallvec.workspace = true +smol.workspace = true diff --git a/crates/markdown_element/src/markdown_element.rs b/crates/markdown_element/src/markdown_element.rs new file mode 100644 index 0000000000..66011d2a25 --- /dev/null +++ b/crates/markdown_element/src/markdown_element.rs @@ -0,0 +1,339 @@ +use std::{ops::Range, sync::Arc}; + +use futures::FutureExt; +use gpui::{ + color::Color, + elements::Text, + fonts::{HighlightStyle, TextStyle, Underline, Weight}, + platform::{CursorStyle, MouseButton}, + AnyElement, CursorRegion, Element, MouseRegion, +}; +use language::{HighlightId, Language, LanguageRegistry}; +use theme::SyntaxTheme; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Highlight { + Id(HighlightId), + Highlight(HighlightStyle), +} + +#[derive(Debug, Clone)] +pub struct MarkdownData { + text: String, + highlights: Vec<(Range, Highlight)>, + region_ranges: Vec>, + regions: Vec, +} + +#[derive(Debug, Clone)] +struct RenderedRegion { + code: bool, + link_url: Option, +} + +pub struct MarkdownElement { + data: Arc, + syntax: Arc, + style: TextStyle, + code_span_background_color: Color, +} + +impl MarkdownElement { + pub fn new( + data: Arc, + style: TextStyle, + syntax: Arc, + code_span_background_color: Color, + ) -> Self { + Self { + data, + style, + syntax, + code_span_background_color, + } + } +} + +impl Element for MarkdownElement { + type LayoutState = AnyElement; + + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + view: &mut V, + cx: &mut gpui::ViewContext, + ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) { + let mut region_id = 0; + let view_id = cx.view_id(); + + let code_span_background_color = self.code_span_background_color; + let data = self.data.clone(); + let mut element = Text::new(self.data.text.clone(), self.style.clone()) + .with_highlights( + self.data + .highlights + .iter() + .filter_map(|(range, highlight)| { + let style = match highlight { + Highlight::Id(id) => id.style(&self.syntax)?, + Highlight::Highlight(style) => style.clone(), + }; + Some((range.clone(), style)) + }) + .collect::>(), + ) + .with_custom_runs(self.data.region_ranges.clone(), move |ix, bounds, cx| { + region_id += 1; + let region = data.regions[ix].clone(); + if let Some(url) = region.link_url { + cx.scene().push_cursor_region(CursorRegion { + bounds, + style: CursorStyle::PointingHand, + }); + cx.scene().push_mouse_region( + MouseRegion::new::(view_id, region_id, bounds) + .on_click::(MouseButton::Left, move |_, _, cx| { + cx.platform().open_url(&url) + }), + ); + } + if region.code { + cx.scene().push_quad(gpui::Quad { + bounds, + background: Some(code_span_background_color), + border: Default::default(), + corner_radii: (2.0).into(), + }); + } + }) + .with_soft_wrap(true) + .into_any(); + + let constraint = element.layout(constraint, view, cx); + + (constraint, element) + } + + fn paint( + &mut self, + bounds: gpui::geometry::rect::RectF, + visible_bounds: gpui::geometry::rect::RectF, + layout: &mut Self::LayoutState, + view: &mut V, + cx: &mut gpui::ViewContext, + ) -> Self::PaintState { + layout.paint(bounds.origin(), visible_bounds, view, cx); + } + + fn rect_for_text_range( + &self, + range_utf16: std::ops::Range, + _: gpui::geometry::rect::RectF, + _: gpui::geometry::rect::RectF, + layout: &Self::LayoutState, + _: &Self::PaintState, + view: &V, + cx: &gpui::ViewContext, + ) -> Option { + layout.rect_for_text_range(range_utf16, view, cx) + } + + fn debug( + &self, + _: gpui::geometry::rect::RectF, + layout: &Self::LayoutState, + _: &Self::PaintState, + view: &V, + cx: &gpui::ViewContext, + ) -> gpui::serde_json::Value { + layout.debug(view, cx) + } +} + +pub fn render_markdown(block: String, language_registry: &Arc) -> MarkdownData { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut region_ranges = Vec::new(); + let mut regions = Vec::new(); + + use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; + + let mut bold_depth = 0; + let mut italic_depth = 0; + let mut link_url = None; + let mut current_language = None; + let mut list_stack = Vec::new(); + + for event in Parser::new_ext(&block, Options::all()) { + let prev_len = text.len(); + match event { + Event::Text(t) => { + if let Some(language) = ¤t_language { + render_code(&mut text, &mut highlights, t.as_ref(), language); + } else { + text.push_str(t.as_ref()); + + let mut style = HighlightStyle::default(); + if bold_depth > 0 { + style.weight = Some(Weight::BOLD); + } + if italic_depth > 0 { + style.italic = Some(true); + } + if let Some(link_url) = link_url.clone() { + region_ranges.push(prev_len..text.len()); + regions.push(RenderedRegion { + link_url: Some(link_url), + code: false, + }); + style.underline = Some(Underline { + thickness: 1.0.into(), + ..Default::default() + }); + } + + if style != HighlightStyle::default() { + let mut new_highlight = true; + if let Some((last_range, last_style)) = highlights.last_mut() { + if last_range.end == prev_len + && last_style == &Highlight::Highlight(style) + { + last_range.end = text.len(); + new_highlight = false; + } + } + if new_highlight { + highlights.push((prev_len..text.len(), Highlight::Highlight(style))); + } + } + } + } + Event::Code(t) => { + text.push_str(t.as_ref()); + region_ranges.push(prev_len..text.len()); + if link_url.is_some() { + highlights.push(( + prev_len..text.len(), + Highlight::Highlight(HighlightStyle { + underline: Some(Underline { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }), + )); + } + regions.push(RenderedRegion { + code: true, + link_url: link_url.clone(), + }); + } + Event::Start(tag) => match tag { + Tag::Paragraph => new_paragraph(&mut text, &mut list_stack), + Tag::Heading(_, _, _) => { + new_paragraph(&mut text, &mut list_stack); + bold_depth += 1; + } + Tag::CodeBlock(kind) => { + new_paragraph(&mut text, &mut list_stack); + current_language = if let CodeBlockKind::Fenced(language) = kind { + language_registry + .language_for_name(language.as_ref()) + .now_or_never() + .and_then(Result::ok) + } else { + None + } + } + Tag::Emphasis => italic_depth += 1, + Tag::Strong => bold_depth += 1, + Tag::Link(_, url, _) => link_url = Some(url.to_string()), + Tag::List(number) => { + list_stack.push((number, false)); + } + Tag::Item => { + let len = list_stack.len(); + if let Some((list_number, has_content)) = list_stack.last_mut() { + *has_content = false; + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + for _ in 0..len - 1 { + text.push_str(" "); + } + if let Some(number) = list_number { + text.push_str(&format!("{}. ", number)); + *number += 1; + *has_content = false; + } else { + text.push_str("- "); + } + } + } + _ => {} + }, + Event::End(tag) => match tag { + Tag::Heading(_, _, _) => bold_depth -= 1, + Tag::CodeBlock(_) => current_language = None, + Tag::Emphasis => italic_depth -= 1, + Tag::Strong => bold_depth -= 1, + Tag::Link(_, _, _) => link_url = None, + Tag::List(_) => drop(list_stack.pop()), + _ => {} + }, + Event::HardBreak => text.push('\n'), + Event::SoftBreak => text.push(' '), + _ => {} + } + } + + MarkdownData { + text: text.trim().to_string(), + highlights, + region_ranges, + regions, + } +} + +fn render_code( + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + content: &str, + language: &Arc, +) { + let prev_len = text.len(); + text.push_str(content); + for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) { + highlights.push(( + prev_len + range.start..prev_len + range.end, + Highlight::Id(highlight_id), + )); + } +} + +fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option, bool)>) { + let mut is_subsequent_paragraph_of_list = false; + if let Some((_, has_content)) = list_stack.last_mut() { + if *has_content { + is_subsequent_paragraph_of_list = true; + } else { + *has_content = true; + return; + } + } + + if !text.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push('\n'); + } + for _ in 0..list_stack.len().saturating_sub(1) { + text.push_str(" "); + } + if is_subsequent_paragraph_of_list { + text.push_str(" "); + } +}