Add initial markdown preview to Zed (#6958)

Kieran Gill created

Adds a "markdown: open preview" action to open a markdown preview.

https://github.com/zed-industries/zed/assets/18583882/6fd7f009-53f7-4f98-84ea-7dd3f0dd11bf


This PR extends the work done in `crates/rich_text` to render markdown
to also support:

- Variable heading sizes
- Markdown tables
- Code blocks
- Block quotes

## Release Notes

- Added `Markdown: Open preview` action to partially close
([#6789](https://github.com/zed-industries/zed/issues/6789)).

## Known issues that will not be included in this PR

- Images.
- Nested block quotes.
- Footnote Reference.
- Headers highlighting.
- Inline code highlighting (this will need to be implemented in
`rich_text`)
- Checkboxes (`- [ ]` and `- [x]`)
- Syntax highlighting in code blocks.
- Markdown table text alignment.
- Inner markdown URL clicks

Change summary

Cargo.lock                                           |  21 
Cargo.toml                                           |   2 
crates/collab_ui/src/chat_panel.rs                   |   2 
crates/language/Cargo.toml                           |   4 
crates/markdown_preview/Cargo.toml                   |  32 +
crates/markdown_preview/LICENSE-GPL                  |   1 
crates/markdown_preview/src/markdown_preview.rs      |  14 
crates/markdown_preview/src/markdown_preview_view.rs | 134 +++++
crates/markdown_preview/src/markdown_renderer.rs     | 328 ++++++++++++++
crates/multi_buffer/Cargo.toml                       |   2 
crates/rich_text/Cargo.toml                          |   2 
crates/rich_text/src/rich_text.rs                    |  11 
crates/zed/Cargo.toml                                |   1 
crates/zed/src/main.rs                               |   1 
14 files changed, 547 insertions(+), 8 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4317,6 +4317,26 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "markdown_preview"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "editor",
+ "gpui",
+ "language",
+ "lazy_static",
+ "log",
+ "menu",
+ "project",
+ "pulldown-cmark",
+ "rich_text",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "matchers"
 version = "0.1.0"
@@ -10315,6 +10335,7 @@ dependencies = [
  "libc",
  "log",
  "lsp",
+ "markdown_preview",
  "menu",
  "mimalloc",
  "node_runtime",

Cargo.toml 🔗

@@ -42,6 +42,7 @@ members = [
     "crates/live_kit_client",
     "crates/live_kit_server",
     "crates/lsp",
+    "crates/markdown_preview",
     "crates/media",
     "crates/menu",
     "crates/multi_buffer",
@@ -111,6 +112,7 @@ parking_lot = "0.11.1"
 postage = { version = "0.5", features = ["futures-traits"] }
 pretty_assertions = "1.3.0"
 prost = "0.8"
+pulldown-cmark = { version = "0.9.2", default-features = false }
 rand = "0.8.5"
 refineable = { path = "./crates/refineable" }
 regex = "1.5"

crates/collab_ui/src/chat_panel.rs 🔗

@@ -453,7 +453,7 @@ impl ChatPanel {
             })
             .collect::<Vec<_>>();
 
-        rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
+        rich_text::render_rich_text(message.body.clone(), &mentions, language_registry, None)
     }
 
     fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {

crates/language/Cargo.toml 🔗

@@ -29,7 +29,7 @@ async-trait.workspace = true
 clock = { path = "../clock" }
 collections = { path = "../collections" }
 futures.workspace = true
-fuzzy = {  path = "../fuzzy" }
+fuzzy = { path = "../fuzzy" }
 git = { path = "../git" }
 globset.workspace = true
 gpui = { path = "../gpui" }
@@ -38,7 +38,6 @@ log.workspace = true
 lsp = { path = "../lsp" }
 parking_lot.workspace = true
 postage.workspace = true
-pulldown-cmark = { version = "0.9.2", default-features = false }
 rand = { workspace = true, optional = true }
 regex.workspace = true
 rpc = { path = "../rpc" }
@@ -55,6 +54,7 @@ text = { path = "../text" }
 theme = { path = "../theme" }
 tree-sitter-rust = { workspace = true, optional = true }
 tree-sitter-typescript = { workspace = true, optional = true }
+pulldown-cmark.workspace = true
 tree-sitter.workspace = true
 unicase = "2.6"
 util = { path = "../util" }

crates/markdown_preview/Cargo.toml 🔗

@@ -0,0 +1,32 @@
+[package]
+name = "markdown_preview"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/markdown_preview.rs"
+
+[features]
+test-support = []
+
+[dependencies]
+editor = { path = "../editor" }
+gpui = { path = "../gpui" }
+language = { path = "../language" }
+menu = { path = "../menu" }
+project = { path = "../project" }
+theme = { path = "../theme" }
+ui = { path = "../ui" }
+util = { path = "../util" }
+workspace = { path = "../workspace" }
+rich_text = { path = "../rich_text" }
+
+anyhow.workspace = true
+lazy_static.workspace = true
+log.workspace = true
+pulldown-cmark.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/markdown_preview/src/markdown_preview.rs 🔗

@@ -0,0 +1,14 @@
+use gpui::{actions, AppContext};
+use workspace::Workspace;
+
+pub mod markdown_preview_view;
+pub mod markdown_renderer;
+
+actions!(markdown, [OpenPreview]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(|workspace: &mut Workspace, cx| {
+        markdown_preview_view::MarkdownPreviewView::register(workspace, cx);
+    })
+    .detach();
+}

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -0,0 +1,134 @@
+use editor::{Editor, EditorEvent};
+use gpui::{
+    canvas, AnyElement, AppContext, AvailableSpace, EventEmitter, FocusHandle, FocusableView,
+    InteractiveElement, IntoElement, ParentElement, Render, Styled, View, ViewContext,
+};
+use language::LanguageRegistry;
+use std::sync::Arc;
+use ui::prelude::*;
+use workspace::item::Item;
+use workspace::Workspace;
+
+use crate::{markdown_renderer::render_markdown, OpenPreview};
+
+pub struct MarkdownPreviewView {
+    focus_handle: FocusHandle,
+    languages: Arc<LanguageRegistry>,
+    contents: String,
+}
+
+impl MarkdownPreviewView {
+    pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
+        let languages = workspace.app_state().languages.clone();
+
+        workspace.register_action(move |workspace, _: &OpenPreview, cx| {
+            if workspace.has_active_modal(cx) {
+                cx.propagate();
+                return;
+            }
+            let languages = languages.clone();
+            if let Some(editor) = workspace.active_item_as::<Editor>(cx) {
+                let view: View<MarkdownPreviewView> =
+                    cx.new_view(|cx| MarkdownPreviewView::new(editor, languages, cx));
+                workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx);
+                cx.notify();
+            }
+        });
+    }
+
+    pub fn new(
+        active_editor: View<Editor>,
+        languages: Arc<LanguageRegistry>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        cx.subscribe(&active_editor, |this, editor, event: &EditorEvent, cx| {
+            if *event == EditorEvent::Edited {
+                let editor = editor.read(cx);
+                let contents = editor.buffer().read(cx).snapshot(cx).text();
+                this.contents = contents;
+                cx.notify();
+            }
+        })
+        .detach();
+
+        let editor = active_editor.read(cx);
+        let contents = editor.buffer().read(cx).snapshot(cx).text();
+
+        Self {
+            focus_handle,
+            languages,
+            contents,
+        }
+    }
+}
+
+impl FocusableView for MarkdownPreviewView {
+    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum PreviewEvent {}
+
+impl EventEmitter<PreviewEvent> for MarkdownPreviewView {}
+
+impl Item for MarkdownPreviewView {
+    type Event = PreviewEvent;
+
+    fn tab_content(
+        &self,
+        _detail: Option<usize>,
+        selected: bool,
+        _cx: &WindowContext,
+    ) -> AnyElement {
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FileDoc).color(if selected {
+                Color::Default
+            } else {
+                Color::Muted
+            }))
+            .child(Label::new("Markdown preview").color(if selected {
+                Color::Default
+            } else {
+                Color::Muted
+            }))
+            .into_any()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("markdown preview")
+    }
+
+    fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {}
+}
+
+impl Render for MarkdownPreviewView {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let rendered_markdown = v_flex()
+            .items_start()
+            .justify_start()
+            .key_context("MarkdownPreview")
+            .track_focus(&self.focus_handle)
+            .id("MarkdownPreview")
+            .overflow_scroll()
+            .size_full()
+            .bg(cx.theme().colors().editor_background)
+            .p_4()
+            .children(render_markdown(&self.contents, &self.languages, cx));
+
+        div().flex_1().child(
+            canvas(move |bounds, cx| {
+                rendered_markdown.into_any().draw(
+                    bounds.origin,
+                    bounds.size.map(AvailableSpace::Definite),
+                    cx,
+                )
+            })
+            .size_full(),
+        )
+    }
+}

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -0,0 +1,328 @@
+use std::{ops::Range, sync::Arc};
+
+use gpui::{
+    div, px, rems, AnyElement, DefiniteLength, Div, ElementId, Hsla, ParentElement, SharedString,
+    Styled, StyledText, WindowContext,
+};
+use language::LanguageRegistry;
+use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
+use rich_text::render_rich_text;
+use theme::{ActiveTheme, Theme};
+use ui::{h_flex, v_flex};
+
+enum TableState {
+    Header,
+    Body,
+}
+
+struct MarkdownTable {
+    header: Vec<Div>,
+    body: Vec<Vec<Div>>,
+    current_row: Vec<Div>,
+    state: TableState,
+    border_color: Hsla,
+}
+
+impl MarkdownTable {
+    fn new(border_color: Hsla) -> Self {
+        Self {
+            header: Vec::new(),
+            body: Vec::new(),
+            current_row: Vec::new(),
+            state: TableState::Header,
+            border_color,
+        }
+    }
+
+    fn finish_row(&mut self) {
+        match self.state {
+            TableState::Header => {
+                self.header.extend(self.current_row.drain(..));
+                self.state = TableState::Body;
+            }
+            TableState::Body => {
+                self.body.push(self.current_row.drain(..).collect());
+            }
+        }
+    }
+
+    fn add_cell(&mut self, contents: AnyElement) {
+        let cell = div()
+            .child(contents)
+            .w_full()
+            .px_2()
+            .py_1()
+            .border_color(self.border_color);
+
+        let cell = match self.state {
+            TableState::Header => cell.border_2(),
+            TableState::Body => cell.border_1(),
+        };
+
+        self.current_row.push(cell);
+    }
+
+    fn finish(self) -> Div {
+        let mut table = v_flex().w_full();
+        let mut header = h_flex();
+
+        for cell in self.header {
+            header = header.child(cell);
+        }
+        table = table.child(header);
+        for row in self.body {
+            let mut row_div = h_flex();
+            for cell in row {
+                row_div = row_div.child(cell);
+            }
+            table = table.child(row_div);
+        }
+        table
+    }
+}
+
+struct Renderer<I> {
+    source_contents: String,
+    iter: I,
+    theme: Arc<Theme>,
+    finished: Vec<Div>,
+    language_registry: Arc<LanguageRegistry>,
+    table: Option<MarkdownTable>,
+    list_depth: usize,
+    block_quote_depth: usize,
+}
+
+impl<'a, I> Renderer<I>
+where
+    I: Iterator<Item = (Event<'a>, Range<usize>)>,
+{
+    fn new(
+        iter: I,
+        source_contents: String,
+        language_registry: &Arc<LanguageRegistry>,
+        theme: Arc<Theme>,
+    ) -> Self {
+        Self {
+            iter,
+            source_contents,
+            theme,
+            table: None,
+            finished: vec![],
+            language_registry: language_registry.clone(),
+            list_depth: 0,
+            block_quote_depth: 0,
+        }
+    }
+
+    fn run(mut self, cx: &WindowContext) -> Self {
+        while let Some((event, source_range)) = self.iter.next() {
+            match event {
+                Event::Start(tag) => {
+                    self.start_tag(tag);
+                }
+                Event::End(tag) => {
+                    self.end_tag(tag, source_range, cx);
+                }
+                Event::Rule => {
+                    let rule = div().w_full().h(px(2.)).bg(self.theme.colors().border);
+                    self.finished.push(div().mb_4().child(rule));
+                }
+                _ => {}
+            }
+        }
+        self
+    }
+
+    fn start_tag(&mut self, tag: Tag<'a>) {
+        match tag {
+            Tag::List(_) => {
+                self.list_depth += 1;
+            }
+            Tag::BlockQuote => {
+                self.block_quote_depth += 1;
+            }
+            Tag::Table(_text_alignments) => {
+                self.table = Some(MarkdownTable::new(self.theme.colors().border));
+            }
+            _ => {}
+        }
+    }
+
+    fn end_tag(&mut self, tag: Tag, source_range: Range<usize>, cx: &WindowContext) {
+        match tag {
+            Tag::Paragraph => {
+                if self.list_depth > 0 || self.block_quote_depth > 0 {
+                    return;
+                }
+
+                let element = self.render_md_from_range(source_range.clone(), cx);
+                let paragraph = h_flex().mb_3().child(element);
+
+                self.finished.push(paragraph);
+            }
+            Tag::Heading(level, _, _) => {
+                let mut headline = self.headline(level);
+                if source_range.start > 0 {
+                    headline = headline.mt_4();
+                }
+
+                let element = self.render_md_from_range(source_range.clone(), cx);
+                let headline = headline.child(element);
+
+                self.finished.push(headline);
+            }
+            Tag::List(_) => {
+                if self.list_depth == 1 {
+                    let element = self.render_md_from_range(source_range.clone(), cx);
+                    let list = div().mb_3().child(element);
+
+                    self.finished.push(list);
+                }
+
+                self.list_depth -= 1;
+            }
+            Tag::BlockQuote => {
+                let element = self.render_md_from_range(source_range.clone(), cx);
+
+                let block_quote = h_flex()
+                    .mb_3()
+                    .child(
+                        div()
+                            .w(px(4.))
+                            .bg(self.theme.colors().border)
+                            .h_full()
+                            .mr_2()
+                            .mt_1(),
+                    )
+                    .text_color(self.theme.colors().text_muted)
+                    .child(element);
+
+                self.finished.push(block_quote);
+
+                self.block_quote_depth -= 1;
+            }
+            Tag::CodeBlock(kind) => {
+                let contents = self.source_contents[source_range.clone()].trim();
+                let contents = contents.trim_start_matches("```");
+                let contents = contents.trim_end_matches("```");
+                let contents = match kind {
+                    CodeBlockKind::Fenced(language) => {
+                        contents.trim_start_matches(&language.to_string())
+                    }
+                    CodeBlockKind::Indented => contents,
+                };
+                let contents: String = contents.into();
+                let contents = SharedString::from(contents);
+
+                let code_block = div()
+                    .mb_3()
+                    .px_4()
+                    .py_0()
+                    .bg(self.theme.colors().surface_background)
+                    .child(StyledText::new(contents));
+
+                self.finished.push(code_block);
+            }
+            Tag::Table(_alignment) => {
+                if self.table.is_none() {
+                    log::error!("Table end without table ({:?})", source_range);
+                    return;
+                }
+
+                let table = self.table.take().unwrap();
+                let table = table.finish().mb_4();
+                self.finished.push(table);
+            }
+            Tag::TableHead => {
+                if self.table.is_none() {
+                    log::error!("Table head without table ({:?})", source_range);
+                    return;
+                }
+
+                self.table.as_mut().unwrap().finish_row();
+            }
+            Tag::TableRow => {
+                if self.table.is_none() {
+                    log::error!("Table row without table ({:?})", source_range);
+                    return;
+                }
+
+                self.table.as_mut().unwrap().finish_row();
+            }
+            Tag::TableCell => {
+                if self.table.is_none() {
+                    log::error!("Table cell without table ({:?})", source_range);
+                    return;
+                }
+
+                let contents = self.render_md_from_range(source_range.clone(), cx);
+                self.table.as_mut().unwrap().add_cell(contents);
+            }
+            _ => {}
+        }
+    }
+
+    fn render_md_from_range(
+        &self,
+        source_range: Range<usize>,
+        cx: &WindowContext,
+    ) -> gpui::AnyElement {
+        let mentions = &[];
+        let language = None;
+        let paragraph = &self.source_contents[source_range.clone()];
+        let rich_text = render_rich_text(
+            paragraph.into(),
+            mentions,
+            &self.language_registry,
+            language,
+        );
+        let id: ElementId = source_range.start.into();
+        rich_text.element(id, cx)
+    }
+
+    fn headline(&self, level: HeadingLevel) -> Div {
+        let size = match level {
+            HeadingLevel::H1 => rems(2.),
+            HeadingLevel::H2 => rems(1.5),
+            HeadingLevel::H3 => rems(1.25),
+            HeadingLevel::H4 => rems(1.),
+            HeadingLevel::H5 => rems(0.875),
+            HeadingLevel::H6 => rems(0.85),
+        };
+
+        let color = match level {
+            HeadingLevel::H6 => self.theme.colors().text_muted,
+            _ => self.theme.colors().text,
+        };
+
+        let line_height = DefiniteLength::from(rems(1.25));
+
+        let headline = h_flex()
+            .w_full()
+            .line_height(line_height)
+            .text_size(size)
+            .text_color(color)
+            .mb_4()
+            .pb(rems(0.15));
+
+        headline
+    }
+}
+
+pub fn render_markdown(
+    markdown_input: &str,
+    language_registry: &Arc<LanguageRegistry>,
+    cx: &WindowContext,
+) -> Vec<Div> {
+    let theme = cx.theme().clone();
+    let options = Options::all();
+    let parser = Parser::new_ext(markdown_input, options);
+    let renderer = Renderer::new(
+        parser.into_offset_iter(),
+        markdown_input.to_owned(),
+        language_registry,
+        theme,
+    );
+    let renderer = renderer.run(cx);
+    return renderer.finished;
+}

crates/multi_buffer/Cargo.toml 🔗

@@ -39,7 +39,7 @@ lsp = { path = "../lsp" }
 ordered-float.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
-pulldown-cmark = { version = "0.9.2", default-features = false }
+pulldown-cmark.workspace = true
 rand.workspace = true
 rich_text = { path = "../rich_text" }
 schemars.workspace = true

crates/rich_text/Cargo.toml 🔗

@@ -22,7 +22,7 @@ futures.workspace = true
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lazy_static.workspace = true
-pulldown-cmark = { version = "0.9.2", default-features = false }
+pulldown-cmark.workspace = true
 smallvec.workspace = true
 smol.workspace = true
 sum_tree = { path = "../sum_tree" }

crates/rich_text/src/rich_text.rs 🔗

@@ -47,7 +47,7 @@ pub struct Mention {
 }
 
 impl RichText {
-    pub fn element(&self, id: ElementId, cx: &mut WindowContext) -> AnyElement {
+    pub fn element(&self, id: ElementId, cx: &WindowContext) -> AnyElement {
         let theme = cx.theme();
         let code_background = theme.colors().surface_background;
 
@@ -83,7 +83,12 @@ impl RichText {
         )
         .on_click(self.link_ranges.clone(), {
             let link_urls = self.link_urls.clone();
-            move |ix, cx| cx.open_url(&link_urls[ix])
+            move |ix, cx| {
+                let url = &link_urls[ix];
+                if url.starts_with("http") {
+                    cx.open_url(url);
+                }
+            }
         })
         .tooltip({
             let link_ranges = self.link_ranges.clone();
@@ -256,7 +261,7 @@ pub fn render_markdown_mut(
     }
 }
 
-pub fn render_markdown(
+pub fn render_rich_text(
     block: String,
     mentions: &[Mention],
     language_registry: &Arc<LanguageRegistry>,

crates/zed/Cargo.toml 🔗

@@ -65,6 +65,7 @@ lazy_static.workspace = true
 libc = "0.2"
 log.workspace = true
 lsp = { path = "../lsp" }
+markdown_preview = { path = "../markdown_preview" }
 menu = { path = "../menu" }
 mimalloc = "0.1"
 node_runtime = { path = "../node_runtime" }

crates/zed/src/main.rs 🔗

@@ -248,6 +248,7 @@ fn main() {
         notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
         feedback::init(cx);
+        markdown_preview::init(cx);
         welcome::init(cx);
 
         cx.set_menus(app_menus());