From 8bafc61ef57f8f1807ced6f8b69891c4ee701a7d Mon Sep 17 00:00:00 2001 From: Kieran Gill Date: Thu, 1 Feb 2024 04:03:09 -0500 Subject: [PATCH] Add initial markdown preview to Zed (#6958) 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 --- 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 + .../markdown_preview/src/markdown_preview.rs | 14 + .../src/markdown_preview_view.rs | 134 +++++++ .../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(-) create mode 100644 crates/markdown_preview/Cargo.toml create mode 120000 crates/markdown_preview/LICENSE-GPL create mode 100644 crates/markdown_preview/src/markdown_preview.rs create mode 100644 crates/markdown_preview/src/markdown_preview_view.rs create mode 100644 crates/markdown_preview/src/markdown_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 9fcd3a0292..cd632175c1 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index a954995ac2..00d69c8786 100644 --- a/Cargo.toml +++ b/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" diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index aad22a1e50..88ee461dee 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -453,7 +453,7 @@ impl ChatPanel { }) .collect::>(); - 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) { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index cf8801e857..826a8287e5 100644 --- a/crates/language/Cargo.toml +++ b/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" } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml new file mode 100644 index 0000000000..bb52a64ed6 --- /dev/null +++ b/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"] } diff --git a/crates/markdown_preview/LICENSE-GPL b/crates/markdown_preview/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/markdown_preview/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs new file mode 100644 index 0000000000..84c8ac6245 --- /dev/null +++ b/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(); +} diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs new file mode 100644 index 0000000000..475a9fbaa3 --- /dev/null +++ b/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, + contents: String, +} + +impl MarkdownPreviewView { + pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext) { + 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::(cx) { + let view: View = + 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, + languages: Arc, + cx: &mut ViewContext, + ) -> 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 for MarkdownPreviewView {} + +impl Item for MarkdownPreviewView { + type Event = PreviewEvent; + + fn tab_content( + &self, + _detail: Option, + 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) -> 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(), + ) + } +} diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs new file mode 100644 index 0000000000..d40046863b --- /dev/null +++ b/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
, + body: Vec>, + current_row: Vec
, + 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 { + source_contents: String, + iter: I, + theme: Arc, + finished: Vec
, + language_registry: Arc, + table: Option, + list_depth: usize, + block_quote_depth: usize, +} + +impl<'a, I> Renderer +where + I: Iterator, Range)>, +{ + fn new( + iter: I, + source_contents: String, + language_registry: &Arc, + theme: Arc, + ) -> 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, 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, + 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, + cx: &WindowContext, +) -> Vec
{ + 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; +} diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 87f36f3096..5fc7bdb254 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/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 diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml index a772407a83..bfd8cf70d1 100644 --- a/crates/rich_text/Cargo.toml +++ b/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" } diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs index 12188c5031..f5ab38c4ce 100644 --- a/crates/rich_text/src/rich_text.rs +++ b/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, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 64d4a59c2d..f17d3053a0 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index a2436ffb5d..11e9667a8e 100644 --- a/crates/zed/src/main.rs +++ b/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());