logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
[package]
name = "markdown"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/markdown.rs"
doctest = false
[features]
test-support = [
"gpui/test-support",
"util/test-support"
]
[dependencies]
anyhow.workspace = true
base64.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
html5ever.workspace = true
language.workspace = true
linkify.workspace = true
log.workspace = true
markup5ever_rcdom.workspace = true
mermaid-rs-renderer.workspace = true
pulldown-cmark.workspace = true
settings.workspace = true
stacksafe.workspace = true
sum_tree.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
util.workspace = true
[dev-dependencies]
assets.workspace = true
env_logger.workspace = true
fs = {workspace = true, features = ["test-support"]}
gpui = { workspace = true, features = ["test-support"] }
gpui_platform.workspace = true
language = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["load-grammars"] }
node_runtime.workspace = true
settings = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }

1
crates/markdown/LICENSE-GPL Symbolic link
View File

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

View File

@@ -0,0 +1,121 @@
use assets::Assets;
use gpui::{Entity, KeyBinding, StyleRefinement, WindowOptions, prelude::*, rgb};
use language::LanguageRegistry;
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use node_runtime::NodeRuntime;
use settings::SettingsStore;
use std::sync::Arc;
use theme::LoadThemes;
use ui::prelude::*;
use ui::{App, Window, div};
const MARKDOWN_EXAMPLE: &str = r#"
# Markdown Example Document
## Headings
Headings are created by adding one or more `#` symbols before your heading text. The number of `#` you use will determine the size of the heading.
```
function a(b: T) {
}
```
Remember, markdown processors may have slight differences and extensions, so always refer to the specific documentation or guides relevant to your platform or editor for the best practices and additional features.
## Images
![Alt Text](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCA1NDAgMzAwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzdHlsZT4KICAgIC5ncmVlbi1zaGFwZSB7CiAgICAgIGZpbGw6ICNDNEVFRDA7IC8qIExpZ2h0IG1vZGUgKi8KICAgIH0KCiAgICBAbWVkaWEgKHByZWZlcnMtY29sb3Itc2NoZW1lOiBkYXJrKSB7CiAgICAgIC5ncmVlbi1zaGFwZSB7CiAgICAgICAgZmlsbDogIzEyNTIyNTsgLyogRGFyayBtb2RlICovCiAgICAgIH0KICAgIH0KICA8L3N0eWxlPgogIDxwYXRoIGQ9Ik00MjAgMzBMMzkwIDYwTDQ4MCAxNTBMMzkwIDI0MEwzMzAgMTgwTDMwMCAyMTBMMzkwIDMwMEw1NDAgMTUwTDQyMCAzMFoiIGNsYXNzPSJncmVlbi1zaGFwZSIvPgogIDxwYXRoIGQ9Ik0xNTAgMEwzMCAxMjBMNjAgMTUwTDE1MCA2MEwyMTAgMTIwTDI0MCA5MEwxNTAgMFoiIGNsYXNzPSJncmVlbi1zaGFwZSIvPgogIDxwYXRoIGQ9Ik0zOTAgMEw0MjAgMzBMMTUwIDMwMEwwIDE1MEwzMCAxMjBMMTUwIDI0MEwzOTAgMFoiIGZpbGw9IiMxRUE0NDYiLz4KPC9zdmc+) item one
![other alt text](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCA1NDAgMzAwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzdHlsZT4KICAgIC5ncmF5LXNoYXBlIHsKICAgICAgZmlsbDogI0M2QzZDNjsgLyogTGlnaHQgbW9kZSAqLwogICAgfQoKICAgIEBtZWRpYSAocHJlZmVycy1jb2xvci1zY2hlbWU6IGRhcmspIHsKICAgICAgLmdyYXktc2hhcGUgewogICAgICAgIGZpbGw6ICM1NjU2NTY7IC8qIERhcmsgbW9kZSAqLwogICAgICB9CiAgICB9CiAgPC9zdHlsZT4KICA8cGF0aCBkPSJNMTUwIDBMMjQwIDkwTDIxMCAxMjBMMTIwIDMwTDE1MCAwWiIgZmlsbD0iI0YwOTQwOSIvPgogIDxwYXRoIGQ9Ik00MjAgMzBMNTQwIDE1MEw0MjAgMjcwTDM5MCAyNDBMNDgwIDE1MEwzOTAgNjBMNDIwIDMwWiIgY2xhc3M9ImdyYXktc2hhcGUiLz4KICA8cGF0aCBkPSJNMzMwIDE4MEwzMDAgMjEwTDM5MCAzMDBMNDIwIDI3MEwzMzAgMTgwWiIgZmlsbD0iI0YwOTQwOSIvPgogIDxwYXRoIGQ9Ik0xMjAgMzBMMTUwIDYwTDYwIDE1MEwxNTAgMjQwTDEyMCAyNzBMMCAxNTBMMTIwIDMwWiIgY2xhc3M9ImdyYXktc2hhcGUiLz4KICA8cGF0aCBkPSJNMzkwIDBMNDIwIDMwTDE1MCAzMDBMMTIwIDI3MEwzOTAgMFoiIGZpbGw9IiNGMDk0MDkiLz4KPC9zdmc+) item two
![third alt text](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCA1NDAgMzAwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzdHlsZT4KICAgIC5ibHVlLXNoYXBlIHsKICAgICAgZmlsbDogI0E4QzdGQTsgLyogTGlnaHQgbW9kZSAqLwogICAgfQoKICAgIEBtZWRpYSAocHJlZmVycy1jb2xvci1zY2hlbWU6IGRhcmspIHsKICAgICAgLmJsdWUtc2hhcGUgewogICAgICAgIGZpbGw6ICMyRDUwOUU7IC8qIERhcmsgbW9kZSAqLwogICAgICB9CiAgICB9CgogICAgLmRhcmtlci1ibHVlLXNoYXBlIHsKICAgICAgICBmaWxsOiAjMUI2RUYzOwogICAgfQoKICAgIEBtZWRpYSAocHJlZmVycy1jb2xvci1zY2hlbWU6IGRhcmspIHsKICAgICAgICAuZGFya2VyLWJsdWUtc2hhcGUgewogICAgICAgICAgICBmaWxsOiAjNDE4NUZGOwogICAgICAgIH0KICAgIH0KCiAgPC9zdHlsZT4KICA8cGF0aCBkPSJNMTUwIDBMMTgwIDMwTDE1MCA2MEwxMjAgMzBMMTUwIDBaIiBjbGFzcz0iYmx1ZS1zaGFwZSIvPgogIDxwYXRoIGQ9Ik0yMTAgNjBMMjQwIDkwTDIxMCAxMjBMMTgwIDkwTDIxMCA2MFoiIGNsYXNzPSJibHVlLXNoYXBlIi8+CiAgPHBhdGggZD0iTTQ1MCA2MEw0ODAgOTBMNDUwIDEyMEw0MjAgOTBMNDUwIDYwWiIgY2xhc3M9ImJsdWUtc2hhcGUiLz4KICA8cGF0aCBkPSJNNTEwIDEyMEw1NDAgMTUwTDUxMCAxODBMNDgwIDE1MEw1MTAgMTIwWiIgY2xhc3M9ImJsdWUtc2hhcGUiLz4KICA8cGF0aCBkPSJNNDUwIDE4MEw0ODAgMjEwTDQ1MCAyNDBMNDIwIDIxMEw0NTAgMTgwWiIgY2xhc3M9ImJsdWUtc2hhcGUiLz4KICA8cGF0aCBkPSJNMzkwIDI0MEw0MjAgMjcwTDM5MCAzMDBMMzYwIDI3MEwzOTAgMjQwWiIgY2xhc3M9ImJsdWUtc2hhcGUiLz4KICA8cGF0aCBkPSJNMzMwIDE4MEwzNjAgMjEwTDMzMCAyNDBMMzAwIDIxMEwzMzAgMTgwWiIgY2xhc3M9ImJsdWUtc2hhcGUiLz4KICA8cGF0aCBkPSJNOTAgNjBMMTIwIDkwTDkwIDEyMEw2MCA5MEw5MCA2MFoiIGNsYXNzPSJibHVlLXNoYXBlIi8+CiAgPHBhdGggZD0iTTM5MCAwTDQyMCAzMEwxNTAgMzAwTDAgMTUwTDMwIDEyMEwxNTAgMjQwTDM5MCAwWiIgY2xhc3M9ImRhcmtlci1ibHVlLXNoYXBlIi8+Cjwvc3ZnPg==) item three
"#;
pub fn main() {
env_logger::init();
gpui_platform::application().with_assets(Assets).run(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]);
let node_runtime = NodeRuntime::unavailable();
theme_settings::init(LoadThemes::JustBase, cx);
let fs = fs::FakeFs::new(cx.background_executor().clone());
let language_registry = LanguageRegistry::new(cx.background_executor().clone());
language_registry.set_theme(cx.theme().clone());
let language_registry = Arc::new(language_registry);
languages::init(language_registry.clone(), fs, node_runtime, cx);
Assets.load_fonts(cx).unwrap();
cx.activate(true);
cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|cx| MarkdownExample::new(MARKDOWN_EXAMPLE.into(), language_registry, cx))
})
.unwrap();
});
}
struct MarkdownExample {
markdown: Entity<Markdown>,
}
impl MarkdownExample {
pub fn new(text: SharedString, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
let markdown = cx
.new(|cx| Markdown::new(text, Some(language_registry), Some("TypeScript".into()), cx));
Self { markdown }
}
}
impl Render for MarkdownExample {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let markdown_style = MarkdownStyle {
base_text_style: gpui::TextStyle {
font_family: ".ZedSans".into(),
color: cx.theme().colors().terminal_ansi_black,
..Default::default()
},
code_block: StyleRefinement::default()
.font_family(".ZedMono")
.m(rems(1.))
.bg(rgb(0xAAAAAAA)),
inline_code: gpui::TextStyleRefinement {
font_family: Some(".ZedMono".into()),
color: Some(cx.theme().colors().editor_foreground),
background_color: Some(cx.theme().colors().editor_background),
..Default::default()
},
rule_color: Color::Muted.color(cx),
block_quote_border_color: Color::Muted.color(cx),
block_quote: gpui::TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: gpui::TextStyleRefinement {
color: Some(Color::Accent.color(cx)),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(Color::Accent.color(cx)),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().colors().element_selection_background,
..Default::default()
};
div()
.id("markdown-example")
.debug_selector(|| "foo".into())
.relative()
.bg(gpui::white())
.size_full()
.p_4()
.overflow_y_scroll()
.child(MarkdownElement::new(self.markdown.clone(), markdown_style))
}
}

View File

@@ -0,0 +1,113 @@
use assets::Assets;
use gpui::{Entity, KeyBinding, Length, StyleRefinement, WindowOptions, rgb};
use language::LanguageRegistry;
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use node_runtime::NodeRuntime;
use settings::SettingsStore;
use std::sync::Arc;
use theme::LoadThemes;
use ui::div;
use ui::prelude::*;
const MARKDOWN_EXAMPLE: &str = r#"
this text should be selectable
wow so cool
## Heading 2
"#;
pub fn main() {
env_logger::init();
gpui_platform::application().with_assets(Assets).run(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]);
let node_runtime = NodeRuntime::unavailable();
let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
let fs = fs::FakeFs::new(cx.background_executor().clone());
languages::init(language_registry, fs, node_runtime, cx);
theme_settings::init(LoadThemes::JustBase, cx);
Assets.load_fonts(cx).unwrap();
cx.activate(true);
let _ = cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|cx| {
let markdown = cx.new(|cx| Markdown::new(MARKDOWN_EXAMPLE.into(), None, None, cx));
HelloWorld { markdown }
})
});
});
}
struct HelloWorld {
markdown: Entity<Markdown>,
}
impl Render for HelloWorld {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let markdown_style = MarkdownStyle {
base_text_style: gpui::TextStyle {
font_family: "Zed Mono".into(),
color: cx.theme().colors().text,
..Default::default()
},
code_block: StyleRefinement {
text: gpui::TextStyleRefinement {
font_family: Some("Zed Mono".into()),
background_color: Some(cx.theme().colors().editor_background),
..Default::default()
},
margin: gpui::EdgesRefinement {
top: Some(Length::Definite(rems(4.).into())),
left: Some(Length::Definite(rems(4.).into())),
right: Some(Length::Definite(rems(4.).into())),
bottom: Some(Length::Definite(rems(4.).into())),
},
..Default::default()
},
inline_code: gpui::TextStyleRefinement {
font_family: Some("Zed Mono".into()),
background_color: Some(cx.theme().colors().editor_background),
..Default::default()
},
rule_color: Color::Muted.color(cx),
block_quote_border_color: Color::Muted.color(cx),
block_quote: gpui::TextStyleRefinement {
color: Some(Color::Muted.color(cx)),
..Default::default()
},
link: gpui::TextStyleRefinement {
color: Some(Color::Accent.color(cx)),
underline: Some(gpui::UnderlineStyle {
thickness: px(1.),
color: Some(Color::Accent.color(cx)),
wavy: false,
}),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().colors().element_selection_background,
heading: Default::default(),
..Default::default()
};
div()
.flex()
.bg(rgb(0x2e7d32))
.size(Length::Definite(px(700.0).into()))
.justify_center()
.items_center()
.shadow_lg()
.border_1()
.border_color(rgb(0x0000ff))
.text_xl()
.text_color(rgb(0xffffff))
.child(
div()
.child(MarkdownElement::new(self.markdown.clone(), markdown_style))
.p_20(),
)
}
}

View File

@@ -0,0 +1,3 @@
mod html_minifier;
pub(crate) mod html_parser;
mod html_rendering;

View File

@@ -0,0 +1,829 @@
use html5ever::{
Attribute, ParseOpts, QualName, parse_document,
tendril::{Tendril, TendrilSink, fmt::UTF8},
};
use markup5ever_rcdom::{Node, NodeData, RcDom};
use std::{cell::RefCell, io, rc::Rc, str};
#[derive(Default)]
pub(crate) struct MinifierOptions {
pub omit_doctype: bool,
pub preserve_comments: bool,
pub collapse_whitespace: bool,
}
pub(crate) struct Minifier<'a, W: io::Write> {
w: &'a mut W,
options: MinifierOptions,
preceding_whitespace: bool,
}
impl<'a, W> Minifier<'a, W>
where
W: io::Write,
{
/// Creates a new `Minifier` instance.
#[inline]
pub fn new(w: &'a mut W, options: MinifierOptions) -> Self {
Self {
w,
options,
preceding_whitespace: false,
}
}
/// Minifies the given reader input.
///
/// # Errors
///
/// Will return `Err` if unable to write to the output writer.
#[inline]
pub fn minify<R: io::Read>(&mut self, mut r: &mut R) -> io::Result<()> {
let dom = parse_document(RcDom::default(), ParseOpts::default())
.from_utf8()
.read_from(&mut r)?;
if !self.options.omit_doctype {
self.w.write_all(b"<!doctype html>")?;
}
self.minify_node(&None, &dom.document)
}
fn minify_node<'b>(&mut self, ctx: &'b Option<Context>, node: &'b Node) -> io::Result<()> {
match &node.data {
NodeData::Text { contents } => {
// Check if whitespace collapsing disabled
let contents = contents.borrow();
let contents = contents.as_ref();
if !self.options.collapse_whitespace {
return self.w.write_all(contents.as_bytes());
}
// Check if parent is whitespace preserving element or contains code (<script>, <style>)
let (skip_collapse_whitespace, contains_code) =
ctx.as_ref().map_or((false, false), |ctx| {
if let NodeData::Element { name, .. } = &ctx.parent.data {
let name = name.local.as_ref();
(preserve_whitespace(name), contains_code(name))
} else {
(false, false)
}
});
if skip_collapse_whitespace {
return self.w.write_all(contents.as_bytes());
}
if contains_code {
return self
.w
.write_all(contents.trim_matches(is_ascii_whitespace).as_bytes());
}
// Early exit if empty to forego expensive trim logic
if contents.is_empty() {
return io::Result::Ok(());
}
let (trim_left, trim_right) = ctx
.as_ref()
.map_or((true, true), |ctx| ctx.trim(self.preceding_whitespace));
let contents = match (trim_left, trim_right) {
(true, true) => contents.trim_matches(is_ascii_whitespace),
(true, false) => contents.trim_start_matches(is_ascii_whitespace),
(false, true) => contents.trim_end_matches(is_ascii_whitespace),
_ => contents,
};
// Second empty check after trimming whitespace
if !contents.is_empty() {
// replace \n, \r to ' '
let contents = contents
.bytes()
.map(|c| if matches!(c, b'\n' | b'\r') { b' ' } else { c })
.collect::<Vec<u8>>();
self.write_collapse_whitespace(&contents, reserved_entity, None)?;
self.preceding_whitespace = !trim_right
&& contents
.iter()
.last()
.map_or(false, u8::is_ascii_whitespace);
}
Ok(())
}
NodeData::Comment { contents } if self.options.preserve_comments => {
self.w.write_all(b"<!--")?;
self.w.write_all(contents.as_bytes())?;
self.w.write_all(b"-->")
}
NodeData::Document => self.minify_children(ctx, node),
NodeData::Element { name, attrs, .. } => {
let attrs = attrs.borrow();
let tag = name.local.as_ref();
if is_self_closing(tag) {
return self.write_start_tag(name, &attrs);
}
let (omit_start_tag, omit_end_tag) =
self.omit_tags(ctx, node, tag, attrs.is_empty());
if !omit_start_tag {
self.write_start_tag(name, &attrs)?;
}
self.minify_children(ctx, node)?;
if !omit_end_tag {
self.write_end_tag(name)?;
}
Ok(())
}
_ => Ok(()),
}
}
fn next_is_comment<'b, I>(&self, v: I) -> bool
where
I: IntoIterator<Item = &'b Rc<Node>>,
{
v.into_iter()
.find_map(|node| match &node.data {
NodeData::Text { contents } => {
if self.options.collapse_whitespace && is_whitespace(contents) {
// Blocks of whitespace are skipped
None
} else {
Some(false)
}
}
NodeData::Comment { .. } => Some(self.options.preserve_comments),
_ => Some(false),
})
.unwrap_or(false)
}
fn is_whitespace(&self, s: &RefCell<Tendril<UTF8>>) -> Option<bool> {
if self.options.collapse_whitespace && is_whitespace(s) {
None
} else {
Some(
!s.borrow()
.as_bytes()
.iter()
.next()
.map_or(false, u8::is_ascii_whitespace),
)
}
}
/// Determines if start and end tags can be omitted.
/// Whitespace rules are ignored if `collapse_whitespace` is enabled.
#[allow(clippy::too_many_lines)]
fn omit_tags(
&self,
ctx: &Option<Context>,
node: &Node,
name: &str,
empty_attributes: bool,
) -> (bool, bool) {
ctx.as_ref().map_or((false, false), |ctx| match name {
"html" => {
// The end tag may be omitted if the <html> element is not immediately followed by a comment.
let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right));
// The start tag may be omitted if the first thing inside the <html> element is not a comment.
let omit_start =
empty_attributes && omit_end && !self.next_is_comment(&*node.children.borrow());
(omit_start, omit_end)
}
"head" => {
// The end tag may be omitted if the first thing following the <head> element is not a space character or a comment.
let omit_end = ctx.right.map_or(true, |right| {
right
.iter()
.find_map(|node| match &node.data {
NodeData::Text { contents } => self.is_whitespace(contents),
NodeData::Comment { .. } => {
if self.options.preserve_comments {
Some(false)
} else {
None
}
}
_ => Some(true),
})
.unwrap_or(true)
});
// The start tag may be omitted if the first thing inside the <head> element is an element.
let omit_start = empty_attributes
&& omit_end
&& node
.children
.borrow()
.iter()
.find_map(|node| match &node.data {
NodeData::Text { contents } => self.is_whitespace(contents),
NodeData::Element { .. } => Some(true),
NodeData::Comment { .. } => {
if self.options.preserve_comments {
Some(false)
} else {
None
}
}
_ => Some(false),
})
.unwrap_or(true);
(omit_start, omit_end)
}
"body" => {
// The start tag may be omitted if the first thing inside it is not a space character, comment, <script> element or <style> element.
let omit_start = empty_attributes
&& node
.children
.borrow()
.iter()
.find_map(|node| match &node.data {
NodeData::Text { contents } => self.is_whitespace(contents),
NodeData::Element { name, .. } => {
Some(!matches!(name.local.as_ref(), "script" | "style"))
}
NodeData::Comment { .. } => {
if self.options.preserve_comments {
Some(false)
} else {
None
}
}
_ => Some(true),
})
.unwrap_or(true);
// The end tag may be omitted if the <body> element has contents or has a start tag, and is not immediately followed by a comment.
let omit_end = ctx.right.map_or(true, |right| !self.next_is_comment(right));
(omit_start && omit_end, omit_end)
}
"p" => {
let omit_end = ctx.next_element().map_or(true, |node| {
if let NodeData::Element { name, .. } = &node.data {
matches!(
name.local.as_ref().to_ascii_lowercase().as_str(),
"address"
| "article"
| "aside"
| "blockquote"
| "div"
| "dl"
| "fieldset"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "header"
| "hr"
| "menu"
| "nav"
| "ol"
| "p"
| "pre"
| "section"
| "table"
| "ul"
)
} else {
false
}
});
(false, omit_end)
}
// TODO: comprehensive handling of optional end element rules
_ => (false, optional_end_tag(name)),
})
}
#[allow(clippy::needless_pass_by_value)]
fn minify_children(&mut self, ctx: &Option<Context>, node: &Node) -> io::Result<()> {
let children = node.children.borrow();
let l = children.len();
children.iter().enumerate().try_for_each(|(i, child)| {
if self.preceding_whitespace && is_block_element(child) {
self.preceding_whitespace = false;
}
self.minify_node(
&Some(Context {
parent: node,
parent_context: ctx.as_ref(),
left: if i > 0 { Some(&children[..i]) } else { None },
right: if i + 1 < l {
Some(&children[i + 1..])
} else {
None
},
}),
child,
)
})
}
fn write_qualified_name(&mut self, name: &QualName) -> io::Result<()> {
if let Some(prefix) = &name.prefix {
self.w
.write_all(prefix.as_ref().to_ascii_lowercase().as_bytes())?;
self.w.write_all(b":")?;
}
self.w
.write_all(name.local.as_ref().to_ascii_lowercase().as_bytes())
}
fn write_start_tag(&mut self, name: &QualName, attrs: &[Attribute]) -> io::Result<()> {
self.w.write_all(b"<")?;
self.write_qualified_name(name)?;
attrs
.iter()
.try_for_each(|attr| self.write_attribute(attr))?;
self.w.write_all(b">")
}
fn write_end_tag(&mut self, name: &QualName) -> io::Result<()> {
self.w.write_all(b"</")?;
self.write_qualified_name(name)?;
self.w.write_all(b">")
}
fn write_attribute(&mut self, attr: &Attribute) -> io::Result<()> {
self.w.write_all(b" ")?;
self.write_qualified_name(&attr.name)?;
let value = attr.value.as_ref();
let value = if self.options.collapse_whitespace {
value.trim_matches(is_ascii_whitespace)
} else {
value
};
if value.is_empty() {
return io::Result::Ok(());
}
self.w.write_all(b"=")?;
let b = value.as_bytes();
let (unquoted, double, _) =
b.iter()
.fold((true, false, false), |(unquoted, double, single), &c| {
let (double, single) = (double || c == b'"', single || c == b'\'');
let unquoted =
unquoted && !double && !single && c != b'=' && !c.is_ascii_whitespace();
(unquoted, double, single)
});
if unquoted {
self.w.write_all(b)
} else if double {
self.write_attribute_value(b, b"'", reserved_entity_with_apos)
} else {
self.write_attribute_value(b, b"\"", reserved_entity)
}
}
fn write_attribute_value<T: AsRef<[u8]>>(
&mut self,
v: T,
quote: &[u8],
f: EntityFn,
) -> io::Result<()> {
self.w.write_all(quote)?;
let b = v.as_ref();
if self.options.collapse_whitespace {
self.write_collapse_whitespace(b, f, Some(false))
} else {
self.w.write_all(b)
}?;
self.w.write_all(quote)
}
/// Efficiently writes blocks of content, e.g. a string with no collapsed
/// whitespace would result in a single write.
fn write_collapse_whitespace(
&mut self,
b: &[u8],
f: EntityFn,
preceding_whitespace: Option<bool>,
) -> io::Result<()> {
b.iter()
.enumerate()
.try_fold(
(0, preceding_whitespace.unwrap_or(self.preceding_whitespace)),
|(pos, preceding_whitespace), (i, &c)| {
let is_whitespace = c.is_ascii_whitespace();
Ok(if is_whitespace && preceding_whitespace {
if i != pos {
self.write(&b[pos..i], f)?;
}
// ASCII whitespace = 1 byte
(i + 1, true)
} else {
(pos, is_whitespace)
})
},
)
.and_then(|(pos, _)| {
if pos < b.len() {
self.write(&b[pos..], f)?;
}
Ok(())
})
}
fn write(&mut self, b: &[u8], f: EntityFn) -> io::Result<()> {
b.iter()
.enumerate()
.try_fold(0, |pos, (i, &c)| {
Ok(if let Some(entity) = f(c) {
self.w.write_all(&b[pos..i])?;
self.w.write_all(entity)?;
// Reserved characters are 1 byte
i + 1
} else {
pos
})
})
.and_then(|pos| {
if pos < b.len() {
self.w.write_all(&b[pos..])?;
}
Ok(())
})
}
}
struct Context<'a> {
parent: &'a Node,
parent_context: Option<&'a Context<'a>>,
left: Option<&'a [Rc<Node>]>,
right: Option<&'a [Rc<Node>]>,
}
impl<'a> Context<'a> {
/// Determine whether to trim whitespace.
/// Uses naive HTML5 whitespace collapsing rules.
fn trim(&self, preceding_whitespace: bool) -> (bool, bool) {
(preceding_whitespace || self.trim_left(), self.trim_right())
}
fn trim_left(&self) -> bool {
self.left.map_or_else(
|| is_block_element(self.parent) || self.parent_trim_left(),
|siblings| {
siblings
.iter()
.rev()
.find_map(Self::is_block_element)
.unwrap_or_else(|| self.parent_trim_left())
},
)
}
fn parent_trim_left(&self) -> bool {
self.parent_context.map_or(true, Context::trim_left)
}
fn trim_right(&self) -> bool {
self.right.map_or(true, |siblings| {
siblings
.iter()
.find_map(Self::is_block_element)
.unwrap_or(true)
})
}
fn next_element(&self) -> Option<&Rc<Node>> {
self.right.and_then(|siblings| {
siblings
.iter()
.find(|node| matches!(node.data, NodeData::Element { .. }))
})
}
fn is_block_element(node: &Rc<Node>) -> Option<bool> {
if let NodeData::Element { name, .. } = &node.data {
Some(is_block_element_name(name.local.as_ref()))
} else {
None
}
}
}
type EntityFn = fn(u8) -> Option<&'static [u8]>;
const fn reserved_entity(v: u8) -> Option<&'static [u8]> {
match v {
b'<' => Some(b"&lt;"),
b'>' => Some(b"&gt;"),
b'&' => Some(b"&#38;"),
_ => None,
}
}
const fn reserved_entity_with_apos(v: u8) -> Option<&'static [u8]> {
if v == b'\'' {
Some(b"&#39;")
} else {
reserved_entity(v)
}
}
fn is_whitespace(s: &RefCell<Tendril<UTF8>>) -> bool {
s.borrow().as_bytes().iter().all(u8::is_ascii_whitespace)
}
fn is_block_element_name(name: &str) -> bool {
matches!(
name,
"address"
| "article"
| "aside"
| "blockquote"
| "body"
| "br"
| "details"
| "dialog"
| "dd"
| "div"
| "dl"
| "dt"
| "fieldset"
| "figcaption"
| "figure"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "head"
| "header"
| "hgroup"
| "hr"
| "html"
| "li"
| "link"
| "main"
| "meta"
| "nav"
| "ol"
| "option"
| "p"
| "pre"
| "script"
| "section"
| "source"
| "table"
| "td"
| "th"
| "title"
| "tr"
| "ul"
)
}
fn is_block_element(node: &Node) -> bool {
match &node.data {
NodeData::Element { name, .. } => is_block_element_name(name.local.as_ref()),
NodeData::Document => true,
_ => false,
}
}
#[allow(clippy::missing_const_for_fn)]
fn is_ascii_whitespace(c: char) -> bool {
c.is_ascii_whitespace()
}
fn preserve_whitespace(name: &str) -> bool {
matches!(name, "pre" | "textarea")
}
fn contains_code(name: &str) -> bool {
matches!(name, "script" | "style")
}
fn is_self_closing(name: &str) -> bool {
matches!(
name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
| "command"
| "keygen"
| "menuitem"
)
}
fn optional_end_tag(name: &str) -> bool {
matches!(
name,
"basefont"
| "colgroup"
| "dd"
| "dt"
| "frame"
| "isindex"
| "li"
| "option"
| "p"
| "tbody"
| "td"
| "tfoot"
| "th"
| "thead"
| "tr"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::str;
#[test]
fn test_write_collapse_whitespace() {
for &(input, expected, preceding_whitespace) in &[
("", "", false),
(" ", " ", false),
(" ", " ", false),
(" ", "", true),
(" x y ", " x y ", false),
(" x y ", "x y ", true),
(" x \n \t \n y ", " x y ", false),
(" x \n \t \n y ", "x y ", true),
] {
let mut w = Vec::new();
let mut minifier = Minifier::new(&mut w, MinifierOptions::default());
minifier.preceding_whitespace = preceding_whitespace;
minifier
.write_collapse_whitespace(
input.as_bytes(),
reserved_entity,
Some(preceding_whitespace),
)
.unwrap();
let s = str::from_utf8(&w).unwrap();
assert_eq!(expected, s);
}
}
#[test]
fn test_omit_tags() {
for &(input, expected, collapse_whitespace, preserve_comments) in &[
// <html>
("<html>", "", true, false),
// Comments ignored
("<html><!-- -->", "", true, false),
// Comments preserved
("<html> <!-- --> ", "<html><!-- -->", true, true),
("<html><!-- --></html>", "<html><!-- -->", true, true),
(
"<html><!-- --></html><!-- -->",
"<html><!-- --></html><!-- -->",
true,
true,
),
(
"<html> <!-- --> </html> <!-- --> ",
"<html><!-- --></html><!-- -->",
true,
true,
),
(
"<html> <!-- --> </html> <!-- --> ",
// <body> is implicitly added to the DOM
"<html><!-- --><body> </html><!-- -->",
false,
true,
),
// <head>
(
"<html> <head> <title>A</title> </head> <body><p> B </p> </body>",
"<title>A</title><p>B",
true,
false,
),
(
"<html> <head> <title>A</title> </head> <body><p> B </p> </body>",
"<head> <title>A</title> </head> <p> B ",
false,
false,
),
(
"<html> <head><!-- --> <title>A</title> </head> <body><p> B </p> </body>",
"<head><!-- --><title>A</title><p>B",
true,
true,
),
// <body>
("<body>", "", true, false),
(
"<body> <script>let x = 1;</script> ",
"<body><script>let x = 1;</script>",
true,
false,
),
(
"<body> <style>body{margin:1em}</style>",
"<body><style>body{margin:1em}</style>",
true,
false,
),
("<body> <p>A", "<p>A", true, false),
("<body id=main> <p>A", "<body id=main><p>A", true, false),
// Retain whitespace, whitespace before <p>
(
" <body> <p>A ",
"<body> <p>A ",
false,
false,
),
// Retain whitespace, touching <p>
("<body><p>A</body>", "<p>A", false, false),
// Comments ignored
("<body><p>A</body><!-- -->", "<p>A", false, false),
// Comments preserved
(
"<body><p>A</body><!-- -->",
"<body><p>A</body><!-- -->",
false,
true,
),
// Retain end tag if touching inline element
(
"<p>Some text</p><button></button>",
"<p>Some text</p><button></button>",
false,
false,
),
] {
let mut w = Vec::new();
let mut minifier = Minifier::new(
&mut w,
MinifierOptions {
omit_doctype: true,
preserve_comments,
collapse_whitespace,
},
);
minifier.minify(&mut input.as_bytes()).unwrap();
let s = str::from_utf8(&w).unwrap();
assert_eq!(expected, s);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
use std::ops::Range;
use gpui::{
App, FontStyle, FontWeight, StrikethroughStyle, TextAlign, TextStyleRefinement, UnderlineStyle,
};
use pulldown_cmark::Alignment;
use ui::prelude::*;
use crate::html::html_parser::{
HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock,
ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow,
ParsedHtmlText,
};
use crate::{MarkdownElement, MarkdownElementBuilder};
pub(crate) struct HtmlSourceAllocator {
source_range: Range<usize>,
next_source_index: usize,
}
impl HtmlSourceAllocator {
pub(crate) fn new(source_range: Range<usize>) -> Self {
Self {
next_source_index: source_range.start,
source_range,
}
}
pub(crate) fn allocate(&mut self, requested_len: usize) -> Range<usize> {
let remaining = self.source_range.end.saturating_sub(self.next_source_index);
let len = requested_len.min(remaining);
let start = self.next_source_index;
let end = start + len;
self.next_source_index = end;
start..end
}
}
impl MarkdownElement {
pub(crate) fn render_html_block(
&self,
block: &ParsedHtmlBlock,
builder: &mut MarkdownElementBuilder,
markdown_end: usize,
cx: &mut App,
) {
let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone());
self.render_html_elements(
&block.children,
&mut source_allocator,
builder,
markdown_end,
cx,
);
}
fn render_html_elements(
&self,
elements: &[ParsedHtmlElement],
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
markdown_end: usize,
cx: &mut App,
) {
for element in elements {
self.render_html_element(element, source_allocator, builder, markdown_end, cx);
}
}
fn render_html_element(
&self,
element: &ParsedHtmlElement,
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
markdown_end: usize,
cx: &mut App,
) {
let Some(source_range) = element.source_range() else {
return;
};
match element {
ParsedHtmlElement::Paragraph(paragraph) => {
self.push_markdown_paragraph(
builder,
&source_range,
markdown_end,
paragraph.text_align,
);
self.render_html_paragraph(
&paragraph.contents,
source_allocator,
builder,
cx,
markdown_end,
);
self.pop_markdown_paragraph(builder);
}
ParsedHtmlElement::Heading(heading) => {
self.push_markdown_heading(
builder,
heading.level,
&heading.source_range,
markdown_end,
heading.text_align,
);
self.render_html_paragraph(
&heading.contents,
source_allocator,
builder,
cx,
markdown_end,
);
self.pop_markdown_heading(builder);
}
ParsedHtmlElement::List(list) => {
self.render_html_list(list, source_allocator, builder, markdown_end, cx);
}
ParsedHtmlElement::BlockQuote(block_quote) => {
self.push_markdown_block_quote(
builder,
None,
&block_quote.source_range,
markdown_end,
);
self.render_html_elements(
&block_quote.children,
source_allocator,
builder,
markdown_end,
cx,
);
self.pop_markdown_block_quote(builder);
}
ParsedHtmlElement::Table(table) => {
self.render_html_table(table, source_allocator, builder, markdown_end, cx);
}
ParsedHtmlElement::Image(image) => {
self.render_html_image(image, builder);
}
}
}
fn render_html_list(
&self,
list: &ParsedHtmlList,
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
markdown_end: usize,
cx: &mut App,
) {
builder.push_div(div().pl_2p5(), &list.source_range, markdown_end);
for list_item in &list.items {
let bullet = match list_item.item_type {
ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix(
order as usize,
list.ordered,
list.depth.saturating_sub(1) as usize,
),
ParsedHtmlListItemType::Unordered => {
html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize)
}
};
self.push_markdown_list_item(
builder,
div().child(bullet).into_any_element(),
&list_item.source_range,
markdown_end,
);
self.render_html_elements(
&list_item.content,
source_allocator,
builder,
markdown_end,
cx,
);
self.pop_markdown_list_item(builder);
}
builder.pop_div();
}
fn render_html_table(
&self,
table: &ParsedHtmlTable,
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
markdown_end: usize,
cx: &mut App,
) {
if let Some(caption) = &table.caption {
builder.push_div(
div().when(!self.style.height_is_multiple_of_line_height, |el| {
el.mb_2().line_height(rems(1.3))
}),
&table.source_range,
markdown_end,
);
self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end);
builder.pop_div();
}
let actual_header_column_count = html_table_columns_count(&table.header);
let actual_body_column_count = html_table_columns_count(&table.body);
let max_column_count = actual_header_column_count.max(actual_body_column_count);
if max_column_count == 0 {
return;
}
let total_rows = table.header.len() + table.body.len();
let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
builder.push_div(
div()
.id(("html-table", table.source_range.start))
.grid()
.grid_cols(max_column_count as u16)
.when(self.style.table_columns_min_size, |this| {
this.grid_cols_min_content(max_column_count as u16)
})
.when(!self.style.table_columns_min_size, |this| {
this.grid_cols(max_column_count as u16)
})
.w_full()
.mb_2()
.border(px(1.5))
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden(),
&table.source_range,
markdown_end,
);
for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() {
let mut column_index = 0;
for cell in &row.columns {
while column_index < max_column_count && grid_occupied[row_index][column_index] {
column_index += 1;
}
if column_index >= max_column_count {
break;
}
let max_span = max_column_count.saturating_sub(column_index);
let text_align = match cell.alignment {
Alignment::Left => TextAlign::Left,
Alignment::Center => TextAlign::Center,
Alignment::Right => TextAlign::Right,
_ => self.style.base_text_style.text_align,
};
let mut cell_div = div()
.col_span(cell.col_span.min(max_span) as u16)
.row_span(cell.row_span.min(total_rows - row_index) as u16)
.flex()
.flex_col()
.when(column_index > 0, |this| this.border_l_1())
.when(row_index > 0, |this| this.border_t_1())
.border_color(cx.theme().colors().border)
.px_2()
.py_1()
.h_full()
.when(cell.is_header, |this| {
this.bg(cx.theme().colors().title_bar_background)
})
.when(!cell.is_header && row_index % 2 == 1, |this| {
this.bg(cx.theme().colors().panel_background)
});
cell_div = match cell.alignment {
Alignment::Center => cell_div.items_center(),
Alignment::Right => cell_div.items_end(),
_ => cell_div,
};
builder.push_text_style(TextStyleRefinement {
text_align: Some(text_align),
..Default::default()
});
builder.push_div(cell_div, &table.source_range, markdown_end);
builder.push_div(
div()
.flex()
.flex_col()
.flex_1()
.w_full()
.justify_center()
.text_align(text_align),
&table.source_range,
markdown_end,
);
self.render_html_paragraph(
&cell.children,
source_allocator,
builder,
cx,
markdown_end,
);
builder.pop_div();
builder.pop_div();
builder.pop_text_style();
for row_offset in 0..cell.row_span {
for column_offset in 0..cell.col_span {
if row_index + row_offset < total_rows
&& column_index + column_offset < max_column_count
{
grid_occupied[row_index + row_offset][column_index + column_offset] =
true;
}
}
}
column_index += cell.col_span;
}
while column_index < max_column_count {
if grid_occupied[row_index][column_index] {
column_index += 1;
continue;
}
builder.push_div(
div()
.when(column_index > 0, |this| this.border_l_1())
.when(row_index > 0, |this| this.border_t_1())
.border_color(cx.theme().colors().border)
.when(row_index % 2 == 1, |this| {
this.bg(cx.theme().colors().panel_background)
}),
&table.source_range,
markdown_end,
);
builder.pop_div();
column_index += 1;
}
}
builder.pop_div();
}
fn render_html_paragraph(
&self,
paragraph: &HtmlParagraph,
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
cx: &mut App,
_markdown_end: usize,
) {
for chunk in paragraph {
match chunk {
HtmlParagraphChunk::Text(text) => {
self.render_html_text(text, source_allocator, builder, cx);
}
HtmlParagraphChunk::Image(image) => {
self.render_html_image(image, builder);
}
}
}
}
fn render_html_text(
&self,
text: &ParsedHtmlText,
source_allocator: &mut HtmlSourceAllocator,
builder: &mut MarkdownElementBuilder,
cx: &mut App,
) {
let text_contents = text.contents.as_ref();
if text_contents.is_empty() {
return;
}
let allocated_range = source_allocator.allocate(text_contents.len());
let allocated_len = allocated_range.end.saturating_sub(allocated_range.start);
let mut boundaries = vec![0, text_contents.len()];
for (range, _) in &text.highlights {
boundaries.push(range.start);
boundaries.push(range.end);
}
for (range, _) in &text.links {
boundaries.push(range.start);
boundaries.push(range.end);
}
boundaries.sort_unstable();
boundaries.dedup();
for segment in boundaries.windows(2) {
let start = segment[0];
let end = segment[1];
if start >= end {
continue;
}
let source_start = allocated_range.start + start.min(allocated_len);
let source_end = allocated_range.start + end.min(allocated_len);
if source_start >= source_end {
continue;
}
let mut refinement = TextStyleRefinement::default();
let mut has_refinement = false;
for (highlight_range, style) in &text.highlights {
if highlight_range.start < end && highlight_range.end > start {
apply_html_highlight_style(&mut refinement, style);
has_refinement = true;
}
}
let link = text.links.iter().find_map(|(link_range, link)| {
if link_range.start < end && link_range.end > start {
Some(link.clone())
} else {
None
}
});
if let Some(link) = link.as_ref() {
builder.push_link(link.clone(), source_start..source_end);
let link_style = self
.style
.link_callback
.as_ref()
.and_then(|callback| callback(link.as_ref(), cx))
.unwrap_or_else(|| self.style.link.clone());
builder.push_text_style(link_style);
}
if has_refinement {
builder.push_text_style(refinement);
}
builder.push_text(&text_contents[start..end], source_start..source_end);
if has_refinement {
builder.pop_text_style();
}
if link.is_some() {
builder.pop_text_style();
}
}
}
fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
let Some(source) = self
.image_resolver
.as_ref()
.and_then(|resolve| resolve(image.dest_url.as_ref()))
else {
return;
};
self.push_markdown_image(
builder,
&image.source_range,
source,
image.dest_url.clone(),
image.alt_text.clone(),
image.width,
image.height,
);
}
}
fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) {
if style.weight != FontWeight::default() {
refinement.font_weight = Some(style.weight);
}
if style.oblique {
refinement.font_style = Some(FontStyle::Oblique);
} else if style.italic {
refinement.font_style = Some(FontStyle::Italic);
}
if style.underline {
refinement.underline = Some(UnderlineStyle {
thickness: px(1.),
color: None,
..Default::default()
});
}
if style.strikethrough {
refinement.strikethrough = Some(StrikethroughStyle {
thickness: px(1.),
color: None,
});
}
}
fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
let index = order.saturating_sub(1);
const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
const BULLETS: [&str; 5] = ["", "", "", "", ""];
if ordered {
match depth {
0 => format!("{}. ", order),
1 => format!(
"{}. ",
NUMBERED_PREFIXES_1
.chars()
.nth(index % NUMBERED_PREFIXES_1.len())
.unwrap()
),
_ => format!(
"{}. ",
NUMBERED_PREFIXES_2
.chars()
.nth(index % NUMBERED_PREFIXES_2.len())
.unwrap()
),
}
} else {
let depth = depth.min(BULLETS.len() - 1);
format!("{} ", BULLETS[depth])
}
}
fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize {
let mut actual_column_count = 0;
for row in rows {
actual_column_count = actual_column_count.max(
row.columns
.iter()
.map(|column| column.col_span)
.sum::<usize>(),
);
}
actual_column_count
}
#[cfg(test)]
mod tests {
use gpui::{TestAppContext, size};
use ui::prelude::*;
use crate::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
MarkdownStyle,
};
fn ensure_theme_initialized(cx: &mut TestAppContext) {
cx.update(|cx| {
if !cx.has_global::<settings::SettingsStore>() {
settings::init(cx);
}
if !cx.has_global::<theme::GlobalTheme>() {
theme_settings::init(theme::LoadThemes::JustBase, cx);
}
});
}
fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
struct TestWindow;
impl Render for TestWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
cx.run_until_parked();
let (rendered, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|_window, _cx| {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
border: false,
},
)
},
);
rendered.text
}
#[gpui::test]
fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
let rendered = render_markdown_text(
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
cx,
);
let rendered_lines = rendered
.lines
.iter()
.map(|line| line.layout.wrapped_text())
.collect::<Vec<_>>();
assert_eq!(
rendered_lines.concat().replace('\n', ""),
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
);
}
#[gpui::test]
fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
struct TestWindow;
impl Render for TestWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| {
Markdown::new_with_options(
"<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
None,
None,
MarkdownOptions {
parse_html: true,
..Default::default()
},
cx,
)
});
cx.run_until_parked();
let (rendered, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|_window, _cx| {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
border: false,
},
)
},
);
let rendered_lines = rendered
.text
.lines
.iter()
.map(|line| line.layout.wrapped_text())
.collect::<Vec<_>>();
assert_eq!(rendered_lines[0], "Hello");
assert_eq!(rendered_lines[1], "world");
assert!(rendered_lines.iter().any(|line| line.contains("item")));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,941 @@
use collections::HashMap;
use gpui::{
Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla,
ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between,
};
use std::collections::BTreeMap;
use std::ops::Range;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use ui::CopyButton;
use ui::prelude::*;
use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag};
use settings::Settings as _;
use theme_settings::ThemeSettings;
use super::{CopyButtonVisibility, Markdown, MarkdownStyle, ParsedMarkdown};
type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, Arc<CachedMermaidDiagram>>;
#[derive(Clone, Debug)]
pub(crate) struct ParsedMarkdownMermaidDiagram {
pub(crate) content_range: Range<usize>,
pub(crate) contents: ParsedMarkdownMermaidDiagramContents,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ParsedMarkdownMermaidDiagramContents {
pub(crate) contents: SharedString,
pub(crate) scale: u32,
}
#[derive(Default, Clone)]
pub(crate) struct MermaidState {
cache: MermaidDiagramCache,
order: Vec<ParsedMarkdownMermaidDiagramContents>,
}
struct CachedMermaidDiagram {
render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
fallback_image: Option<Arc<RenderImage>>,
_task: Task<()>,
}
impl MermaidState {
pub(crate) fn clear(&mut self) {
self.cache.clear();
self.order.clear();
}
fn get_fallback_image(
idx: usize,
old_order: &[ParsedMarkdownMermaidDiagramContents],
new_order_len: usize,
cache: &MermaidDiagramCache,
) -> Option<Arc<RenderImage>> {
if old_order.len() != new_order_len {
return None;
}
old_order.get(idx).and_then(|old_content| {
cache.get(old_content).and_then(|old_cached| {
old_cached
.render_image
.get()
.and_then(|result| result.as_ref().ok().cloned())
.or_else(|| old_cached.fallback_image.clone())
})
})
}
pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context<Markdown>) {
let mut new_order = Vec::new();
for mermaid_diagram in parsed.mermaid_diagrams.values() {
new_order.push(mermaid_diagram.contents.clone());
}
for (idx, new_content) in new_order.iter().enumerate() {
if !self.cache.contains_key(new_content) {
let fallback =
Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
self.cache.insert(
new_content.clone(),
Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)),
);
}
}
let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect();
self.cache
.retain(|content, _| new_order_set.contains(content));
self.order = new_order;
}
}
impl CachedMermaidDiagram {
fn new(
contents: ParsedMarkdownMermaidDiagramContents,
fallback_image: Option<Arc<RenderImage>>,
cx: &mut Context<Markdown>,
) -> Self {
let render_image = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
let render_image_clone = render_image.clone();
let svg_renderer = cx.svg_renderer();
let mermaid_theme = build_mermaid_theme(cx);
let accent_classdefs = build_accent_classdefs(cx);
let task = cx.spawn(async move |this, cx| {
let value = cx
.background_spawn(async move {
let options = mermaid_rs_renderer::RenderOptions {
theme: mermaid_theme,
layout: mermaid_rs_renderer::LayoutConfig::default(),
};
let full_source = format!("{}\n{}", contents.contents, accent_classdefs);
let svg_string =
mermaid_rs_renderer::render_with_options(&full_source, options)?;
let scale = contents.scale as f32 / 100.0;
svg_renderer
.render_single_frame(svg_string.as_bytes(), scale)
.map_err(|error| anyhow::anyhow!("{error}"))
})
.await;
let _ = render_image_clone.set(value);
this.update(cx, |_, cx| {
cx.notify();
})
.ok();
});
Self {
render_image,
fallback_image,
_task: task,
}
}
#[cfg(test)]
fn new_for_test(
render_image: Option<Arc<RenderImage>>,
fallback_image: Option<Arc<RenderImage>>,
) -> Self {
let result = Arc::new(OnceLock::new());
if let Some(render_image) = render_image {
let _ = result.set(Ok(render_image));
}
Self {
render_image: result,
fallback_image,
_task: Task::ready(()),
}
}
}
/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`).
fn hsla_to_hex(color: Hsla) -> String {
let rgba: Rgba = color.to_rgb();
let r = (rgba.r * 255.0).round() as u8;
let g = (rgba.g * 255.0).round() as u8;
let b = (rgba.b * 255.0).round() as u8;
format!("#{r:02x}{g:02x}{b:02x}")
}
fn build_mermaid_theme(cx: &Context<Markdown>) -> mermaid_rs_renderer::Theme {
let colors = cx.theme().colors();
let theme_settings = ThemeSettings::get_global(cx);
let mut theme = mermaid_rs_renderer::Theme::modern();
theme.font_family = theme_settings.ui_font.family.to_string();
theme.background = hsla_to_hex(colors.editor_background);
theme.primary_color = hsla_to_hex(colors.surface_background);
theme.primary_text_color = hsla_to_hex(colors.text);
theme.primary_border_color = hsla_to_hex(colors.border);
theme.line_color = hsla_to_hex(colors.border);
theme.secondary_color = hsla_to_hex(colors.element_background);
theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover);
theme.edge_label_background = hsla_to_hex(colors.editor_background);
theme.cluster_background = hsla_to_hex(colors.panel_background);
theme.cluster_border = hsla_to_hex(colors.border_variant);
theme.text_color = hsla_to_hex(colors.text);
let accents = cx.theme().accents();
let pie_colors: [String; 12] =
std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32)));
theme.pie_colors = pie_colors;
theme.pie_title_text_color = hsla_to_hex(colors.text);
theme.pie_section_text_color = "#fff".to_string();
theme.pie_legend_text_color = hsla_to_hex(colors.text);
theme.pie_stroke_color = hsla_to_hex(colors.border);
theme.pie_outer_stroke_color = hsla_to_hex(colors.border);
theme.sequence_actor_fill = hsla_to_hex(colors.element_background);
theme.sequence_actor_border = hsla_to_hex(colors.border);
theme.sequence_actor_line = hsla_to_hex(colors.border);
theme.sequence_note_fill = hsla_to_hex(colors.surface_background);
theme.sequence_note_border = hsla_to_hex(colors.border_variant);
theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover);
theme.sequence_activation_border = hsla_to_hex(colors.border);
let players = cx.theme().players();
theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor));
theme.git_inv_colors =
std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background));
theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string());
theme.git_commit_label_color = hsla_to_hex(colors.text);
theme.git_commit_label_background = hsla_to_hex(colors.element_background);
theme.git_tag_label_color = hsla_to_hex(colors.text);
theme.git_tag_label_background = hsla_to_hex(colors.element_background);
theme.git_tag_label_border = hsla_to_hex(colors.border);
theme
}
fn build_accent_classdefs(cx: &Context<Markdown>) -> String {
use std::fmt::Write;
let players = &cx.theme().players();
let is_light = cx.theme().appearance.is_light();
let mut defs = String::new();
for (i, player) in players.0.iter().enumerate() {
let (fill, text_color) = accent_fill_and_text(player.background, is_light);
let fill = hsla_to_hex(fill);
let stroke = hsla_to_hex(player.cursor);
let text_color = hsla_to_hex(text_color);
writeln!(
defs,
"classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}"
)
.ok();
}
defs
}
/// Adjusts an accent fill color to ensure readable text contrast.
///
/// On dark themes, darkens the fill and uses white text.
/// On light themes, lightens the fill and uses black text.
/// The fill is adjusted until it meets a minimum WCAG contrast ratio
/// of ~4.5:1 against the chosen text color.
fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) {
let mut fill = color;
if is_light {
// Lighten fill until luminance is high enough for black text.
// Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black.
for _ in 0..50 {
if relative_luminance(fill) >= 0.35 {
break;
}
fill.l = (fill.l + 0.02).min(1.0);
}
(fill, gpui::black())
} else {
// Darken fill until luminance is low enough for white text.
// Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white.
for _ in 0..50 {
if relative_luminance(fill) <= 0.18 {
break;
}
fill.l = (fill.l - 0.02).max(0.0);
}
(fill, gpui::white())
}
}
fn relative_luminance(color: Hsla) -> f32 {
let rgba: Rgba = color.to_rgb();
fn linearize(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b)
}
fn parse_mermaid_info(info: &str) -> Option<u32> {
let mut parts = info.split_whitespace();
if parts.next()? != "mermaid" {
return None;
}
Some(
parts
.next()
.and_then(|scale| scale.parse().ok())
.unwrap_or(100)
.clamp(10, 500),
)
}
pub(crate) fn extract_mermaid_diagrams(
source: &str,
events: &[(Range<usize>, MarkdownEvent)],
) -> BTreeMap<usize, ParsedMarkdownMermaidDiagram> {
let mut mermaid_diagrams = BTreeMap::default();
for (source_range, event) in events {
let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else {
continue;
};
if !metadata.is_fenced_closed {
continue;
}
let scale = match kind {
CodeBlockKind::FencedLang(info) => match parse_mermaid_info(info.as_ref()) {
Some(scale) => scale,
None => continue,
},
CodeBlockKind::FencedSrc(path_range) => {
let path = Path::new(path_range.path.as_ref());
match path.extension().and_then(|ext| ext.to_str()) {
Some("mermaid" | "mmd") => 100,
_ => continue,
}
}
_ => continue,
};
let contents = source[metadata.content_range.clone()]
.strip_suffix('\n')
.unwrap_or(&source[metadata.content_range.clone()])
.to_string();
mermaid_diagrams.insert(
source_range.start,
ParsedMarkdownMermaidDiagram {
content_range: metadata.content_range.clone(),
contents: ParsedMarkdownMermaidDiagramContents {
contents: contents.into(),
scale,
},
},
);
}
mermaid_diagrams
}
pub(crate) fn render_mermaid_diagram(
parsed: &ParsedMarkdownMermaidDiagram,
mermaid_state: &MermaidState,
style: &MarkdownStyle,
markdown: Entity<Markdown>,
source_offset: usize,
showing_code: bool,
copy_button_visibility: CopyButtonVisibility,
) -> AnyElement {
let cached = mermaid_state.cache.get(&parsed.contents);
let render_result = cached.and_then(|cached| cached.render_image.get());
let show_interactive = copy_button_visibility != CopyButtonVisibility::Hidden;
let code = parsed.contents.contents.clone();
let mut container = div().group("code_block").relative().w_full().rounded_lg();
container.style().refine(&style.code_block);
match render_result {
Some(Ok(render_image)) => {
let body = if showing_code {
render_mermaid_code_view(&parsed.contents.contents)
} else {
div()
.w_full()
.child(
img(ImageSource::Render(render_image.clone()))
.max_w_full()
.with_fallback(|| {
div()
.child(Label::new("Failed to load mermaid diagram"))
.into_any_element()
}),
)
.into_any_element()
};
container
.when(show_interactive, |container| {
container.child(render_mermaid_tab_header(
source_offset,
showing_code,
markdown.clone(),
))
})
.child(body)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
Some(Err(_)) => {
// Render failed — show the source code without tabs
container
.child(render_mermaid_code_view(&parsed.contents.contents))
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
None => {
// Still rendering
if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
container
.child(
div()
.w_full()
.child(
img(ImageSource::Render(fallback.clone()))
.max_w_full()
.with_fallback(|| {
div()
.child(Label::new("Failed to load mermaid diagram"))
.into_any_element()
}),
)
.with_animation(
"mermaid-fallback-pulse",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.6, 1.0)),
|element, delta| element.opacity(delta),
),
)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
} else {
// No fallback — show the code so the user has something to look at
container
.child(render_mermaid_code_view(&parsed.contents.contents))
.child(
div().absolute().top_1().right_2().child(
Label::new("Rendering...")
.size(LabelSize::XSmall)
.color(Color::Muted)
.with_animation(
"mermaid-loading-pulse",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
),
),
)
.when(show_interactive, |container| {
container.child(render_mermaid_copy_button(
source_offset,
code.to_string(),
markdown,
))
})
.into_any_element()
}
}
}
}
fn render_mermaid_tab_header(
source_offset: usize,
showing_code: bool,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let preview_markdown = markdown.clone();
let code_markdown = markdown;
h_flex()
.gap_0p5()
.p_0p5()
.mb_1()
.child(render_mermaid_tab_button(
"Preview",
source_offset,
!showing_code,
move |_event, _window, cx| {
preview_markdown.update(cx, |md, cx| {
if md.is_mermaid_showing_code(source_offset) {
md.toggle_mermaid_tab(source_offset);
cx.notify();
}
});
},
))
.child(render_mermaid_tab_button(
"Code",
source_offset,
showing_code,
move |_event, _window, cx| {
code_markdown.update(cx, |md, cx| {
if !md.is_mermaid_showing_code(source_offset) {
md.toggle_mermaid_tab(source_offset);
cx.notify();
}
});
},
))
}
fn render_mermaid_tab_button(
label: &'static str,
source_offset: usize,
is_selected: bool,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> impl IntoElement {
div()
.id(ElementId::named_usize(
SharedString::from(format!("mermaid-tab-{label}")),
source_offset,
))
.cursor_pointer()
.px_1p5()
.py_0p5()
.rounded_md()
.text_size(rems(0.75))
.when(is_selected, |this| this.bg(gpui::hsla(0., 0., 0.5, 0.15)))
.when(!is_selected, |this| {
this.hover(|this| this.bg(gpui::hsla(0., 0., 0.5, 0.08)))
})
.child(label)
.on_click(on_click)
}
fn render_mermaid_copy_button(
source_offset: usize,
code: String,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let id = ElementId::named_usize("copy-mermaid-code", source_offset);
h_flex()
.w_4()
.absolute()
.top_0()
.right_0()
.justify_end()
.visible_on_hover("code_block")
.child(CopyButton::new(id.clone(), code.clone()).custom_on_click({
move |_window, cx| {
let id = id.clone();
markdown.update(cx, |this, cx| {
this.copied_code_blocks.insert(id.clone());
cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.copied_code_blocks.remove(&id);
cx.notify();
})
})
.ok();
})
.detach();
});
}
}))
}
fn render_mermaid_code_view(contents: &SharedString) -> AnyElement {
div()
.w_full()
.child(StyledText::new(contents.clone()))
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::{
CachedMermaidDiagram, MermaidDiagramCache, MermaidState,
ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info,
};
use crate::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
MarkdownStyle,
};
use collections::HashMap;
use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size};
use std::sync::Arc;
use ui::prelude::*;
#[gpui::property_test]
fn accent_fill_and_text_sufficient_contrast(
#[strategy = Hsla::opaque_strategy()] color: Hsla,
light_mode: bool,
) {
let (fill, text) = super::accent_fill_and_text(color, light_mode);
let fill_luminance = super::relative_luminance(fill);
let text_luminance = super::relative_luminance(text);
let lighter = fill_luminance.max(text_luminance);
let darker = fill_luminance.min(text_luminance);
let contrast_ratio = (lighter + 0.05) / (darker + 0.05);
assert!(contrast_ratio >= 4.5,);
}
fn ensure_theme_initialized(cx: &mut TestAppContext) {
cx.update(|cx| {
if !cx.has_global::<settings::SettingsStore>() {
settings::init(cx);
}
if !cx.has_global::<theme::GlobalTheme>() {
theme_settings::init(theme::LoadThemes::JustBase, cx);
}
});
}
fn render_markdown_with_options(
markdown: &str,
options: MarkdownOptions,
cx: &mut TestAppContext,
) -> crate::RenderedText {
struct TestWindow;
impl Render for TestWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| {
Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx)
});
cx.run_until_parked();
let (rendered, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|_window, _cx| {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
border: false,
},
)
},
);
rendered.text
}
fn mock_render_image(cx: &mut TestAppContext) -> Arc<RenderImage> {
cx.update(|cx| {
cx.svg_renderer()
.render_single_frame(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
1.0,
)
.unwrap()
})
}
fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents {
ParsedMarkdownMermaidDiagramContents {
contents: contents.to_string().into(),
scale: 100,
}
}
fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
diagrams
.iter()
.map(|diagram| mermaid_contents(diagram))
.collect()
}
fn mermaid_fallback(
new_diagram: &str,
new_full_order: &[ParsedMarkdownMermaidDiagramContents],
old_full_order: &[ParsedMarkdownMermaidDiagramContents],
cache: &MermaidDiagramCache,
) -> Option<Arc<RenderImage>> {
let new_content = mermaid_contents(new_diagram);
let idx = new_full_order
.iter()
.position(|diagram| diagram == &new_content)?;
MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
}
#[test]
fn test_parse_mermaid_info() {
assert_eq!(parse_mermaid_info("mermaid"), Some(100));
assert_eq!(parse_mermaid_info("mermaid 150"), Some(150));
assert_eq!(parse_mermaid_info("mermaid 5"), Some(10));
assert_eq!(parse_mermaid_info("mermaid 999"), Some(500));
assert_eq!(parse_mermaid_info("rust"), None);
}
#[test]
fn test_extract_mermaid_diagrams_parses_scale() {
let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events;
let diagrams = extract_mermaid_diagrams(markdown, &events);
assert_eq!(diagrams.len(), 1);
let diagram = diagrams.values().next().unwrap();
assert_eq!(diagram.contents.contents, "graph TD;");
assert_eq!(diagram.contents.scale, 150);
}
#[gpui::test]
fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) {
let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
let svg_b = mock_render_image(cx);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
cache.insert(
mermaid_contents("graph B"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(svg_b.clone()),
None,
)),
);
cache.insert(
mermaid_contents("graph C"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
let fallback =
mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id));
}
#[gpui::test]
fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) {
let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
cache.insert(
mermaid_contents("graph C"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
assert!(fallback.is_none());
}
#[gpui::test]
fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) {
let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
let original_svg = mock_render_image(cx);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
cache.insert(
mermaid_contents("graph B modified"),
Arc::new(CachedMermaidDiagram::new_for_test(
None,
Some(original_svg.clone()),
)),
);
cache.insert(
mermaid_contents("graph C"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
let fallback = mermaid_fallback(
"graph B modified again",
&new_full_order,
&old_full_order,
&cache,
);
assert_eq!(
fallback.as_ref().map(|image| image.id),
Some(original_svg.id)
);
}
#[gpui::test]
fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) {
let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
let svg_a = mock_render_image(cx);
let mut cache: MermaidDiagramCache = HashMap::default();
cache.insert(
mermaid_contents("graph A"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(svg_a.clone()),
None,
)),
);
cache.insert(
mermaid_contents("graph B"),
Arc::new(CachedMermaidDiagram::new_for_test(
Some(mock_render_image(cx)),
None,
)),
);
let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id));
}
#[gpui::test]
fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) {
let rendered = render_markdown_with_options(
"```mermaid\ngraph TD;\n```",
MarkdownOptions {
render_mermaid_diagrams: true,
..Default::default()
},
cx,
);
let text = rendered
.lines
.iter()
.map(|line| line.layout.wrapped_text())
.collect::<Vec<_>>()
.join("\n");
assert!(!text.contains("graph TD;"));
}
#[gpui::test]
fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) {
struct TestWindow;
impl Render for TestWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| {
Markdown::new_with_options(
"```mermaid\ngraph TD;\n```".into(),
None,
None,
MarkdownOptions {
render_mermaid_diagrams: true,
..Default::default()
},
cx,
)
});
cx.run_until_parked();
let render_image = mock_render_image(cx);
markdown.update(cx, |markdown, _| {
let contents = markdown
.parsed_markdown
.mermaid_diagrams
.values()
.next()
.unwrap()
.contents
.clone();
markdown.mermaid_state.cache.insert(
contents.clone(),
Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)),
);
markdown.mermaid_state.order = vec![contents];
});
let (rendered, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|_window, _cx| {
MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
border: false,
})
},
);
let mermaid_diagram = markdown.update(cx, |markdown, _| {
markdown
.parsed_markdown
.mermaid_diagrams
.values()
.next()
.unwrap()
.clone()
});
assert!(
rendered
.text
.position_for_source_index(mermaid_diagram.content_range.start)
.is_some()
);
assert!(
rendered
.text
.position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1))
.is_some()
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
use std::{ops::Range, sync::Arc};
#[derive(Debug, Clone, PartialEq)]
pub struct PathWithRange {
pub path: Arc<str>,
pub range: Option<Range<LineCol>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct LineCol {
pub line: u32,
pub col: Option<u32>,
}
impl std::fmt::Debug for LineCol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.col {
Some(col) => write!(f, "L{}:{}", self.line, col),
None => write!(f, "L{}", self.line),
}
}
}
impl LineCol {
pub fn new(str: impl AsRef<str>) -> Option<Self> {
let str = str.as_ref();
match str.split_once(':') {
Some((line, col)) => match (line.parse::<u32>(), col.parse::<u32>()) {
(Ok(line), Ok(col)) => Some(Self {
line,
col: Some(col),
}),
_ => None,
},
None => match str.parse::<u32>() {
Ok(line) => Some(Self { line, col: None }),
Err(_) => None,
},
}
}
}
impl PathWithRange {
// Note: We could try out this as an alternative, and see how it does on evals.
//
// The closest to a standard way of including a filename is this:
// ```rust filename="path/to/file.rs#42:43"
// ```
//
// or, alternatively,
// ```rust filename="path/to/file.rs" lines="42:43"
// ```
//
// Examples where it's used this way:
// - https://mdxjs.com/guides/syntax-highlighting/#syntax-highlighting-with-the-meta-field
// - https://docusaurus.io/docs/markdown-features/code-blocks
// - https://spec.commonmark.org/0.31.2/#example-143
pub fn new(str: impl AsRef<str>) -> Self {
let str = str.as_ref();
// Sometimes the model will include a language at the start,
// e.g. "```rust zed/crates/markdown/src/markdown.rs#L1"
// We just discard that.
let str = match str.trim_end().rfind(' ') {
Some(space) => &str[space + 1..],
None => str.trim_start(),
};
match str.rsplit_once('#') {
Some((path, after_hash)) => {
// Be tolerant to the model omitting the "L" prefix, lowercasing it,
// or including it more than once.
let after_hash = after_hash.replace(['L', 'l'], "");
let range = {
let mut iter = after_hash.split('-').flat_map(LineCol::new);
iter.next()
.map(|start| iter.next().map(|end| start..end).unwrap_or(start..start))
};
Self {
path: path.into(),
range,
}
}
None => Self {
path: str.into(),
range: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linecol_parsing() {
let line_col = LineCol::new("10:5");
assert_eq!(
line_col,
Some(LineCol {
line: 10,
col: Some(5)
})
);
let line_only = LineCol::new("42");
assert_eq!(
line_only,
Some(LineCol {
line: 42,
col: None
})
);
assert_eq!(LineCol::new(""), None);
assert_eq!(LineCol::new("not a number"), None);
assert_eq!(LineCol::new("10:not a number"), None);
assert_eq!(LineCol::new("not:5"), None);
}
#[test]
fn test_pathrange_parsing() {
let path_range = PathWithRange::new("file.rs#L10-L20");
assert_eq!(path_range.path.as_ref(), "file.rs");
assert!(path_range.range.is_some());
if let Some(range) = path_range.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, None);
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, None);
}
let single_line = PathWithRange::new("file.rs#L15");
assert_eq!(single_line.path.as_ref(), "file.rs");
assert!(single_line.range.is_some());
if let Some(range) = single_line.range {
assert_eq!(range.start.line, 15);
assert_eq!(range.end.line, 15);
}
let no_range = PathWithRange::new("file.rs");
assert_eq!(no_range.path.as_ref(), "file.rs");
assert!(no_range.range.is_none());
let lowercase = PathWithRange::new("file.rs#l5-l10");
assert_eq!(lowercase.path.as_ref(), "file.rs");
assert!(lowercase.range.is_some());
if let Some(range) = lowercase.range {
assert_eq!(range.start.line, 5);
assert_eq!(range.end.line, 10);
}
let complex = PathWithRange::new("src/path/to/file.rs#L100");
assert_eq!(complex.path.as_ref(), "src/path/to/file.rs");
assert!(complex.range.is_some());
}
#[test]
fn test_pathrange_from_str() {
let with_range = PathWithRange::new("file.rs#L10-L20");
assert!(with_range.range.is_some());
assert_eq!(with_range.path.as_ref(), "file.rs");
let without_range = PathWithRange::new("file.rs");
assert!(without_range.range.is_none());
let single_line = PathWithRange::new("file.rs#L15");
assert!(single_line.range.is_some());
}
#[test]
fn test_pathrange_leading_text_trimming() {
let with_language = PathWithRange::new("```rust file.rs#L10");
assert_eq!(with_language.path.as_ref(), "file.rs");
assert!(with_language.range.is_some());
if let Some(range) = with_language.range {
assert_eq!(range.start.line, 10);
}
let with_spaces = PathWithRange::new("``` file.rs#L10-L20");
assert_eq!(with_spaces.path.as_ref(), "file.rs");
assert!(with_spaces.range.is_some());
let with_words = PathWithRange::new("```rust code example file.rs#L15:10");
assert_eq!(with_words.path.as_ref(), "file.rs");
assert!(with_words.range.is_some());
if let Some(range) = with_words.range {
assert_eq!(range.start.line, 15);
assert_eq!(range.start.col, Some(10));
}
let with_whitespace = PathWithRange::new(" file.rs#L5");
assert_eq!(with_whitespace.path.as_ref(), "file.rs");
assert!(with_whitespace.range.is_some());
let no_leading = PathWithRange::new("file.rs#L10");
assert_eq!(no_leading.path.as_ref(), "file.rs");
assert!(no_leading.range.is_some());
}
#[test]
fn test_pathrange_with_line_and_column() {
let line_and_col = PathWithRange::new("file.rs#L10:5");
assert_eq!(line_and_col.path.as_ref(), "file.rs");
assert!(line_and_col.range.is_some());
if let Some(range) = line_and_col.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 10);
assert_eq!(range.end.col, Some(5));
}
let full_range = PathWithRange::new("file.rs#L10:5-L20:15");
assert_eq!(full_range.path.as_ref(), "file.rs");
assert!(full_range.range.is_some());
if let Some(range) = full_range.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, Some(15));
}
let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20");
assert_eq!(mixed_range1.path.as_ref(), "file.rs");
assert!(mixed_range1.range.is_some());
if let Some(range) = mixed_range1.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, Some(5));
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, None);
}
let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15");
assert_eq!(mixed_range2.path.as_ref(), "file.rs");
assert!(mixed_range2.range.is_some());
if let Some(range) = mixed_range2.range {
assert_eq!(range.start.line, 10);
assert_eq!(range.start.col, None);
assert_eq!(range.end.line, 20);
assert_eq!(range.end.col, Some(15));
}
}
}