markdown preview: highlight code blocks (#9087)

Bennet Bo Fenner created

![image](https://github.com/zed-industries/zed/assets/53836821/e20acd87-9680-4e1c-818d-7ae900bf0e31)

Release Notes:

- Added syntax highlighting to code blocks in markdown preview
- Fixed scroll position in markdown preview when editing a markdown file
(#9208)

Change summary

Cargo.lock                                           |   1 
Cargo.toml                                           |   1 
crates/auto_update/src/auto_update.rs                |   2 
crates/markdown_preview/Cargo.toml                   |   1 
crates/markdown_preview/src/markdown_elements.rs     |   1 
crates/markdown_preview/src/markdown_parser.rs       | 223 +++++++++----
crates/markdown_preview/src/markdown_preview_view.rs | 147 ++++++---
crates/markdown_preview/src/markdown_renderer.rs     |  16 
crates/workspace/Cargo.toml                          |   2 
9 files changed, 266 insertions(+), 128 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5643,6 +5643,7 @@ dependencies = [
 name = "markdown_preview"
 version = "0.1.0"
 dependencies = [
+ "async-recursion 1.0.5",
  "editor",
  "gpui",
  "language",

Cargo.toml 🔗

@@ -198,6 +198,7 @@ zed_actions = { path = "crates/zed_actions" }
 
 anyhow = "1.0.57"
 async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+async-recursion = "1.0.0"
 async-tar = "0.4.2"
 async-trait = "0.1"
 bitflags = "2.4.2"

crates/auto_update/src/auto_update.rs 🔗

@@ -229,6 +229,7 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Wo
                             buffer.update(cx, |buffer, cx| {
                                 buffer.edit([(0..0, body.release_notes)], None, cx)
                             });
+                            let language_registry = project.read(cx).languages().clone();
 
                             let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
 
@@ -240,6 +241,7 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Wo
                                 editor,
                                 workspace_handle,
                                 Some(tab_description),
+                                language_registry,
                                 cx,
                             );
                             workspace.add_item_to_active_pane(Box::new(view.clone()), cx);

crates/markdown_preview/Cargo.toml 🔗

@@ -15,6 +15,7 @@ path = "src/markdown_preview.rs"
 test-support = []
 
 [dependencies]
+async-recursion.workspace = true
 editor.workspace = true
 gpui.workspace = true
 language.workspace = true

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -68,6 +68,7 @@ pub struct ParsedMarkdownCodeBlock {
     pub source_range: Range<usize>,
     pub language: Option<String>,
     pub contents: SharedString,
+    pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
 }
 
 #[derive(Debug)]

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -1,16 +1,23 @@
 use crate::markdown_elements::*;
+use async_recursion::async_recursion;
 use gpui::FontWeight;
+use language::LanguageRegistry;
 use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
-use std::{ops::Range, path::PathBuf};
+use std::{ops::Range, path::PathBuf, sync::Arc};
 
-pub fn parse_markdown(
+pub async fn parse_markdown(
     markdown_input: &str,
     file_location_directory: Option<PathBuf>,
+    language_registry: Option<Arc<LanguageRegistry>>,
 ) -> ParsedMarkdown {
     let options = Options::all();
     let parser = Parser::new_ext(markdown_input, options);
-    let parser = MarkdownParser::new(parser.into_offset_iter().collect(), file_location_directory);
-    let renderer = parser.parse_document();
+    let parser = MarkdownParser::new(
+        parser.into_offset_iter().collect(),
+        file_location_directory,
+        language_registry,
+    );
+    let renderer = parser.parse_document().await;
     ParsedMarkdown {
         children: renderer.parsed,
     }
@@ -23,16 +30,19 @@ struct MarkdownParser<'a> {
     /// The blocks that we have successfully parsed so far
     parsed: Vec<ParsedMarkdownElement>,
     file_location_directory: Option<PathBuf>,
+    language_registry: Option<Arc<LanguageRegistry>>,
 }
 
 impl<'a> MarkdownParser<'a> {
     fn new(
         tokens: Vec<(Event<'a>, Range<usize>)>,
         file_location_directory: Option<PathBuf>,
+        language_registry: Option<Arc<LanguageRegistry>>,
     ) -> Self {
         Self {
             tokens,
             file_location_directory,
+            language_registry,
             cursor: 0,
             parsed: vec![],
         }
@@ -81,16 +91,16 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
-    fn parse_document(mut self) -> Self {
+    async fn parse_document(mut self) -> Self {
         while !self.eof() {
-            if let Some(block) = self.parse_block() {
+            if let Some(block) = self.parse_block().await {
                 self.parsed.push(block);
             }
         }
         self
     }
 
-    fn parse_block(&mut self) -> Option<ParsedMarkdownElement> {
+    async fn parse_block(&mut self) -> Option<ParsedMarkdownElement> {
         let (current, source_range) = self.current().unwrap();
         match current {
             Event::Start(tag) => match tag {
@@ -119,12 +129,12 @@ impl<'a> MarkdownParser<'a> {
                 Tag::List(order) => {
                     let order = *order;
                     self.cursor += 1;
-                    let list = self.parse_list(1, order);
+                    let list = self.parse_list(1, order).await;
                     Some(ParsedMarkdownElement::List(list))
                 }
                 Tag::BlockQuote => {
                     self.cursor += 1;
-                    let block_quote = self.parse_block_quote();
+                    let block_quote = self.parse_block_quote().await;
                     Some(ParsedMarkdownElement::BlockQuote(block_quote))
                 }
                 Tag::CodeBlock(kind) => {
@@ -141,7 +151,7 @@ impl<'a> MarkdownParser<'a> {
 
                     self.cursor += 1;
 
-                    let code_block = self.parse_code_block(language);
+                    let code_block = self.parse_code_block(language).await;
                     Some(ParsedMarkdownElement::CodeBlock(code_block))
                 }
                 _ => {
@@ -407,7 +417,8 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
-    fn parse_list(&mut self, depth: u16, order: Option<u64>) -> ParsedMarkdownList {
+    #[async_recursion]
+    async fn parse_list(&mut self, depth: u16, order: Option<u64>) -> ParsedMarkdownList {
         let (_event, source_range) = self.previous().unwrap();
         let source_range = source_range.clone();
         let mut children = vec![];
@@ -424,7 +435,7 @@ impl<'a> MarkdownParser<'a> {
                     let order = *order;
                     self.cursor += 1;
 
-                    let inner_list = self.parse_list(depth + 1, order);
+                    let inner_list = self.parse_list(depth + 1, order).await;
                     let block = ParsedMarkdownElement::List(inner_list);
                     current_list_items.push(Box::new(block));
                 }
@@ -455,7 +466,7 @@ impl<'a> MarkdownParser<'a> {
                             let block = ParsedMarkdownElement::Paragraph(text);
                             current_list_items.push(Box::new(block));
                         } else {
-                            let block = self.parse_block();
+                            let block = self.parse_block().await;
                             if let Some(block) = block {
                                 current_list_items.push(Box::new(block));
                             }
@@ -493,7 +504,7 @@ impl<'a> MarkdownParser<'a> {
                         break;
                     }
 
-                    let block = self.parse_block();
+                    let block = self.parse_block().await;
                     if let Some(block) = block {
                         current_list_items.push(Box::new(block));
                     }
@@ -507,7 +518,8 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
-    fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
+    #[async_recursion]
+    async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
         let (_event, source_range) = self.previous().unwrap();
         let source_range = source_range.clone();
         let mut nested_depth = 1;
@@ -515,7 +527,7 @@ impl<'a> MarkdownParser<'a> {
         let mut children: Vec<Box<ParsedMarkdownElement>> = vec![];
 
         while !self.eof() {
-            let block = self.parse_block();
+            let block = self.parse_block().await;
 
             if let Some(block) = block {
                 children.push(Box::new(block));
@@ -553,7 +565,7 @@ impl<'a> MarkdownParser<'a> {
         }
     }
 
-    fn parse_code_block(&mut self, language: Option<String>) -> ParsedMarkdownCodeBlock {
+    async fn parse_code_block(&mut self, language: Option<String>) -> ParsedMarkdownCodeBlock {
         let (_event, source_range) = self.previous().unwrap();
         let source_range = source_range.clone();
         let mut code = String::new();
@@ -575,10 +587,26 @@ impl<'a> MarkdownParser<'a> {
             }
         }
 
+        let highlights = if let Some(language) = &language {
+            if let Some(registry) = &self.language_registry {
+                let rope: language::Rope = code.as_str().into();
+                registry
+                    .language_for_name_or_extension(language)
+                    .await
+                    .map(|l| l.highlight_text(&rope, 0..code.len()))
+                    .ok()
+            } else {
+                None
+            }
+        } else {
+            None
+        };
+
         ParsedMarkdownCodeBlock {
             source_range,
             contents: code.trim().to_string().into(),
             language,
+            highlights,
         }
     }
 }
@@ -587,18 +615,20 @@ impl<'a> MarkdownParser<'a> {
 mod tests {
     use super::*;
 
+    use gpui::BackgroundExecutor;
+    use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher};
     use pretty_assertions::assert_eq;
 
     use ParsedMarkdownElement::*;
     use ParsedMarkdownListItemType::*;
 
-    fn parse(input: &str) -> ParsedMarkdown {
-        parse_markdown(input, None)
+    async fn parse(input: &str) -> ParsedMarkdown {
+        parse_markdown(input, None, None).await
     }
 
-    #[test]
-    fn test_headings() {
-        let parsed = parse("# Heading one\n## Heading two\n### Heading three");
+    #[gpui::test]
+    async fn test_headings() {
+        let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
 
         assert_eq!(
             parsed.children,
@@ -610,9 +640,9 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_newlines_dont_new_paragraphs() {
-        let parsed = parse("Some text **that is bolded**\n and *italicized*");
+    #[gpui::test]
+    async fn test_newlines_dont_new_paragraphs() {
+        let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
 
         assert_eq!(
             parsed.children,
@@ -620,9 +650,9 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_heading_with_paragraph() {
-        let parsed = parse("# Zed\nThe editor");
+    #[gpui::test]
+    async fn test_heading_with_paragraph() {
+        let parsed = parse("# Zed\nThe editor").await;
 
         assert_eq!(
             parsed.children,
@@ -630,9 +660,9 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_double_newlines_do_new_paragraphs() {
-        let parsed = parse("Some text **that is bolded**\n\n and *italicized*");
+    #[gpui::test]
+    async fn test_double_newlines_do_new_paragraphs() {
+        let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
 
         assert_eq!(
             parsed.children,
@@ -643,9 +673,9 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_bold_italic_text() {
-        let parsed = parse("Some text **that is bolded** and *italicized*");
+    #[gpui::test]
+    async fn test_bold_italic_text() {
+        let parsed = parse("Some text **that is bolded** and *italicized*").await;
 
         assert_eq!(
             parsed.children,
@@ -653,9 +683,9 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_nested_bold_strikethrough_text() {
-        let parsed = parse("Some **bo~~strikethrough~~ld** text");
+    #[gpui::test]
+    async fn test_nested_bold_strikethrough_text() {
+        let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
 
         assert_eq!(parsed.children.len(), 1);
         assert_eq!(
@@ -703,8 +733,8 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_header_only_table() {
+    #[gpui::test]
+    async fn test_header_only_table() {
         let markdown = "\
 | Header 1 | Header 2 |
 |----------|----------|
@@ -719,13 +749,13 @@ Some other content
         );
 
         assert_eq!(
-            parse(markdown).children[0],
+            parse(markdown).await.children[0],
             ParsedMarkdownElement::Table(expected_table)
         );
     }
 
-    #[test]
-    fn test_basic_table() {
+    #[gpui::test]
+    async fn test_basic_table() {
         let markdown = "\
 | Header 1 | Header 2 |
 |----------|----------|
@@ -742,20 +772,21 @@ Some other content
         );
 
         assert_eq!(
-            parse(markdown).children[0],
+            parse(markdown).await.children[0],
             ParsedMarkdownElement::Table(expected_table)
         );
     }
 
-    #[test]
-    fn test_list_basic() {
+    #[gpui::test]
+    async fn test_list_basic() {
         let parsed = parse(
             "\
 * Item 1
 * Item 2
 * Item 3
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -770,14 +801,15 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_list_with_tasks() {
+    #[gpui::test]
+    async fn test_list_with_tasks() {
         let parsed = parse(
             "\
 - [ ] TODO
 - [x] Checked
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -791,8 +823,8 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_list_nested() {
+    #[gpui::test]
+    async fn test_list_nested() {
         let parsed = parse(
             "\
 * Item 1
@@ -813,7 +845,8 @@ Some other content
   2. Goodbyte
 * Last
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -900,14 +933,15 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_list_with_nested_content() {
+    #[gpui::test]
+    async fn test_list_with_nested_content() {
         let parsed = parse(
             "\
 *   This is a list item with two paragraphs.
 
     This is the second paragraph in the list item.",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -925,15 +959,16 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_list_with_leading_text() {
+    #[gpui::test]
+    async fn test_list_with_leading_text() {
         let parsed = parse(
             "\
 * `code`
 * **bold**
 * [link](https://example.com)
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -948,9 +983,9 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_simple_block_quote() {
-        let parsed = parse("> Simple block quote with **styled text**");
+    #[gpui::test]
+    async fn test_simple_block_quote() {
+        let parsed = parse("> Simple block quote with **styled text**").await;
 
         assert_eq!(
             parsed.children,
@@ -961,8 +996,8 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_simple_block_quote_with_multiple_lines() {
+    #[gpui::test]
+    async fn test_simple_block_quote_with_multiple_lines() {
         let parsed = parse(
             "\
 > # Heading
@@ -971,7 +1006,8 @@ Some other content
 >
 > More text
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -986,8 +1022,8 @@ Some other content
         );
     }
 
-    #[test]
-    fn test_nested_block_quote() {
+    #[gpui::test]
+    async fn test_nested_block_quote() {
         let parsed = parse(
             "\
 > A
@@ -998,7 +1034,8 @@ Some other content
 
 More text
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
@@ -1016,8 +1053,8 @@ More text
         );
     }
 
-    #[test]
-    fn test_code_block() {
+    #[gpui::test]
+    async fn test_code_block() {
         let parsed = parse(
             "\
 ```
@@ -1026,17 +1063,28 @@ fn main() {
 }
 ```
 ",
-        );
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
-            vec![code_block(None, "fn main() {\n    return 0;\n}", 0..35)]
+            vec![code_block(
+                None,
+                "fn main() {\n    return 0;\n}",
+                0..35,
+                None
+            )]
         );
     }
 
-    #[test]
-    fn test_code_block_with_language() {
-        let parsed = parse(
+    #[gpui::test]
+    async fn test_code_block_with_language(executor: BackgroundExecutor) {
+        let mut language_registry = LanguageRegistry::test();
+        language_registry.set_executor(executor);
+        let language_registry = Arc::new(language_registry);
+        language_registry.add(rust_lang());
+
+        let parsed = parse_markdown(
             "\
 ```rust
 fn main() {
@@ -1044,18 +1092,37 @@ fn main() {
 }
 ```
 ",
-        );
+            None,
+            Some(language_registry),
+        )
+        .await;
 
         assert_eq!(
             parsed.children,
             vec![code_block(
-                Some("rust".into()),
+                Some("rust".to_string()),
                 "fn main() {\n    return 0;\n}",
-                0..39
+                0..39,
+                Some(vec![])
             )]
         );
     }
 
+    fn rust_lang() -> Arc<Language> {
+        Arc::new(Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".into()],
+                    ..Default::default()
+                },
+                collapsed_placeholder: " /* ... */ ".to_string(),
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        ))
+    }
+
     fn h1(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
         ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
             source_range,
@@ -1108,11 +1175,13 @@ fn main() {
         language: Option<String>,
         code: &str,
         source_range: Range<usize>,
+        highlights: Option<Vec<(Range<usize>, HighlightId)>>,
     ) -> ParsedMarkdownElement {
         ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
             source_range,
             language,
             contents: code.to_string().into(),
+            highlights,
         })
     }
 

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -1,3 +1,4 @@
+use std::sync::Arc;
 use std::{ops::Range, path::PathBuf};
 
 use editor::{Editor, EditorEvent};
@@ -5,6 +6,7 @@ use gpui::{
     list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
     IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView,
 };
+use language::LanguageRegistry;
 use ui::prelude::*;
 use workspace::item::{Item, ItemHandle};
 use workspace::Workspace;
@@ -19,7 +21,7 @@ use crate::{
 pub struct MarkdownPreviewView {
     workspace: WeakView<Workspace>,
     focus_handle: FocusHandle,
-    contents: ParsedMarkdown,
+    contents: Option<ParsedMarkdown>,
     selected_block: usize,
     list_state: ListState,
     tab_description: String,
@@ -34,10 +36,16 @@ impl MarkdownPreviewView {
             }
 
             if let Some(editor) = workspace.active_item_as::<Editor>(cx) {
+                let language_registry = workspace.project().read(cx).languages().clone();
                 let workspace_handle = workspace.weak_handle();
                 let tab_description = editor.tab_description(0, cx);
-                let view: View<MarkdownPreviewView> =
-                    MarkdownPreviewView::new(editor, workspace_handle, tab_description, cx);
+                let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
+                    editor,
+                    workspace_handle,
+                    tab_description,
+                    language_registry,
+                    cx,
+                );
                 workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx);
                 cx.notify();
             }
@@ -48,55 +56,82 @@ impl MarkdownPreviewView {
         active_editor: View<Editor>,
         workspace: WeakView<Workspace>,
         tab_description: Option<SharedString>,
+        language_registry: Arc<LanguageRegistry>,
         cx: &mut ViewContext<Workspace>,
     ) -> View<Self> {
         cx.new_view(|cx: &mut ViewContext<Self>| {
             let view = cx.view().downgrade();
             let editor = active_editor.read(cx);
-
             let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
             let contents = editor.buffer().read(cx).snapshot(cx).text();
-            let contents = parse_markdown(&contents, file_location);
-
-            cx.subscribe(&active_editor, |this, editor, event: &EditorEvent, cx| {
-                match event {
-                    EditorEvent::Edited => {
-                        let editor = editor.read(cx);
-                        let contents = editor.buffer().read(cx).snapshot(cx).text();
-                        let file_location =
-                            MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
-                        this.contents = parse_markdown(&contents, file_location);
-                        this.list_state.reset(this.contents.children.len());
-                        cx.notify();
-
-                        // TODO: This does not work as expected.
-                        // The scroll request appears to be dropped
-                        // after `.reset` is called.
-                        this.list_state.scroll_to_reveal_item(this.selected_block);
-                        cx.notify();
-                    }
-                    EditorEvent::SelectionsChanged { .. } => {
-                        let editor = editor.read(cx);
-                        let selection_range = editor.selections.last::<usize>(cx).range();
-                        this.selected_block = this.get_block_index_under_cursor(selection_range);
-                        this.list_state.scroll_to_reveal_item(this.selected_block);
-                        cx.notify();
-                    }
-                    _ => {}
-                };
+
+            let language_registry_copy = language_registry.clone();
+            cx.spawn(|view, mut cx| async move {
+                let contents =
+                    parse_markdown(&contents, file_location, Some(language_registry_copy)).await;
+
+                view.update(&mut cx, |view, cx| {
+                    let markdown_blocks_count = contents.children.len();
+                    view.contents = Some(contents);
+                    view.list_state.reset(markdown_blocks_count);
+                    cx.notify();
+                })
             })
             .detach();
 
-            let list_state = ListState::new(
-                contents.children.len(),
-                gpui::ListAlignment::Top,
-                px(1000.),
-                move |ix, cx| {
+            cx.subscribe(
+                &active_editor,
+                move |this, editor, event: &EditorEvent, cx| {
+                    match event {
+                        EditorEvent::Edited => {
+                            let editor = editor.read(cx);
+                            let contents = editor.buffer().read(cx).snapshot(cx).text();
+                            let file_location =
+                                MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
+                            let language_registry = language_registry.clone();
+                            cx.spawn(move |view, mut cx| async move {
+                                let contents = parse_markdown(
+                                    &contents,
+                                    file_location,
+                                    Some(language_registry.clone()),
+                                )
+                                .await;
+                                view.update(&mut cx, move |view, cx| {
+                                    let markdown_blocks_count = contents.children.len();
+                                    view.contents = Some(contents);
+
+                                    let scroll_top = view.list_state.logical_scroll_top();
+                                    view.list_state.reset(markdown_blocks_count);
+                                    view.list_state.scroll_to(scroll_top);
+                                    cx.notify();
+                                })
+                            })
+                            .detach();
+                        }
+                        EditorEvent::SelectionsChanged { .. } => {
+                            let editor = editor.read(cx);
+                            let selection_range = editor.selections.last::<usize>(cx).range();
+                            this.selected_block =
+                                this.get_block_index_under_cursor(selection_range);
+                            this.list_state.scroll_to_reveal_item(this.selected_block);
+                            cx.notify();
+                        }
+                        _ => {}
+                    };
+                },
+            )
+            .detach();
+
+            let list_state =
+                ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
                     if let Some(view) = view.upgrade() {
                         view.update(cx, |view, cx| {
+                            let Some(contents) = &view.contents else {
+                                return div().into_any();
+                            };
                             let mut render_cx =
                                 RenderContext::new(Some(view.workspace.clone()), cx);
-                            let block = view.contents.children.get(ix).unwrap();
+                            let block = contents.children.get(ix).unwrap();
                             let block = render_markdown_block(block, &mut render_cx);
                             let block = div().child(block).pl_4().pb_3();
 
@@ -119,8 +154,7 @@ impl MarkdownPreviewView {
                     } else {
                         div().into_any()
                     }
-                },
-            );
+                });
 
             let tab_description = tab_description
                 .map(|tab_description| format!("Preview {}", tab_description))
@@ -130,9 +164,9 @@ impl MarkdownPreviewView {
                 selected_block: 0,
                 focus_handle: cx.focus_handle(),
                 workspace,
-                contents,
+                contents: None,
                 list_state,
-                tab_description: tab_description,
+                tab_description,
             }
         })
     }
@@ -154,18 +188,33 @@ impl MarkdownPreviewView {
     }
 
     fn get_block_index_under_cursor(&self, selection_range: Range<usize>) -> usize {
-        let mut block_index = 0;
+        let mut block_index = None;
         let cursor = selection_range.start;
 
-        for (i, block) in self.contents.children.iter().enumerate() {
-            let Range { start, end } = block.source_range();
-            if start <= cursor && end >= cursor {
-                block_index = i;
-                break;
+        let mut last_end = 0;
+        if let Some(content) = &self.contents {
+            for (i, block) in content.children.iter().enumerate() {
+                let Range { start, end } = block.source_range();
+
+                // Check if the cursor is between the last block and the current block
+                if last_end > cursor && cursor < start {
+                    block_index = Some(i.saturating_sub(1));
+                    break;
+                }
+
+                if start <= cursor && end >= cursor {
+                    block_index = Some(i);
+                    break;
+                }
+                last_end = end;
+            }
+
+            if block_index.is_none() && last_end < cursor {
+                block_index = Some(content.children.len().saturating_sub(1));
             }
         }
 
-        return block_index;
+        block_index.unwrap_or_default()
     }
 }
 

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -248,11 +248,25 @@ fn render_markdown_code_block(
     parsed: &ParsedMarkdownCodeBlock,
     cx: &mut RenderContext,
 ) -> AnyElement {
+    let body = if let Some(highlights) = parsed.highlights.as_ref() {
+        StyledText::new(parsed.contents.clone()).with_highlights(
+            &cx.text_style,
+            highlights.iter().filter_map(|(range, highlight_id)| {
+                highlight_id
+                    .style(cx.syntax_theme.as_ref())
+                    .map(|style| (range.clone(), style))
+            }),
+        )
+    } else {
+        StyledText::new(parsed.contents.clone())
+    };
+
     cx.with_common_p(div())
         .px_3()
         .py_3()
         .bg(cx.code_block_background_color)
-        .child(StyledText::new(parsed.contents.clone()))
+        .rounded_md()
+        .child(body)
         .into_any()
 }
 

crates/workspace/Cargo.toml 🔗

@@ -25,7 +25,7 @@ test-support = [
 
 [dependencies]
 anyhow.workspace = true
-async-recursion = "1.0.0"
+async-recursion.workspace = true
 bincode = "1.2.1"
 call.workspace = true
 client.workspace = true