markdown preview: highlight code blocks (#9087)
 Release Notes: - Added syntax highlighting to code blocks in markdown preview - Fixed scroll position in markdown preview when editing a markdown file (#9208)
This commit is contained in:
parent
e5bd9f184b
commit
d362588055
9 changed files with 264 additions and 126 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -5643,6 +5643,7 @@ dependencies = [
|
||||||
name = "markdown_preview"
|
name = "markdown_preview"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-recursion 1.0.5",
|
||||||
"editor",
|
"editor",
|
||||||
"gpui",
|
"gpui",
|
||||||
"language",
|
"language",
|
||||||
|
|
|
@ -198,6 +198,7 @@ zed_actions = { path = "crates/zed_actions" }
|
||||||
|
|
||||||
anyhow = "1.0.57"
|
anyhow = "1.0.57"
|
||||||
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
|
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
|
||||||
|
async-recursion = "1.0.0"
|
||||||
async-tar = "0.4.2"
|
async-tar = "0.4.2"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
bitflags = "2.4.2"
|
bitflags = "2.4.2"
|
||||||
|
|
|
@ -229,6 +229,7 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Wo
|
||||||
buffer.update(cx, |buffer, cx| {
|
buffer.update(cx, |buffer, cx| {
|
||||||
buffer.edit([(0..0, body.release_notes)], None, 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));
|
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,
|
editor,
|
||||||
workspace_handle,
|
workspace_handle,
|
||||||
Some(tab_description),
|
Some(tab_description),
|
||||||
|
language_registry,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
workspace.add_item_to_active_pane(Box::new(view.clone()), cx);
|
workspace.add_item_to_active_pane(Box::new(view.clone()), cx);
|
||||||
|
|
|
@ -15,6 +15,7 @@ path = "src/markdown_preview.rs"
|
||||||
test-support = []
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-recursion.workspace = true
|
||||||
editor.workspace = true
|
editor.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
language.workspace = true
|
language.workspace = true
|
||||||
|
|
|
@ -68,6 +68,7 @@ pub struct ParsedMarkdownCodeBlock {
|
||||||
pub source_range: Range<usize>,
|
pub source_range: Range<usize>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
pub contents: SharedString,
|
pub contents: SharedString,
|
||||||
|
pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
|
@ -1,16 +1,23 @@
|
||||||
use crate::markdown_elements::*;
|
use crate::markdown_elements::*;
|
||||||
|
use async_recursion::async_recursion;
|
||||||
use gpui::FontWeight;
|
use gpui::FontWeight;
|
||||||
|
use language::LanguageRegistry;
|
||||||
use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
|
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,
|
markdown_input: &str,
|
||||||
file_location_directory: Option<PathBuf>,
|
file_location_directory: Option<PathBuf>,
|
||||||
|
language_registry: Option<Arc<LanguageRegistry>>,
|
||||||
) -> ParsedMarkdown {
|
) -> ParsedMarkdown {
|
||||||
let options = Options::all();
|
let options = Options::all();
|
||||||
let parser = Parser::new_ext(markdown_input, options);
|
let parser = Parser::new_ext(markdown_input, options);
|
||||||
let parser = MarkdownParser::new(parser.into_offset_iter().collect(), file_location_directory);
|
let parser = MarkdownParser::new(
|
||||||
let renderer = parser.parse_document();
|
parser.into_offset_iter().collect(),
|
||||||
|
file_location_directory,
|
||||||
|
language_registry,
|
||||||
|
);
|
||||||
|
let renderer = parser.parse_document().await;
|
||||||
ParsedMarkdown {
|
ParsedMarkdown {
|
||||||
children: renderer.parsed,
|
children: renderer.parsed,
|
||||||
}
|
}
|
||||||
|
@ -23,16 +30,19 @@ struct MarkdownParser<'a> {
|
||||||
/// The blocks that we have successfully parsed so far
|
/// The blocks that we have successfully parsed so far
|
||||||
parsed: Vec<ParsedMarkdownElement>,
|
parsed: Vec<ParsedMarkdownElement>,
|
||||||
file_location_directory: Option<PathBuf>,
|
file_location_directory: Option<PathBuf>,
|
||||||
|
language_registry: Option<Arc<LanguageRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> MarkdownParser<'a> {
|
impl<'a> MarkdownParser<'a> {
|
||||||
fn new(
|
fn new(
|
||||||
tokens: Vec<(Event<'a>, Range<usize>)>,
|
tokens: Vec<(Event<'a>, Range<usize>)>,
|
||||||
file_location_directory: Option<PathBuf>,
|
file_location_directory: Option<PathBuf>,
|
||||||
|
language_registry: Option<Arc<LanguageRegistry>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tokens,
|
tokens,
|
||||||
file_location_directory,
|
file_location_directory,
|
||||||
|
language_registry,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
parsed: vec![],
|
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() {
|
while !self.eof() {
|
||||||
if let Some(block) = self.parse_block() {
|
if let Some(block) = self.parse_block().await {
|
||||||
self.parsed.push(block);
|
self.parsed.push(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_block(&mut self) -> Option<ParsedMarkdownElement> {
|
async fn parse_block(&mut self) -> Option<ParsedMarkdownElement> {
|
||||||
let (current, source_range) = self.current().unwrap();
|
let (current, source_range) = self.current().unwrap();
|
||||||
match current {
|
match current {
|
||||||
Event::Start(tag) => match tag {
|
Event::Start(tag) => match tag {
|
||||||
|
@ -119,12 +129,12 @@ impl<'a> MarkdownParser<'a> {
|
||||||
Tag::List(order) => {
|
Tag::List(order) => {
|
||||||
let order = *order;
|
let order = *order;
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
let list = self.parse_list(1, order);
|
let list = self.parse_list(1, order).await;
|
||||||
Some(ParsedMarkdownElement::List(list))
|
Some(ParsedMarkdownElement::List(list))
|
||||||
}
|
}
|
||||||
Tag::BlockQuote => {
|
Tag::BlockQuote => {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
let block_quote = self.parse_block_quote();
|
let block_quote = self.parse_block_quote().await;
|
||||||
Some(ParsedMarkdownElement::BlockQuote(block_quote))
|
Some(ParsedMarkdownElement::BlockQuote(block_quote))
|
||||||
}
|
}
|
||||||
Tag::CodeBlock(kind) => {
|
Tag::CodeBlock(kind) => {
|
||||||
|
@ -141,7 +151,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
|
|
||||||
self.cursor += 1;
|
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))
|
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 (_event, source_range) = self.previous().unwrap();
|
||||||
let source_range = source_range.clone();
|
let source_range = source_range.clone();
|
||||||
let mut children = vec![];
|
let mut children = vec![];
|
||||||
|
@ -424,7 +435,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
let order = *order;
|
let order = *order;
|
||||||
self.cursor += 1;
|
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);
|
let block = ParsedMarkdownElement::List(inner_list);
|
||||||
current_list_items.push(Box::new(block));
|
current_list_items.push(Box::new(block));
|
||||||
}
|
}
|
||||||
|
@ -455,7 +466,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
let block = ParsedMarkdownElement::Paragraph(text);
|
let block = ParsedMarkdownElement::Paragraph(text);
|
||||||
current_list_items.push(Box::new(block));
|
current_list_items.push(Box::new(block));
|
||||||
} else {
|
} else {
|
||||||
let block = self.parse_block();
|
let block = self.parse_block().await;
|
||||||
if let Some(block) = block {
|
if let Some(block) = block {
|
||||||
current_list_items.push(Box::new(block));
|
current_list_items.push(Box::new(block));
|
||||||
}
|
}
|
||||||
|
@ -493,7 +504,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let block = self.parse_block();
|
let block = self.parse_block().await;
|
||||||
if let Some(block) = block {
|
if let Some(block) = block {
|
||||||
current_list_items.push(Box::new(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 (_event, source_range) = self.previous().unwrap();
|
||||||
let source_range = source_range.clone();
|
let source_range = source_range.clone();
|
||||||
let mut nested_depth = 1;
|
let mut nested_depth = 1;
|
||||||
|
@ -515,7 +527,7 @@ impl<'a> MarkdownParser<'a> {
|
||||||
let mut children: Vec<Box<ParsedMarkdownElement>> = vec![];
|
let mut children: Vec<Box<ParsedMarkdownElement>> = vec![];
|
||||||
|
|
||||||
while !self.eof() {
|
while !self.eof() {
|
||||||
let block = self.parse_block();
|
let block = self.parse_block().await;
|
||||||
|
|
||||||
if let Some(block) = block {
|
if let Some(block) = block {
|
||||||
children.push(Box::new(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 (_event, source_range) = self.previous().unwrap();
|
||||||
let source_range = source_range.clone();
|
let source_range = source_range.clone();
|
||||||
let mut code = String::new();
|
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 {
|
ParsedMarkdownCodeBlock {
|
||||||
source_range,
|
source_range,
|
||||||
contents: code.trim().to_string().into(),
|
contents: code.trim().to_string().into(),
|
||||||
language,
|
language,
|
||||||
|
highlights,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -587,18 +615,20 @@ impl<'a> MarkdownParser<'a> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use gpui::BackgroundExecutor;
|
||||||
|
use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher};
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
use ParsedMarkdownElement::*;
|
use ParsedMarkdownElement::*;
|
||||||
use ParsedMarkdownListItemType::*;
|
use ParsedMarkdownListItemType::*;
|
||||||
|
|
||||||
fn parse(input: &str) -> ParsedMarkdown {
|
async fn parse(input: &str) -> ParsedMarkdown {
|
||||||
parse_markdown(input, None)
|
parse_markdown(input, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_headings() {
|
async fn test_headings() {
|
||||||
let parsed = parse("# Heading one\n## Heading two\n### Heading three");
|
let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -610,9 +640,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_newlines_dont_new_paragraphs() {
|
async fn test_newlines_dont_new_paragraphs() {
|
||||||
let parsed = parse("Some text **that is bolded**\n and *italicized*");
|
let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -620,9 +650,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_heading_with_paragraph() {
|
async fn test_heading_with_paragraph() {
|
||||||
let parsed = parse("# Zed\nThe editor");
|
let parsed = parse("# Zed\nThe editor").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -630,9 +660,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_double_newlines_do_new_paragraphs() {
|
async fn test_double_newlines_do_new_paragraphs() {
|
||||||
let parsed = parse("Some text **that is bolded**\n\n and *italicized*");
|
let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -643,9 +673,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_bold_italic_text() {
|
async fn test_bold_italic_text() {
|
||||||
let parsed = parse("Some text **that is bolded** and *italicized*");
|
let parsed = parse("Some text **that is bolded** and *italicized*").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -653,9 +683,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_nested_bold_strikethrough_text() {
|
async fn test_nested_bold_strikethrough_text() {
|
||||||
let parsed = parse("Some **bo~~strikethrough~~ld** text");
|
let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
|
||||||
|
|
||||||
assert_eq!(parsed.children.len(), 1);
|
assert_eq!(parsed.children.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -703,8 +733,8 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_header_only_table() {
|
async fn test_header_only_table() {
|
||||||
let markdown = "\
|
let markdown = "\
|
||||||
| Header 1 | Header 2 |
|
| Header 1 | Header 2 |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
|
@ -719,13 +749,13 @@ Some other content
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse(markdown).children[0],
|
parse(markdown).await.children[0],
|
||||||
ParsedMarkdownElement::Table(expected_table)
|
ParsedMarkdownElement::Table(expected_table)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_basic_table() {
|
async fn test_basic_table() {
|
||||||
let markdown = "\
|
let markdown = "\
|
||||||
| Header 1 | Header 2 |
|
| Header 1 | Header 2 |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
|
@ -742,20 +772,21 @@ Some other content
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse(markdown).children[0],
|
parse(markdown).await.children[0],
|
||||||
ParsedMarkdownElement::Table(expected_table)
|
ParsedMarkdownElement::Table(expected_table)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_list_basic() {
|
async fn test_list_basic() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
* Item 1
|
* Item 1
|
||||||
* Item 2
|
* Item 2
|
||||||
* Item 3
|
* Item 3
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -770,14 +801,15 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_list_with_tasks() {
|
async fn test_list_with_tasks() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
- [ ] TODO
|
- [ ] TODO
|
||||||
- [x] Checked
|
- [x] Checked
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -791,8 +823,8 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_list_nested() {
|
async fn test_list_nested() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
* Item 1
|
* Item 1
|
||||||
|
@ -813,7 +845,8 @@ Some other content
|
||||||
2. Goodbyte
|
2. Goodbyte
|
||||||
* Last
|
* Last
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -900,14 +933,15 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_list_with_nested_content() {
|
async fn test_list_with_nested_content() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
* This is a list item with two paragraphs.
|
* This is a list item with two paragraphs.
|
||||||
|
|
||||||
This is the second paragraph in the list item.",
|
This is the second paragraph in the list item.",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -925,15 +959,16 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_list_with_leading_text() {
|
async fn test_list_with_leading_text() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
* `code`
|
* `code`
|
||||||
* **bold**
|
* **bold**
|
||||||
* [link](https://example.com)
|
* [link](https://example.com)
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -948,9 +983,9 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_simple_block_quote() {
|
async fn test_simple_block_quote() {
|
||||||
let parsed = parse("> Simple block quote with **styled text**");
|
let parsed = parse("> Simple block quote with **styled text**").await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -961,8 +996,8 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_simple_block_quote_with_multiple_lines() {
|
async fn test_simple_block_quote_with_multiple_lines() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
> # Heading
|
> # Heading
|
||||||
|
@ -971,7 +1006,8 @@ Some other content
|
||||||
>
|
>
|
||||||
> More text
|
> More text
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -986,8 +1022,8 @@ Some other content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_nested_block_quote() {
|
async fn test_nested_block_quote() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
> A
|
> A
|
||||||
|
@ -998,7 +1034,8 @@ Some other content
|
||||||
|
|
||||||
More text
|
More text
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
|
@ -1016,8 +1053,8 @@ More text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[gpui::test]
|
||||||
fn test_code_block() {
|
async fn test_code_block() {
|
||||||
let parsed = parse(
|
let parsed = parse(
|
||||||
"\
|
"\
|
||||||
```
|
```
|
||||||
|
@ -1026,17 +1063,28 @@ fn main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
",
|
",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
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]
|
#[gpui::test]
|
||||||
fn test_code_block_with_language() {
|
async fn test_code_block_with_language(executor: BackgroundExecutor) {
|
||||||
let parsed = parse(
|
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
|
```rust
|
||||||
fn main() {
|
fn main() {
|
||||||
|
@ -1044,18 +1092,37 @@ fn main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
",
|
",
|
||||||
);
|
None,
|
||||||
|
Some(language_registry),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.children,
|
parsed.children,
|
||||||
vec![code_block(
|
vec![code_block(
|
||||||
Some("rust".into()),
|
Some("rust".to_string()),
|
||||||
"fn main() {\n return 0;\n}",
|
"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 {
|
fn h1(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
|
||||||
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
|
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
|
||||||
source_range,
|
source_range,
|
||||||
|
@ -1108,11 +1175,13 @@ fn main() {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
code: &str,
|
code: &str,
|
||||||
source_range: Range<usize>,
|
source_range: Range<usize>,
|
||||||
|
highlights: Option<Vec<(Range<usize>, HighlightId)>>,
|
||||||
) -> ParsedMarkdownElement {
|
) -> ParsedMarkdownElement {
|
||||||
ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
|
ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
|
||||||
source_range,
|
source_range,
|
||||||
language,
|
language,
|
||||||
contents: code.to_string().into(),
|
contents: code.to_string().into(),
|
||||||
|
highlights,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::sync::Arc;
|
||||||
use std::{ops::Range, path::PathBuf};
|
use std::{ops::Range, path::PathBuf};
|
||||||
|
|
||||||
use editor::{Editor, EditorEvent};
|
use editor::{Editor, EditorEvent};
|
||||||
|
@ -5,6 +6,7 @@ use gpui::{
|
||||||
list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
|
list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
|
||||||
IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView,
|
IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView,
|
||||||
};
|
};
|
||||||
|
use language::LanguageRegistry;
|
||||||
use ui::prelude::*;
|
use ui::prelude::*;
|
||||||
use workspace::item::{Item, ItemHandle};
|
use workspace::item::{Item, ItemHandle};
|
||||||
use workspace::Workspace;
|
use workspace::Workspace;
|
||||||
|
@ -19,7 +21,7 @@ use crate::{
|
||||||
pub struct MarkdownPreviewView {
|
pub struct MarkdownPreviewView {
|
||||||
workspace: WeakView<Workspace>,
|
workspace: WeakView<Workspace>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
contents: ParsedMarkdown,
|
contents: Option<ParsedMarkdown>,
|
||||||
selected_block: usize,
|
selected_block: usize,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
tab_description: String,
|
tab_description: String,
|
||||||
|
@ -34,10 +36,16 @@ impl MarkdownPreviewView {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(editor) = workspace.active_item_as::<Editor>(cx) {
|
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 workspace_handle = workspace.weak_handle();
|
||||||
let tab_description = editor.tab_description(0, cx);
|
let tab_description = editor.tab_description(0, cx);
|
||||||
let view: View<MarkdownPreviewView> =
|
let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
|
||||||
MarkdownPreviewView::new(editor, workspace_handle, tab_description, cx);
|
editor,
|
||||||
|
workspace_handle,
|
||||||
|
tab_description,
|
||||||
|
language_registry,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx);
|
workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
@ -48,55 +56,82 @@ impl MarkdownPreviewView {
|
||||||
active_editor: View<Editor>,
|
active_editor: View<Editor>,
|
||||||
workspace: WeakView<Workspace>,
|
workspace: WeakView<Workspace>,
|
||||||
tab_description: Option<SharedString>,
|
tab_description: Option<SharedString>,
|
||||||
|
language_registry: Arc<LanguageRegistry>,
|
||||||
cx: &mut ViewContext<Workspace>,
|
cx: &mut ViewContext<Workspace>,
|
||||||
) -> View<Self> {
|
) -> View<Self> {
|
||||||
cx.new_view(|cx: &mut ViewContext<Self>| {
|
cx.new_view(|cx: &mut ViewContext<Self>| {
|
||||||
let view = cx.view().downgrade();
|
let view = cx.view().downgrade();
|
||||||
let editor = active_editor.read(cx);
|
let editor = active_editor.read(cx);
|
||||||
|
|
||||||
let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
|
let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
|
||||||
let contents = editor.buffer().read(cx).snapshot(cx).text();
|
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| {
|
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();
|
||||||
|
|
||||||
|
cx.subscribe(
|
||||||
|
&active_editor,
|
||||||
|
move |this, editor, event: &EditorEvent, cx| {
|
||||||
match event {
|
match event {
|
||||||
EditorEvent::Edited => {
|
EditorEvent::Edited => {
|
||||||
let editor = editor.read(cx);
|
let editor = editor.read(cx);
|
||||||
let contents = editor.buffer().read(cx).snapshot(cx).text();
|
let contents = editor.buffer().read(cx).snapshot(cx).text();
|
||||||
let file_location =
|
let file_location =
|
||||||
MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
|
MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
|
||||||
this.contents = parse_markdown(&contents, file_location);
|
let language_registry = language_registry.clone();
|
||||||
this.list_state.reset(this.contents.children.len());
|
cx.spawn(move |view, mut cx| async move {
|
||||||
cx.notify();
|
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);
|
||||||
|
|
||||||
// TODO: This does not work as expected.
|
let scroll_top = view.list_state.logical_scroll_top();
|
||||||
// The scroll request appears to be dropped
|
view.list_state.reset(markdown_blocks_count);
|
||||||
// after `.reset` is called.
|
view.list_state.scroll_to(scroll_top);
|
||||||
this.list_state.scroll_to_reveal_item(this.selected_block);
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
EditorEvent::SelectionsChanged { .. } => {
|
EditorEvent::SelectionsChanged { .. } => {
|
||||||
let editor = editor.read(cx);
|
let editor = editor.read(cx);
|
||||||
let selection_range = editor.selections.last::<usize>(cx).range();
|
let selection_range = editor.selections.last::<usize>(cx).range();
|
||||||
this.selected_block = this.get_block_index_under_cursor(selection_range);
|
this.selected_block =
|
||||||
|
this.get_block_index_under_cursor(selection_range);
|
||||||
this.list_state.scroll_to_reveal_item(this.selected_block);
|
this.list_state.scroll_to_reveal_item(this.selected_block);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
let list_state = ListState::new(
|
let list_state =
|
||||||
contents.children.len(),
|
ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
|
||||||
gpui::ListAlignment::Top,
|
|
||||||
px(1000.),
|
|
||||||
move |ix, cx| {
|
|
||||||
if let Some(view) = view.upgrade() {
|
if let Some(view) = view.upgrade() {
|
||||||
view.update(cx, |view, cx| {
|
view.update(cx, |view, cx| {
|
||||||
|
let Some(contents) = &view.contents else {
|
||||||
|
return div().into_any();
|
||||||
|
};
|
||||||
let mut render_cx =
|
let mut render_cx =
|
||||||
RenderContext::new(Some(view.workspace.clone()), 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 = render_markdown_block(block, &mut render_cx);
|
||||||
let block = div().child(block).pl_4().pb_3();
|
let block = div().child(block).pl_4().pb_3();
|
||||||
|
|
||||||
|
@ -119,8 +154,7 @@ impl MarkdownPreviewView {
|
||||||
} else {
|
} else {
|
||||||
div().into_any()
|
div().into_any()
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
let tab_description = tab_description
|
let tab_description = tab_description
|
||||||
.map(|tab_description| format!("Preview {}", tab_description))
|
.map(|tab_description| format!("Preview {}", tab_description))
|
||||||
|
@ -130,9 +164,9 @@ impl MarkdownPreviewView {
|
||||||
selected_block: 0,
|
selected_block: 0,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
workspace,
|
workspace,
|
||||||
contents,
|
contents: None,
|
||||||
list_state,
|
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 {
|
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;
|
let cursor = selection_range.start;
|
||||||
|
|
||||||
for (i, block) in self.contents.children.iter().enumerate() {
|
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();
|
let Range { start, end } = block.source_range();
|
||||||
if start <= cursor && end >= cursor {
|
|
||||||
block_index = i;
|
// 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;
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -248,11 +248,25 @@ fn render_markdown_code_block(
|
||||||
parsed: &ParsedMarkdownCodeBlock,
|
parsed: &ParsedMarkdownCodeBlock,
|
||||||
cx: &mut RenderContext,
|
cx: &mut RenderContext,
|
||||||
) -> AnyElement {
|
) -> 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())
|
cx.with_common_p(div())
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_3()
|
.py_3()
|
||||||
.bg(cx.code_block_background_color)
|
.bg(cx.code_block_background_color)
|
||||||
.child(StyledText::new(parsed.contents.clone()))
|
.rounded_md()
|
||||||
|
.child(body)
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ test-support = [
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
async-recursion = "1.0.0"
|
async-recursion.workspace = true
|
||||||
bincode = "1.2.1"
|
bincode = "1.2.1"
|
||||||
call.workspace = true
|
call.workspace = true
|
||||||
client.workspace = true
|
client.workspace = true
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue