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
This commit is contained in:
Kieran Gill 2024-02-01 04:03:09 -05:00 committed by GitHub
parent 3b882918f7
commit 8bafc61ef5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 547 additions and 8 deletions

21
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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>) {

View File

@ -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" }

View File

@ -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"] }

View File

@ -0,0 +1 @@
../../LICENSE-GPL

View File

@ -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();
}

View File

@ -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(),
)
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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" }

View File

@ -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>,

View File

@ -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" }

View File

@ -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());