perf: speed up large markdown rendering

This commit is contained in:
2026-11-06 17:18:11 +07:00
parent 310cad32be
commit e524c1a305
3 changed files with 124 additions and 39 deletions
+47 -16
View File
@@ -3,9 +3,24 @@
//! code without re-parsing it. //! code without re-parsing it.
use gpui::SharedString; use gpui::SharedString;
use std::cell::RefCell;
use std::collections::HashMap;
use tree_sitter::StreamingIterator as _;
thread_local! {
static HIGHLIGHTERS: RefCell<HashMap<Language, Option<Highlighter>>> =
RefCell::new(HashMap::new());
}
/// A grammar's parser and compiled highlight query.
struct Highlighter {
parser: tree_sitter::Parser,
query: tree_sitter::Query,
roles: Vec<Option<Role>>,
}
/// A language source can highlight as. /// A language source can highlight as.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Language { pub enum Language {
Rust, Rust,
JavaScript, JavaScript,
@@ -50,8 +65,6 @@ pub struct HighlightedCode {
pub lines: Vec<Line>, pub lines: Vec<Line>,
} }
use tree_sitter::StreamingIterator as _;
/// Highlights `source` with `language`'s tree-sitter grammar. /// Highlights `source` with `language`'s tree-sitter grammar.
pub fn highlight(source: &str, language: Language) -> HighlightedCode { pub fn highlight(source: &str, language: Language) -> HighlightedCode {
highlight_with_tree(source, language, None).0 highlight_with_tree(source, language, None).0
@@ -67,33 +80,50 @@ pub fn highlight_with_tree(
return (plain(source), None); return (plain(source), None);
}; };
HIGHLIGHTERS.with(|cache| {
let mut cache = cache.borrow_mut();
let highlighter = cache
.entry(language)
.or_insert_with(|| {
let mut parser = tree_sitter::Parser::new(); let mut parser = tree_sitter::Parser::new();
if parser.set_language(&grammar).is_err() { if parser.set_language(&grammar).is_err() {
return (plain(source), None); return None;
} }
let query = tree_sitter::Query::new(&grammar, query_source).ok()?;
let Some(tree) = parser.parse(source, old_tree) else { let roles = query
return (plain(source), None);
};
let Ok(query) = tree_sitter::Query::new(&grammar, query_source) else {
return (plain(source), None);
};
let roles: Vec<Option<Role>> = query
.capture_names() .capture_names()
.iter() .iter()
.map(|name| role_for_capture(name)) .map(|name| role_for_capture(name))
.collect(); .collect();
Some(Highlighter {
parser,
query,
roles,
})
})
.as_mut();
let Some(highlighter) = highlighter else {
return (plain(source), None);
};
let Some(tree) = highlighter.parser.parse(source, old_tree) else {
return (plain(source), None);
};
// One role per byte; matches arrive in query order // One role per byte; matches arrive in query order
let mut role_at = vec![None; source.len()]; let mut role_at = vec![None; source.len()];
let mut cursor = tree_sitter::QueryCursor::new(); let mut cursor = tree_sitter::QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); let mut matches = cursor.matches(&highlighter.query, tree.root_node(), source.as_bytes());
while let Some(matched) = matches.next() { while let Some(matched) = matches.next() {
for capture in matched.captures { for capture in matched.captures {
let Some(role) = roles.get(capture.index as usize).copied().flatten() else { let Some(role) = highlighter
.roles
.get(capture.index as usize)
.copied()
.flatten()
else {
continue; continue;
}; };
for byte in capture.node.byte_range() { for byte in capture.node.byte_range() {
@@ -105,6 +135,7 @@ pub fn highlight_with_tree(
} }
(build_lines(source, &role_at), Some(tree)) (build_lines(source, &role_at), Some(tree))
})
} }
/// Source rendered without any grammar: plain text roles. /// Source rendered without any grammar: plain text roles.
+13 -6
View File
@@ -7,7 +7,7 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, ElementId, FontStyle, FontWeight, HighlightStyle, AnyElement, App, Context, Div, ElementId, FontStyle, FontWeight, HighlightStyle,
InteractiveElement as _, InteractiveText, IntoElement, ListAlignment, ListState, InteractiveElement as _, InteractiveText, IntoElement, ListAlignment, ListOffset, ListState,
ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _, ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _,
StrikethroughStyle, Styled as _, StyledText, UnderlineStyle, Window, div, list, px, relative, StrikethroughStyle, Styled as _, StyledText, UnderlineStyle, Window, div, list, px, relative,
}; };
@@ -190,7 +190,7 @@ impl MarkdownViewState {
source, source,
blocks, blocks,
highlight_code: true, highlight_code: true,
handle: ListState::new(count, ListAlignment::Top, px(400.)).measure_all(), handle: ListState::new(count, ListAlignment::Top, px(400.)),
a11y_label: None, a11y_label: None,
} }
} }
@@ -207,7 +207,11 @@ impl MarkdownViewState {
where where
S: Into<SharedString>, S: Into<SharedString>,
{ {
self.source = source.into(); let source = source.into();
if self.source == source {
return;
}
self.source = source;
self.reparse(); self.reparse();
cx.notify(); cx.notify();
} }
@@ -233,10 +237,13 @@ impl MarkdownViewState {
self.highlight_code self.highlight_code
} }
/// Scrolls `block` into view. /// Scrolls `block` to the top of the view.
pub fn scroll_to_block(&self, block: usize) { pub fn scroll_to_block(&self, block: usize) {
self.handle let item_ix = block.min(self.blocks.len().saturating_sub(1));
.scroll_to_reveal_item(block.min(self.blocks.len().saturating_sub(1))); self.handle.scroll_to(ListOffset {
item_ix,
offset_in_item: px(0.),
});
} }
/// Overrides the accessibility label of the view. /// Overrides the accessibility label of the view.
+47
View File
@@ -447,3 +447,50 @@ fn parse_body(source: &str) -> Vec<MarkdownBlock> {
let state = MarkdownViewState::new("parse-md", source); let state = MarkdownViewState::new("parse-md", source);
state.blocks().to_vec() state.blocks().to_vec()
} }
/// Mirrors the gallery's "Large file" sample: five blocks per section.
fn large_markdown(sections: usize) -> String {
let mut source = String::new();
for section in 0..sections {
source.push_str(&format!("## Section {section}\n\n"));
source.push_str(&format!(
"Handler {section} retries stale sockets and logs every outcome.\n\n"
));
source.push_str(&format!(
"- [ ] Audit handler {section}\n- See [issue {section}](https://example.com/issues/{section})\n\n"
));
source.push_str("```rust\nfn handle() -> bool { true }\n```\n\n");
}
source
}
#[test]
fn a_large_document_swaps_and_renders_without_freezing() {
let source = large_markdown(1000);
let started = std::time::Instant::now();
let (_cx, mut visual, state) = view_window("# small\n");
state.update_in(&mut visual, |state, _window, cx| {
state.set_source(source, cx)
});
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(15),
"swapping to a large document took {elapsed:?}; the frame must not
measure every block"
);
let count = _cx.update(|cx| state.read(cx).blocks().len());
assert_eq!(count, 5000, "five blocks per section");
assert!(
visual.debug_bounds("test-md-block-0").is_some(),
"the first block renders after the swap"
);
assert!(
visual.debug_bounds("test-md-block-4999").is_none(),
"blocks past the viewport never render"
);
}