Detailed changes
@@ -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",
@@ -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"
@@ -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>) {
@@ -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" }
@@ -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"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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();
+}
@@ -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(),
+ )
+ }
+}
@@ -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;
+}
@@ -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
@@ -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" }
@@ -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>,
@@ -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" }
@@ -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());