From 6849c2654424325815c300a98d8d5cf9cf3e4fcd Mon Sep 17 00:00:00 2001 From: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:15:18 +0800 Subject: [PATCH 1/2] text: parse prefix extensions incrementally in TextViewState::set_text (#3291) --- crates/base/src/text/state.rs | 93 ++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 85744b432d..53521917cf 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -340,19 +340,22 @@ impl TextViewState { /// Set the text content. /// - /// With a streamed fade-in enabled, text that extends the current text - /// fades in like [`Self::push_str`] would; any other replacement shows at - /// once. + /// Text that extends the current non-empty text is parsed incrementally + /// like [`Self::push_str`]. With a streamed fade-in enabled, extended + /// text fades in; any other replacement shows at once. pub fn set_text(&mut self, text: &str, cx: &mut Context) { if self.text.as_str() == text { return; } + if !self.text.is_empty() && text.starts_with(self.text.as_str()) { + let delta = &text[self.text.len()..]; + self.stream_fade.note_extend(self.text.len()); + self.text.push_str(delta); + self.increment_update(delta, true, cx); + return; + } if self.stream_fade.is_enabled() { - if text.starts_with(self.text.as_str()) { - self.stream_fade.note_extend(self.text.len()); - } else { - self.stream_fade.note_replace(); - } + self.stream_fade.note_replace(); } self.text.clear(); @@ -1741,6 +1744,80 @@ mod tests { }); } + #[gpui::test] + fn set_text_prefix_extension_updates_incrementally_like_push_str(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx))); + cx.run_until_parked(); + + let initial_selection_revision = state.read_with(cx, |state, _| state.selection_revision); + let initial_full_update_rev = state.read_with(cx, |state, _| state.full_update_revision); + + // Extending with set_text parses incrementally like push_str + state.update(cx, |state, cx| { + state.set_text("hello world", cx); + }); + cx.run_until_parked(); + + state.read_with(cx, |state, _| { + assert_eq!(state.text.as_str(), "hello world"); + assert_eq!(state.source().as_str(), "hello world"); + assert_eq!(state.selection_revision, initial_selection_revision); + assert_eq!(state.full_update_revision, initial_full_update_rev); + }); + + // Replacing with non-extending text performs full replacement and bumps revision + state.update(cx, |state, cx| { + state.set_text("completely different", cx); + }); + cx.run_until_parked(); + + state.read_with(cx, |state, _| { + assert_eq!(state.text.as_str(), "completely different"); + assert_eq!(state.source().as_str(), "completely different"); + assert_ne!(state.selection_revision, initial_selection_revision); + assert_ne!(state.full_update_revision, initial_full_update_rev); + }); + } + + #[gpui::test] + fn set_text_streaming_matches_push_str_parsed_content(cx: &mut TestAppContext) { + cx.update(crate::init); + let state_stream = cx.update(|cx| cx.new(|cx| TextViewState::markdown("initial", cx))); + let state_push = cx.update(|cx| cx.new(|cx| TextViewState::markdown("initial", cx))); + cx.run_until_parked(); + + let chunks = [ + " paragraph\n\n", + "second paragraph with ", + "`code`", + "\n\n- list item 1\n- list item 2", + ]; + let mut accumulated = "initial".to_string(); + + for chunk in chunks { + accumulated.push_str(chunk); + state_stream.update(cx, |state, cx| { + state.set_text(&accumulated, cx); + }); + state_push.update(cx, |state, cx| { + state.push_str(chunk, cx); + }); + cx.run_until_parked(); + } + + let doc_stream = + state_stream.read_with(cx, |state, _| state.parsed_content.document.clone()); + let doc_push = state_push.read_with(cx, |state, _| state.parsed_content.document.clone()); + + assert_eq!(doc_stream.source, doc_push.source); + assert_eq!(doc_stream.blocks.len(), doc_push.blocks.len()); + for (b1, b2) in doc_stream.blocks.iter().zip(doc_push.blocks.iter()) { + assert_eq!(b1.text(), b2.text()); + assert_eq!(b1.span(), b2.span()); + } + } + #[test] fn update_options_merge_keeps_latest_full_text() { let mut options = UpdateOptions { From 58e8eeafd50da663179b8e5e9b8a8e5d8e2d8623 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 28 Sep 2026 22:03:35 +0800 Subject: [PATCH 2/2] text: Keep HTML and failed parses on the full `set_text` path Co-Authored-By: Claude Opus 5.5 --- crates/base/src/text/state.rs | 171 ++++++++++++++++++++++------------ 1 file changed, 112 insertions(+), 59 deletions(-) diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 53521917cf..66c5dd3246 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -340,23 +340,26 @@ impl TextViewState { /// Set the text content. /// - /// Text that extends the current non-empty text is parsed incrementally - /// like [`Self::push_str`]. With a streamed fade-in enabled, extended - /// text fades in; any other replacement shows at once. + /// Markdown that extends the current non-empty text is appended like + /// [`Self::push_str`], keeping the selection; any other text replaces it. + /// With a streamed fade-in enabled, extended text fades in; a replacement + /// shows at once. pub fn set_text(&mut self, text: &str, cx: &mut Context) { if self.text.as_str() == text { return; } - if !self.text.is_empty() && text.starts_with(self.text.as_str()) { - let delta = &text[self.text.len()..]; - self.stream_fade.note_extend(self.text.len()); - self.text.push_str(delta); - self.increment_update(delta, true, cx); + // HTML blocks carry no spans, so an append would parse the delta as a + // standalone document. After a failed parse the background parser's + // document lacks that text, so only a full parse resynchronizes it. + if self.format == TextViewFormat::Markdown + && self.parsed_error.is_none() + && !self.text.is_empty() + && let Some(delta) = text.strip_prefix(self.text.as_str()) + { + self.push_str(delta, cx); return; } - if self.stream_fade.is_enabled() { - self.stream_fade.note_replace(); - } + self.stream_fade.note_replace(); self.text.clear(); self.text.push_str(text); @@ -1280,8 +1283,11 @@ fn parse_content( #[cfg(test)] mod tests { use super::*; - use crate::text::{MarkdownNode, node::BlockNode}; - use gpui::TestAppContext; + use crate::text::{ + MarkdownNode, + node::{BlockNode, Span}, + }; + use gpui::{Entity, TestAppContext}; mod stream_fade { use std::{cell::Cell, ops::Range, rc::Rc, time::Duration}; @@ -1744,78 +1750,125 @@ mod tests { }); } + fn parsed_blocks( + state: &Entity, + cx: &mut TestAppContext, + ) -> Vec<(String, Option)> { + state.read_with(cx, |state, _| { + state + .parsed_content + .document + .blocks + .iter() + .map(|block| (block.text(), block.span())) + .collect() + }) + } + #[gpui::test] - fn set_text_prefix_extension_updates_incrementally_like_push_str(cx: &mut TestAppContext) { + fn set_text_extending_markdown_appends_and_keeps_selection(cx: &mut TestAppContext) { cx.update(crate::init); let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx))); cx.run_until_parked(); + let revisions = + |state: &TextViewState| (state.selection_revision, state.full_update_revision); + let initial = state.read_with(cx, |state, _| revisions(state)); - let initial_selection_revision = state.read_with(cx, |state, _| state.selection_revision); - let initial_full_update_rev = state.read_with(cx, |state, _| state.full_update_revision); - - // Extending with set_text parses incrementally like push_str - state.update(cx, |state, cx| { - state.set_text("hello world", cx); - }); + state.update(cx, |state, cx| state.set_text("hello world", cx)); cx.run_until_parked(); - state.read_with(cx, |state, _| { - assert_eq!(state.text.as_str(), "hello world"); assert_eq!(state.source().as_str(), "hello world"); - assert_eq!(state.selection_revision, initial_selection_revision); - assert_eq!(state.full_update_revision, initial_full_update_rev); + assert_eq!(revisions(state), initial); }); - // Replacing with non-extending text performs full replacement and bumps revision - state.update(cx, |state, cx| { - state.set_text("completely different", cx); - }); + state.update(cx, |state, cx| state.set_text("hello", cx)); cx.run_until_parked(); - state.read_with(cx, |state, _| { - assert_eq!(state.text.as_str(), "completely different"); - assert_eq!(state.source().as_str(), "completely different"); - assert_ne!(state.selection_revision, initial_selection_revision); - assert_ne!(state.full_update_revision, initial_full_update_rev); + assert_eq!(state.source().as_str(), "hello"); + assert_ne!(state.selection_revision, initial.0); + assert_ne!(state.full_update_revision, initial.1); }); } #[gpui::test] - fn set_text_streaming_matches_push_str_parsed_content(cx: &mut TestAppContext) { + fn set_text_streaming_markdown_matches_a_full_parse(cx: &mut TestAppContext) { cx.update(crate::init); - let state_stream = cx.update(|cx| cx.new(|cx| TextViewState::markdown("initial", cx))); - let state_push = cx.update(|cx| cx.new(|cx| TextViewState::markdown("initial", cx))); + let streamed = cx.update(|cx| cx.new(|cx| TextViewState::markdown("# Title", cx))); cx.run_until_parked(); + // Chunks continue a heading, a paragraph, an inline code span, a list + // and a fenced code block, and the text grows past the synchronous + // parse limit. + let filler = "word ".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1); let chunks = [ - " paragraph\n\n", - "second paragraph with ", - "`code`", - "\n\n- list item 1\n- list item 2", + "\n\nfirst para", + "graph with `co", + "de`\n\n- one\n", + "- two\n\n```rust\nfn main() {", + "}\n```\n\n", + filler.as_str(), + "\n\nlast", ]; - let mut accumulated = "initial".to_string(); - + let full_update_revision = streamed.read_with(cx, |state, _| state.full_update_revision); + let mut text = "# Title".to_string(); for chunk in chunks { - accumulated.push_str(chunk); - state_stream.update(cx, |state, cx| { - state.set_text(&accumulated, cx); - }); - state_push.update(cx, |state, cx| { - state.push_str(chunk, cx); - }); + text.push_str(chunk); + streamed.update(cx, |state, cx| state.set_text(&text, cx)); cx.run_until_parked(); } + assert_eq!( + streamed.read_with(cx, |state, _| state.full_update_revision), + full_update_revision + ); - let doc_stream = - state_stream.read_with(cx, |state, _| state.parsed_content.document.clone()); - let doc_push = state_push.read_with(cx, |state, _| state.parsed_content.document.clone()); + let parsed = cx.update(|cx| cx.new(|cx| TextViewState::markdown(&text, cx))); + cx.run_until_parked(); - assert_eq!(doc_stream.source, doc_push.source); - assert_eq!(doc_stream.blocks.len(), doc_push.blocks.len()); - for (b1, b2) in doc_stream.blocks.iter().zip(doc_push.blocks.iter()) { - assert_eq!(b1.text(), b2.text()); - assert_eq!(b1.span(), b2.span()); - } + assert_eq!( + streamed.read_with(cx, |state, _| state.source()), + parsed.read_with(cx, |state, _| state.source()) + ); + assert_eq!(parsed_blocks(&streamed, cx), parsed_blocks(&parsed, cx)); + } + + #[gpui::test] + fn set_text_extending_html_parses_it_again(cx: &mut TestAppContext) { + cx.update(crate::init); + let streamed = cx.update(|cx| cx.new(|cx| TextViewState::html("
  • a
  • ", cx))); + cx.run_until_parked(); + + let text = "
    • a
    • b
    "; + streamed.update(cx, |state, cx| state.set_text(text, cx)); + cx.run_until_parked(); + assert_ne!( + streamed.read_with(cx, |state, _| state.full_update_revision), + 0 + ); + + let parsed = cx.update(|cx| cx.new(|cx| TextViewState::html(text, cx))); + cx.run_until_parked(); + assert_eq!(parsed_blocks(&streamed, cx), parsed_blocks(&parsed, cx)); + } + + #[gpui::test] + fn set_text_extending_after_a_parse_error_parses_it_again(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx))); + cx.run_until_parked(); + + let full_update_revision = state.read_with(cx, |state, _| state.full_update_revision); + + state.update(cx, |state, cx| { + state.parsed_error = Some("failed".into()); + state.set_text("hello world", cx); + }); + cx.run_until_parked(); + + state.read_with(cx, |state, _| { + assert!(state.parsed_error.is_none()); + assert_ne!(state.full_update_revision, full_update_revision); + assert_eq!(state.source().as_str(), "hello world"); + }); } #[test]