Detailed changes
@@ -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"
@@ -46,6 +46,7 @@ members = [
"crates/lsp",
"crates/media",
"crates/menu",
+ "crates/markdown_element",
"crates/node_runtime",
"crates/outline",
"crates/picker",
@@ -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),
@@ -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"}
@@ -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<Client>,
channel_store: ModelHandle<ChannelStore>,
+ languages: Arc<LanguageRegistry>,
active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
message_list: ListState<ChatPanel>,
input_editor: ViewHandle<Editor>,
@@ -47,6 +50,7 @@ pub struct ChatPanel {
subscriptions: Vec<gpui::Subscription>,
workspace: WeakViewHandle<Workspace>,
has_focus: bool,
+ markdown_data: HashMap<ChannelMessageId, Arc<MarkdownData>>,
}
#[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<Self>) -> AnyElement<Self> {
+ fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
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::<DeleteMessage, _>(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);
})
})
@@ -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
@@ -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<usize>, Highlight)>,
+ region_ranges: Vec<Range<usize>>,
+ regions: Vec<RenderedRegion>,
+}
+
+#[derive(Debug, Clone)]
+struct RenderedRegion {
+ code: bool,
+ link_url: Option<String>,
+}
+
+pub struct MarkdownElement {
+ data: Arc<MarkdownData>,
+ syntax: Arc<SyntaxTheme>,
+ style: TextStyle,
+ code_span_background_color: Color,
+}
+
+impl MarkdownElement {
+ pub fn new(
+ data: Arc<MarkdownData>,
+ style: TextStyle,
+ syntax: Arc<SyntaxTheme>,
+ code_span_background_color: Color,
+ ) -> Self {
+ Self {
+ data,
+ style,
+ syntax,
+ code_span_background_color,
+ }
+ }
+}
+
+impl<V: 'static> Element<V> for MarkdownElement {
+ type LayoutState = AnyElement<V>;
+
+ type PaintState = ();
+
+ fn layout(
+ &mut self,
+ constraint: gpui::SizeConstraint,
+ view: &mut V,
+ cx: &mut gpui::ViewContext<V>,
+ ) -> (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::<Vec<_>>(),
+ )
+ .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::<Self>(view_id, region_id, bounds)
+ .on_click::<V, _>(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<V>,
+ ) -> Self::PaintState {
+ layout.paint(bounds.origin(), visible_bounds, view, cx);
+ }
+
+ fn rect_for_text_range(
+ &self,
+ range_utf16: std::ops::Range<usize>,
+ _: gpui::geometry::rect::RectF,
+ _: gpui::geometry::rect::RectF,
+ layout: &Self::LayoutState,
+ _: &Self::PaintState,
+ view: &V,
+ cx: &gpui::ViewContext<V>,
+ ) -> Option<gpui::geometry::rect::RectF> {
+ 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<V>,
+ ) -> gpui::serde_json::Value {
+ layout.debug(view, cx)
+ }
+}
+
+pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>) -> 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<usize>, Highlight)>,
+ content: &str,
+ language: &Arc<Language>,
+) {
+ 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<u64>, 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(" ");
+ }
+}