1use futures::Stream as _;
2#[cfg(not(target_family = "wasm"))]
3use std::time::Instant;
4use std::{
5 ops::RangeInclusive,
6 pin::Pin,
7 sync::{Arc, Mutex},
8 task::Poll,
9};
10#[cfg(target_family = "wasm")]
11use web_time::Instant;
12
13use gpui::{
14 App, AppContext as _, Bounds, Context, FocusHandle, IntoElement, KeyBinding, ListState,
15 ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
16 prelude::FluentBuilder as _, px,
17};
18
19use crate::{
20 AutoScroll, ElementExt, TextSelection,
21 async_util::{Receiver, Sender, unbounded},
22 input::{self, SelectAll},
23 text::{
24 CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions,
25 TableActionsFn, TextViewStyle,
26 document::ParsedDocument,
27 format,
28 node::{self, NodeContext},
29 selection_adapter::TextViewSelectionAdapter,
30 stream_fade::{StreamFadeTracker, TextViewMotion},
31 },
32 v_flex,
33};
34
35const CONTEXT: &'static str = "TextView";
36const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
38const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
41
42pub(crate) fn init(cx: &mut App) {
43 cx.bind_keys(vec![
44 #[cfg(target_os = "macos")]
45 KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
46 #[cfg(not(target_os = "macos"))]
47 KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
48 #[cfg(target_os = "macos")]
49 KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
50 #[cfg(not(target_os = "macos"))]
51 KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
52 ]);
53}
54
55#[derive(Clone, Copy, PartialEq, Eq)]
57pub(super) enum TextViewFormat {
58 Markdown,
60 Html,
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub enum SelectionFormat {
69 #[default]
71 Plain,
72 Source,
78}
79
80#[derive(Clone, Copy)]
84pub(super) struct LineSpan {
85 pub(super) top: Pixels,
86 pub(super) bottom: Pixels,
87 pub(super) line_height: Pixels,
88}
89
90pub struct TextViewState {
92 pub(super) focus_handle: FocusHandle,
93 pub(super) list_state: ListState,
94
95 bounds: Bounds<Pixels>,
97
98 pub(super) selectable: bool,
99 pub(super) selection_format: SelectionFormat,
100 pub(super) scrollable: bool,
101 pub(super) max_lines: Option<usize>,
102 pub(super) line_spans: Arc<Mutex<Vec<LineSpan>>>,
105 pub(super) clamped: bool,
107 pub(super) text_view_style: Arc<TextViewStyle>,
108 pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
109 pub(super) code_block_highlighter: Option<std::sync::Arc<CodeBlockHighlighterFn>>,
110 pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
111 pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
112 pub(super) markdown_extensions: Arc<MarkdownExtensions>,
113
114 pub(super) is_selecting: bool,
115 pub(super) preserve_inline_selection: bool,
117 multi_click_selection: Option<TextViewMultiClickSelection>,
118 selected_text_override: Option<String>,
119 select_all: bool,
120 pub(super) auto_scroll: AutoScroll,
121 pub(super) selection_adapter: TextViewSelectionAdapter,
122
123 pub(super) parsed_content: ParsedContent,
124 pub(super) stream_fade: StreamFadeTracker,
125 format: TextViewFormat,
128 text: String,
129 element_text: Option<SharedString>,
132 revision: usize,
133 pub(super) selection_revision: usize,
134 compatible_layout_update: bool,
135 layout_text_style: Option<(gpui::TextStyle, Pixels)>,
136 parsed_error: Option<SharedString>,
137 tx: Sender<UpdateOptions>,
138 _parse_task: Task<()>,
139 _receive_task: Task<()>,
140}
141
142impl TextViewState {
143 pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
145 Self::new(TextViewFormat::Markdown, text, cx)
146 }
147
148 pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
150 Self::new(TextViewFormat::Html, text, cx)
151 }
152
153 fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
155 let focus_handle = cx.focus_handle();
156 let selection_adapter = TextViewSelectionAdapter::new(cx.entity().downgrade(), cx);
157
158 let (tx, rx) = unbounded::<UpdateOptions>();
159 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
160 let _receive_task = cx.spawn({
161 async move |weak_self, cx| {
162 while let Ok(parsed_update) = rx_result.recv().await {
163 _ = weak_self.update(cx, |state, cx| {
164 if parsed_update.revision != state.revision {
165 return;
166 }
167 if parsed_update.baseline_ack {
168 debug_assert!(parsed_update.full_parse);
169 return;
170 }
171
172 match parsed_update.result {
173 Ok(content) => {
174 state.stream_fade.record(
175 &state.parsed_content.document,
176 &content.document,
177 Instant::now(),
178 );
179 state.parsed_content = content;
180 state.parsed_error = None;
181 state.compatible_layout_update = parsed_update.selection_compatible;
182 if parsed_update.full_parse {
183 state.invalidate_measured_heights();
184 }
185 }
186 Err(err) => {
187 state.stream_fade.discard_pending();
188 state.parsed_error = Some(err);
189 }
190 }
191 if !parsed_update.selection_compatible && !state.is_selecting {
195 state.reset_selection_and_adapter(cx);
196 }
197 cx.notify();
198 });
199 }
200 }
201 });
202
203 let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
204
205 let mut this = Self {
206 focus_handle,
207 bounds: Bounds::default(),
208 multi_click_selection: None,
209 selected_text_override: None,
210 select_all: false,
211 selectable: false,
212 selection_format: SelectionFormat::default(),
213 scrollable: false,
214 max_lines: None,
215 line_spans: Arc::default(),
216 clamped: false,
217 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
222 text_view_style: Arc::default(),
223 code_block_actions: None,
224 code_block_highlighter: None,
225 table_actions: None,
226 link_click_handler: None,
227 markdown_extensions: Arc::default(),
228 is_selecting: false,
229 preserve_inline_selection: false,
230 auto_scroll: AutoScroll::default(),
231 selection_adapter,
232 parsed_content: Default::default(),
233 stream_fade: StreamFadeTracker::default(),
234 format,
235 parsed_error: None,
236 text: text.to_string(),
237 element_text: None,
238 revision: 0,
239 selection_revision: 0,
240 compatible_layout_update: false,
241 layout_text_style: None,
242 tx,
243 _parse_task,
244 _receive_task,
245 };
246 this.increment_update(&text, false, cx);
247 this
248 }
249
250 pub(crate) fn source(&self) -> SharedString {
252 self.parsed_content.document.source.clone()
253 }
254
255 pub fn selectable(mut self, selectable: bool) -> Self {
257 self.selectable = selectable;
258 self
259 }
260
261 pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
263 self.selectable = selectable;
264 cx.notify();
265 }
266
267 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
269 self.selection_format = selection_format;
270 self
271 }
272
273 pub fn set_selection_format(
275 &mut self,
276 selection_format: SelectionFormat,
277 cx: &mut Context<Self>,
278 ) {
279 self.selection_format = selection_format;
280 cx.notify();
281 }
282
283 pub fn scrollable(mut self, scrollable: bool) -> Self {
285 self.scrollable = scrollable;
286 self
287 }
288
289 pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
291 if !scrollable {
292 self.reset_selection_and_adapter(cx);
293 }
294 self.scrollable = scrollable;
295 cx.notify();
296 }
297
298 pub fn is_clamped(&self) -> bool {
301 self.clamped
302 }
303
304 pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
310 if self.text.as_str() == text {
311 return;
312 }
313 if self.stream_fade.is_enabled() {
314 if text.starts_with(self.text.as_str()) {
315 self.stream_fade.note_extend(self.text.len());
316 } else {
317 self.stream_fade.note_replace();
318 }
319 }
320
321 self.text.clear();
322 self.text.push_str(text);
323 self.parsed_error = None;
324 self.increment_update(text, false, cx);
325 }
326
327 pub(super) fn set_element_text(&mut self, text: &SharedString, cx: &mut Context<Self>) {
332 let same_string = self.element_text.as_ref().is_some_and(|current| {
333 current.as_ptr() == text.as_ptr() && current.len() == text.len()
334 });
335 if same_string && self.text.len() == text.len() {
336 return;
337 }
338 self.element_text = Some(text.clone());
339 self.set_text(text, cx);
340 }
341
342 pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
344 if new_text.is_empty() {
345 return;
346 }
347 self.stream_fade.note_extend(self.text.len());
348 self.text.push_str(new_text);
349 self.increment_update(new_text, true, cx);
350 }
351
352 pub fn motion(mut self, motion: TextViewMotion) -> Self {
354 self.set_motion(motion);
355 self
356 }
357
358 pub fn set_motion(&mut self, motion: TextViewMotion) {
360 self.stream_fade.set_motion(motion);
361 }
362
363 pub(crate) fn set_markdown_extensions(
364 &mut self,
365 markdown_extensions: Arc<MarkdownExtensions>,
366 cx: &mut Context<Self>,
367 ) {
368 if self.markdown_extensions.revision() == markdown_extensions.revision() {
369 return;
370 }
371
372 let parser_configuration_changed = !self
373 .markdown_extensions
374 .has_same_parser_configuration(&markdown_extensions);
375 self.markdown_extensions = markdown_extensions;
376 if parser_configuration_changed && self.format == TextViewFormat::Markdown {
377 let text = self.text.clone();
378 self.increment_update(&text, false, cx);
379 }
380 }
381
382 pub fn selected_text(&self) -> String {
384 self.selected_text_in(None)
385 }
386
387 fn effective_format(&self) -> SelectionFormat {
397 match self.format {
398 TextViewFormat::Markdown => self.selection_format,
399 TextViewFormat::Html => SelectionFormat::Plain,
400 }
401 }
402
403 pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
410 let format = self.effective_format();
411
412 if self.select_all {
413 if format == SelectionFormat::Source {
414 return self.source().to_string();
415 }
416
417 return self.parsed_content.document.text();
418 }
419
420 if format != SelectionFormat::Source
425 && let Some(text) = &self.selected_text_override
426 {
427 return text.clone();
428 }
429
430 self.parsed_content.document.selected_text(format, blocks)
431 }
432
433 fn invalidate_measured_heights(&self) {
449 let count = self.list_state.item_count();
450 if count > 0 {
451 self.list_state.remeasure_items(0..count);
452 }
453 }
454
455 pub fn invalidate_inline_layout(&mut self, cx: &mut Context<Self>) {
461 self.preserve_inline_selection = true;
462 self.compatible_layout_update = true;
463 self.invalidate_measured_heights();
464 cx.notify();
465 }
466
467 fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
468 self.revision += 1;
469 if !append {
470 self.selection_revision = self.selection_revision.wrapping_add(1);
471 }
472 let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
473 let update_options = UpdateOptions {
474 revision: self.revision,
475 append,
476 mode: if append {
477 ParseMode::Compatible
478 } else if parse_synchronously {
479 ParseMode::BaselineAck
480 } else {
481 ParseMode::Replace
482 },
483 pending_text: text.to_string(),
484 markdown_extensions: self.markdown_extensions.clone(),
485 };
486
487 if parse_synchronously {
491 match parse_content(self.format, ParsedContent::default(), &update_options) {
492 Ok(content) => {
493 self.stream_fade.record(
494 &self.parsed_content.document,
495 &content.document,
496 Instant::now(),
497 );
498 self.parsed_content = content;
499 self.parsed_error = None;
500 self.invalidate_measured_heights();
501 if !self.is_selecting {
502 self.reset_selection_and_adapter(cx);
503 }
504 }
505 Err(err) => {
506 self.stream_fade.discard_pending();
507 self.parsed_error = Some(err);
508 }
509 }
510 _ = self.tx.try_send(update_options);
514 cx.notify();
515 return;
516 }
517
518 _ = self.tx.try_send(update_options);
519 }
520
521 pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
523 self.bounds = bounds;
524 }
525
526 pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
534 if !self.scrollable {
535 return None;
536 }
537
538 let origin = self.bounds.origin.y + self.scroll_offset().y;
539 let count = self.list_state.item_count();
540 let mut ix = self.list_state.logical_scroll_top().item_ix;
541 while ix < count {
542 let bounds = self.list_state.bounds_for_item(ix)?;
543 if content_y < bounds.bottom() - origin {
544 return Some(ix);
545 }
546 ix += 1;
547 }
548
549 count.checked_sub(1)
550 }
551
552 #[doc(hidden)]
553 pub fn bounds(&self) -> Bounds<Pixels> {
554 self.bounds
555 }
556
557 #[doc(hidden)]
558 pub fn list_state(&self) -> &ListState {
559 &self.list_state
560 }
561
562 #[doc(hidden)]
563 pub fn is_selecting(&self) -> bool {
564 self.is_selecting
565 }
566
567 #[doc(hidden)]
568 pub fn focus_handle(&self) -> &FocusHandle {
569 &self.focus_handle
570 }
571
572 pub(super) fn has_view_selection(&self) -> bool {
575 self.select_all
576 || self.multi_click_selection.is_some()
577 || self.selected_text_override.is_some()
578 }
579
580 pub(super) fn stop_auto_scroll(&mut self) {
581 self.auto_scroll.stop();
582 }
583
584 pub(super) fn reset_selection(&mut self) {
585 self.preserve_inline_selection = false;
586 self.multi_click_selection = None;
587 self.selected_text_override = None;
588 self.select_all = false;
589 self.is_selecting = false;
590 self.auto_scroll.stop();
591 self.parsed_content.document.clear_selection();
595 }
596
597 fn reset_selection_and_adapter(&mut self, cx: &mut App) {
598 self.reset_selection();
599 self.selection_adapter.set_local_selection(false, cx);
600 }
601
602 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
604 self.reset_selection_and_adapter(cx);
605 cx.notify();
606 }
607
608 pub(super) fn scroll_offset(&self) -> Point<Pixels> {
609 if self.scrollable {
610 self.list_state.scroll_px_offset_for_scrollbar()
611 } else {
612 Point::default()
613 }
614 }
615
616 pub fn select_all(&mut self, cx: &mut Context<Self>) {
618 self.multi_click_selection = None;
619 self.selected_text_override = None;
620 self.select_all = true;
621 self.is_selecting = false;
622 self.auto_scroll.stop();
623 self.selection_adapter.set_local_selection(true, cx);
624 cx.notify();
625 }
626
627 pub(crate) fn set_multi_click_selection(
628 &mut self,
629 pos: Point<Pixels>,
630 kind: TextViewMultiClickKind,
631 selected_text: String,
632 cx: &mut App,
633 ) {
634 self.preserve_inline_selection = false;
635 let scroll_offset = self.scroll_offset();
636 let pos = pos - self.bounds.origin - scroll_offset;
637 self.multi_click_selection = Some(TextViewMultiClickSelection {
638 pos,
639 kind,
640 line_bounds: None,
641 });
642 self.selected_text_override = Some(selected_text);
643 self.select_all = false;
644 self.is_selecting = false;
645 self.auto_scroll.stop();
646 self.selection_adapter.set_local_selection(true, cx);
647 }
648
649 pub(crate) fn set_multi_click_line(&mut self, bounds: Bounds<Pixels>, cx: &mut App) {
650 self.set_multi_click_selection(
651 bounds.center(),
652 TextViewMultiClickKind::Line,
653 String::new(),
654 cx,
655 );
656 self.selected_text_override = None;
657 let offset = self.bounds.origin + self.scroll_offset();
658 if let Some(selection) = self.multi_click_selection.as_mut() {
659 selection.line_bounds = Some(Bounds::new(bounds.origin - offset, bounds.size));
660 }
661 }
662
663 pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
664 self.auto_scroll.set(delta, cx, |delta, state, cx| {
665 state.list_state.scroll_by(delta);
666 cx.notify();
667 });
668 }
669
670 pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
677 if !self.selectable {
678 return None;
679 }
680 self.selection_adapter.selection_points(cx)
681 }
682
683 pub(crate) fn has_selection(&self, cx: &App) -> bool {
684 self.has_view_selection() || self.selection_points(cx).is_some()
685 }
686
687 pub(super) fn on_action_select_all(
688 &mut self,
689 _: &SelectAll,
690 _: &mut Window,
691 cx: &mut Context<Self>,
692 ) {
693 if !self.selectable {
694 cx.propagate();
695 return;
696 }
697
698 self.select_all(cx);
699 }
700
701 pub(crate) fn is_selectable(&self) -> bool {
702 self.selectable
703 }
704
705 pub(crate) fn is_all_selected(&self) -> bool {
706 self.select_all
707 }
708
709 pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
710 let scroll_offset = self.scroll_offset();
711 self.multi_click_selection.map(|selection| {
712 let pos = selection.pos + scroll_offset + self.bounds.origin;
713 let line_bounds = selection.line_bounds.map(|bounds| {
714 Bounds::new(
715 bounds.origin + scroll_offset + self.bounds.origin,
716 bounds.size,
717 )
718 });
719 TextViewMultiClickSelection {
720 pos,
721 line_bounds,
722 ..selection
723 }
724 })
725 }
726}
727
728#[derive(Clone, Copy, Debug, PartialEq)]
729pub(crate) struct TextViewMultiClickSelection {
730 pub(crate) pos: Point<Pixels>,
731 pub(crate) kind: TextViewMultiClickKind,
732 pub(crate) line_bounds: Option<Bounds<Pixels>>,
733}
734
735#[derive(Clone, Copy, Debug, PartialEq, Eq)]
736pub(crate) enum TextViewMultiClickKind {
737 Word,
738 Paragraph,
739 Line,
740}
741
742impl Render for TextViewState {
743 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
744 let typography = (window.text_style(), window.rem_size());
745 if self
746 .layout_text_style
747 .as_ref()
748 .is_some_and(|previous| previous != &typography)
749 {
750 self.preserve_inline_selection = true;
754 self.compatible_layout_update = true;
755 self.invalidate_measured_heights();
756 }
757 self.layout_text_style = Some(typography);
758 let state = cx.entity();
759 let stream_fade = self.stream_fade.frame(Instant::now(), cx.reduce_motion());
760 if stream_fade.is_some() {
761 window.request_animation_frame();
762 }
763 let node_cx = NodeContext {
765 offset: self.parsed_content.node_cx.offset,
766 link_refs: self.parsed_content.node_cx.link_refs.clone(),
767 style: self.text_view_style.clone(),
768 code_block_actions: self.code_block_actions.clone(),
769 code_block_highlighter: self.code_block_highlighter.clone(),
770 table_actions: self.table_actions.clone(),
771 link_click_handler: self.link_click_handler.clone(),
772 markdown_extensions: self.markdown_extensions.clone(),
773 stream_fade,
774 };
775
776 v_flex()
777 .w_full()
778 .when(self.max_lines.is_none(), |this| this.h_full())
781 .map(|this| match &self.parsed_error {
782 None => this.child(self.parsed_content.document.render_root(
783 if self.scrollable {
784 Some(self.list_state.clone())
785 } else {
786 None
787 },
788 &node_cx,
789 window,
790 cx,
791 )),
792 Some(err) => this.child(
793 v_flex()
794 .gap_1()
795 .child("Failed to parse content")
796 .child(err.to_string()),
797 ),
798 })
799 .on_prepaint(move |bounds, window, cx| {
800 let (
801 size_changed,
802 selection_involves_view,
803 has_selection_snapshot,
804 is_selecting,
805 compatible_layout_update,
806 ) = {
807 let state = state.read(cx);
808 (
809 state.bounds().size != bounds.size,
810 state.selection_adapter.is_part_of_window_selection(cx),
811 state.selection_adapter.has_selection_snapshot(cx),
812 state.is_selecting,
813 state.compatible_layout_update,
814 )
815 };
816 let mut revision_changed = false;
817 state.update(cx, |state, cx| {
818 revision_changed = state
819 .selection_adapter
820 .update_layout_revision(state.selection_revision, state.is_selecting);
821 state.update_bounds(bounds, cx);
822 state.compatible_layout_update = false;
823 });
824 if !is_selecting
825 && ((size_changed && selection_involves_view && !compatible_layout_update)
826 || (revision_changed && has_selection_snapshot))
827 {
828 TextSelection::clear(window, cx);
829 }
830 })
831 }
832}
833
834#[derive(Clone, PartialEq, Default)]
835pub(crate) struct ParsedContent {
836 pub(crate) document: ParsedDocument,
837 pub(crate) node_cx: node::NodeContext,
838}
839
840struct UpdateFuture {
841 format: TextViewFormat,
842 content: ParsedContent,
843 rx: Pin<Box<Receiver<UpdateOptions>>>,
844 tx_result: Sender<ParsedUpdate>,
845}
846
847impl UpdateFuture {
848 fn new(
849 format: TextViewFormat,
850 rx: Receiver<UpdateOptions>,
851 tx_result: Sender<ParsedUpdate>,
852 ) -> Self {
853 Self {
854 format,
855 content: Default::default(),
856 rx: Box::pin(rx),
857 tx_result,
858 }
859 }
860}
861
862impl Future for UpdateFuture {
863 type Output = ();
864
865 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
866 loop {
867 match self.rx.as_mut().poll_next(cx) {
868 Poll::Ready(Some(mut options)) => {
869 let hit_coalesce_budget =
870 merge_pending_options(&mut options, self.rx.as_ref().get_ref());
871
872 let res = parse_content(self.format, self.content.clone(), &options);
873 if let Ok(content) = &res {
874 self.content = content.clone();
875 }
876 _ = self.tx_result.try_send(ParsedUpdate {
877 revision: options.revision,
878 full_parse: !options.append,
879 selection_compatible: options.mode == ParseMode::Compatible,
880 baseline_ack: options.mode == ParseMode::BaselineAck,
881 result: res,
882 });
883 if hit_coalesce_budget {
884 cx.waker().wake_by_ref();
885 return Poll::Pending;
886 }
887 continue;
888 }
889 Poll::Ready(None) => return Poll::Ready(()),
890 Poll::Pending => return Poll::Pending,
891 }
892 }
893 }
894}
895
896#[derive(Clone)]
897struct UpdateOptions {
898 revision: usize,
899 pending_text: String,
900 append: bool,
901 mode: ParseMode,
902 markdown_extensions: Arc<MarkdownExtensions>,
903}
904
905impl UpdateOptions {
906 fn merge(&mut self, next: UpdateOptions) {
907 if next.append {
908 self.pending_text.push_str(&next.pending_text);
909 self.revision = next.revision;
910 if self.mode != ParseMode::Replace {
911 self.mode = ParseMode::Compatible;
912 }
913 } else {
914 *self = next;
915 }
916 }
917}
918
919struct ParsedUpdate {
920 revision: usize,
921 full_parse: bool,
922 selection_compatible: bool,
923 baseline_ack: bool,
924 result: Result<ParsedContent, SharedString>,
925}
926
927#[derive(Clone, Copy, Debug, PartialEq, Eq)]
928enum ParseMode {
929 BaselineAck,
930 Replace,
931 Compatible,
932}
933
934fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
935 let mut update_count = 1;
936
937 while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
938 match rx.try_recv() {
939 Ok(next_options) => {
940 options.merge(next_options);
941 update_count += 1;
942 }
943 Err(_) => return false,
944 }
945 }
946
947 true
948}
949
950fn parse_content(
951 format: TextViewFormat,
952 mut content: ParsedContent,
953 options: &UpdateOptions,
954) -> Result<ParsedContent, SharedString> {
955 let mut node_cx = NodeContext {
956 markdown_extensions: options.markdown_extensions.clone(),
957 ..NodeContext::default()
958 };
959
960 let last_span = options
966 .append
967 .then(|| {
968 content
969 .document
970 .blocks
971 .last()
972 .and_then(|block| block.span())
973 })
974 .flatten();
975
976 let mut source = String::new();
977 if let Some(span) = last_span {
978 Arc::make_mut(&mut content.document.blocks).pop();
979 node_cx.offset = span.start;
980 source.push_str(&content.document.source[span.start..]);
981 source.push_str(&options.pending_text);
982 } else {
983 if options.append {
984 node_cx.offset = content.document.source.len();
985 }
986 source.push_str(&options.pending_text);
987 }
988
989 let new_document = match format {
990 TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
991 TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
992 }?;
993
994 if options.append {
995 content.document.source =
996 format!("{}{}", content.document.source, options.pending_text).into();
997 Arc::make_mut(&mut content.document.blocks)
998 .extend(Arc::unwrap_or_clone(new_document.blocks));
999 } else {
1000 content.document = new_document;
1001 }
1002
1003 Ok(content)
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use crate::text::MarkdownNode;
1010 use gpui::TestAppContext;
1011
1012 mod stream_fade {
1013 use std::{ops::Range, time::Duration};
1014
1015 use gpui::{Entity, TestAppContext};
1016
1017 use super::super::*;
1018 use crate::{motion::Easing, text::stream_fade::TextLeafKey};
1019
1020 const FADE: Duration = Duration::from_secs(10);
1023
1024 fn fading_state(markdown: &str, cx: &mut TestAppContext) -> Entity<TextViewState> {
1025 cx.update(crate::init);
1026 let state = cx.update(|cx| {
1027 cx.new(|cx| {
1028 TextViewState::markdown(markdown, cx).motion(
1029 TextViewMotion::default()
1030 .with_stream_fade(FADE)
1031 .with_stream_fade_easing(Easing::Linear),
1032 )
1033 })
1034 });
1035 cx.run_until_parked();
1036 state
1037 }
1038
1039 fn fades(
1042 state: &Entity<TextViewState>,
1043 key: TextLeafKey,
1044 cx: &mut TestAppContext,
1045 ) -> Option<Vec<Range<usize>>> {
1046 state.update(cx, |state, _| {
1047 let frame = state.stream_fade.frame(Instant::now(), false)?;
1048 let fades = frame.fades(key)?;
1049 assert!(
1050 fades.iter().all(|(_, fade_out)| *fade_out > 0.9),
1051 "a fade sampled right after it starts is still transparent: {fades:?}"
1052 );
1053 Some(fades.iter().map(|(range, _)| range.clone()).collect())
1054 })
1055 }
1056
1057 #[gpui::test]
1058 fn push_str_fades_only_the_appended_text(cx: &mut TestAppContext) {
1059 let state = fading_state("hello", cx);
1060 state.update(cx, |state, cx| state.push_str(" world", cx));
1061 cx.run_until_parked();
1062
1063 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1064
1065 state.update(cx, |state, _| {
1066 let later = Instant::now() + FADE + Duration::from_secs(1);
1067 assert!(state.stream_fade.frame(later, false).is_none());
1068 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1069 });
1070 }
1071
1072 #[gpui::test]
1073 fn set_text_extending_the_text_fades_like_push_str(cx: &mut TestAppContext) {
1074 let state = fading_state("hello", cx);
1075 state.update(cx, |state, cx| state.set_text("hello world", cx));
1076 cx.run_until_parked();
1077
1078 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..11]));
1079 }
1080
1081 #[gpui::test]
1082 fn set_text_replacing_the_text_shows_it_at_once(cx: &mut TestAppContext) {
1083 let state = fading_state("hello", cx);
1084 state.update(cx, |state, cx| state.push_str(" world", cx));
1085 cx.run_until_parked();
1086 assert!(fades(&state, TextLeafKey::block(0), cx).is_some());
1087
1088 state.update(cx, |state, cx| state.set_text("other", cx));
1089 cx.run_until_parked();
1090
1091 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1092 }
1093
1094 #[gpui::test]
1095 fn completed_markup_refades_from_the_divergence(cx: &mut TestAppContext) {
1096 let state = fading_state("text ", cx);
1099 state.update(cx, |state, cx| state.push_str("**bo", cx));
1100 cx.run_until_parked();
1101 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![4..9]));
1102
1103 state.update(cx, |state, cx| state.push_str("ld**", cx));
1106 cx.run_until_parked();
1107 assert_eq!(
1108 fades(&state, TextLeafKey::block(0), cx),
1109 Some(vec![4..5, 5..9])
1110 );
1111 }
1112
1113 #[gpui::test]
1114 fn without_stagger_an_update_fades_as_one_chunk(cx: &mut TestAppContext) {
1115 let state = fading_state("hello", cx);
1116 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1117 cx.run_until_parked();
1118
1119 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![5..19]));
1120 }
1121
1122 #[gpui::test]
1123 fn words_of_one_update_start_one_after_another(cx: &mut TestAppContext) {
1124 let stagger = Duration::from_millis(100);
1125 let state = fading_state("hello", cx);
1126 state.update(cx, |state, _| {
1127 state.set_motion(
1128 TextViewMotion::default()
1129 .with_stream_fade(FADE)
1130 .with_stream_fade_stagger(stagger)
1131 .with_stream_fade_easing(Easing::Linear),
1132 )
1133 });
1134 state.update(cx, |state, cx| state.push_str(" one two three", cx));
1135 cx.run_until_parked();
1136
1137 state.update(cx, |state, _| {
1138 let frame = state
1139 .stream_fade
1140 .frame(Instant::now() + stagger * 3, false)
1141 .expect("words still fading");
1142 let fades = frame.fades(TextLeafKey::block(0)).expect("paragraph fades");
1143 let ranges: Vec<_> = fades.iter().map(|(range, _)| range.clone()).collect();
1144 assert_eq!(ranges, vec![5..10, 10..14, 14..19]);
1145 assert!(
1147 fades[0].1 < fades[1].1 && fades[1].1 < fades[2].1,
1148 "{fades:?}"
1149 );
1150 });
1151 }
1152
1153 #[gpui::test]
1154 fn a_new_paragraph_fades_as_a_whole(cx: &mut TestAppContext) {
1155 let state = fading_state("first", cx);
1156 state.update(cx, |state, cx| state.push_str("\n\nsecond", cx));
1157 cx.run_until_parked();
1158
1159 assert_eq!(fades(&state, TextLeafKey::block(0), cx), None);
1160 assert_eq!(fades(&state, TextLeafKey::block(7), cx), Some(vec![0..6]));
1161 }
1162
1163 #[gpui::test]
1164 fn code_block_text_fades_by_block(cx: &mut TestAppContext) {
1165 let state = fading_state("```rs\nlet", cx);
1166 state.update(cx, |state, cx| state.push_str(" x", cx));
1167 cx.run_until_parked();
1168
1169 assert_eq!(fades(&state, TextLeafKey::block(0), cx), Some(vec![3..5]));
1170 }
1171
1172 #[gpui::test]
1173 fn table_cells_fade_by_ordinal(cx: &mut TestAppContext) {
1174 let state = fading_state("| a | b |\n|---|---|\n| c | d", cx);
1175 state.update(cx, |state, cx| state.push_str("e |", cx));
1176 cx.run_until_parked();
1177
1178 assert_eq!(fades(&state, TextLeafKey::table_cell(0, 2), cx), None);
1179 assert_eq!(
1180 fades(&state, TextLeafKey::table_cell(0, 3), cx),
1181 Some(vec![1..2])
1182 );
1183 }
1184
1185 #[gpui::test]
1186 fn zero_duration_records_nothing(cx: &mut TestAppContext) {
1187 cx.update(crate::init);
1188 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("hello", cx)));
1189 cx.run_until_parked();
1190 state.update(cx, |state, cx| state.push_str(" world", cx));
1191 cx.run_until_parked();
1192
1193 state.update(cx, |state, _| {
1194 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1195 });
1196 }
1197
1198 #[gpui::test]
1199 fn reduced_motion_drops_the_fade(cx: &mut TestAppContext) {
1200 let state = fading_state("hello", cx);
1201 state.update(cx, |state, cx| state.push_str(" world", cx));
1202 cx.run_until_parked();
1203
1204 state.update(cx, |state, _| {
1205 assert!(state.stream_fade.frame(Instant::now(), true).is_none());
1206 assert!(state.stream_fade.frame(Instant::now(), false).is_none());
1207 });
1208 }
1209 }
1210
1211 #[gpui::test]
1212 fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
1213 cx.update(crate::init);
1214 let markdown = "# ready";
1215 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1216
1217 state.read_with(cx, |state, _| {
1218 assert_eq!(state.source().as_str(), markdown);
1219 assert_eq!(state.parsed_content.document.blocks.len(), 1);
1220 });
1221 }
1222
1223 #[gpui::test]
1224 fn large_markdown_and_html_full_replacements_wait_for_background_executor(
1225 cx: &mut TestAppContext,
1226 ) {
1227 cx.update(crate::init);
1228 let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
1229 let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
1230 assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1231 assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
1232
1233 let (markdown_state, html_state) = cx.update(|cx| {
1234 (
1235 cx.new(|cx| TextViewState::markdown(&markdown, cx)),
1236 cx.new(|cx| TextViewState::html(&html, cx)),
1237 )
1238 });
1239
1240 markdown_state.read_with(cx, |state, _| {
1241 assert_eq!(state.text.as_str(), markdown.as_str());
1242 assert!(state.source().as_str().is_empty());
1243 assert!(state.parsed_content.document.blocks.is_empty());
1244 });
1245 html_state.read_with(cx, |state, _| {
1246 assert_eq!(state.text.as_str(), html.as_str());
1247 assert!(state.source().as_str().is_empty());
1248 assert!(state.parsed_content.document.blocks.is_empty());
1249 });
1250
1251 cx.run_until_parked();
1252
1253 markdown_state.read_with(cx, |state, _| {
1254 assert_eq!(state.source().as_str(), markdown.as_str());
1255 assert!(!state.parsed_content.document.blocks.is_empty());
1256 });
1257 html_state.read_with(cx, |state, _| {
1258 assert_eq!(state.source().as_str(), html.as_str());
1259 assert!(!state.parsed_content.document.blocks.is_empty());
1260 });
1261 }
1262
1263 #[gpui::test]
1264 fn inline_source_ranges_follow_streamed_tail_reparsing(cx: &mut TestAppContext) {
1265 cx.update(crate::init);
1266 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("中文 $a$\n\n尾 $x", cx)));
1267 state.update(cx, |state, cx| {
1268 let extensions = MarkdownExtensions::default().plugin(
1269 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(|node, _| {
1270 let markdown::mdast::Node::InlineMath(math) = node else {
1271 return None;
1272 };
1273 Some(super::super::MarkdownNode::new("formula", ()).text(math.value.clone()))
1274 }),
1275 );
1276 state.set_markdown_extensions(Arc::new(extensions), cx);
1277 });
1278 cx.run_until_parked();
1279 state.update(cx, |state, cx| state.push_str("^2$ 后", cx));
1280 cx.run_until_parked();
1281 state.read_with(cx, |state, _| {
1282 let source = "中文 $a$\n\n尾 $x^2$ 后";
1283 assert_eq!(state.source().as_str(), source);
1284 let objects: Vec<_> = state
1285 .parsed_content
1286 .document
1287 .blocks
1288 .iter()
1289 .flat_map(|block| {
1290 let super::super::node::BlockNode::Paragraph(paragraph) = block else {
1291 panic!()
1292 };
1293 paragraph
1294 .children
1295 .iter()
1296 .filter_map(|node| node.custom.as_ref())
1297 })
1298 .collect();
1299 assert_eq!(objects.len(), 2);
1300 for (object, markdown) in objects.iter().zip(["$a$", "$x^2$"]) {
1301 let start = source.find(markdown).unwrap();
1302 assert_eq!(object.source_range(), Some(start..start + markdown.len()));
1303 assert_eq!(object.as_markdown(), markdown);
1304 }
1305 });
1306 }
1307
1308 #[gpui::test]
1309 fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
1310 cx.update(crate::init);
1311 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1312 cx.run_until_parked();
1313
1314 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
1315 let expected = format!("{replacement} tail");
1316 state.update(cx, |state, cx| {
1317 state.set_text(&replacement, cx);
1318 state.push_str(" tail", cx);
1319 });
1320 cx.run_until_parked();
1321
1322 state.read_with(cx, |state, _| {
1323 assert_eq!(state.text.as_str(), expected.as_str());
1324 assert_eq!(state.source().as_str(), expected.as_str());
1325 });
1326 }
1327
1328 #[gpui::test]
1329 fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
1330 cx.update(crate::init);
1331 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
1332 cx.run_until_parked();
1333
1334 state.update(cx, |state, cx| {
1335 state.push_str("<p>second</p>", cx);
1336 });
1337 cx.run_until_parked();
1338
1339 state.read_with(cx, |state, _| {
1340 assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
1341 let text = state
1342 .parsed_content
1343 .document
1344 .blocks
1345 .iter()
1346 .map(|block| block.text())
1347 .collect::<String>();
1348 assert!(text.contains("first"), "lost the first block: {text:?}");
1349 assert!(text.contains("second"), "lost the appended block: {text:?}");
1350 });
1351 }
1352
1353 #[gpui::test]
1354 fn element_text_of_the_same_string_is_not_compared_again(cx: &mut TestAppContext) {
1355 cx.update(crate::init);
1356 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx)));
1357 let revision = |cx: &mut TestAppContext| state.read_with(cx, |state, _| state.revision);
1358 let text = SharedString::from("hello");
1359
1360 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1361 let parsed = revision(cx);
1362 assert!(parsed > 0);
1363
1364 state.update(cx, |state, cx| state.set_element_text(&text, cx));
1367 assert_eq!(revision(cx), parsed);
1368 let equal = SharedString::from("hello".to_string());
1369 state.update(cx, |state, cx| state.set_element_text(&equal, cx));
1370 assert_eq!(revision(cx), parsed);
1371
1372 state.update(cx, |state, cx| {
1374 state.push_str(" world", cx);
1375 state.set_element_text(&equal, cx);
1376 });
1377 state.read_with(cx, |state, _| assert_eq!(state.text.as_str(), "hello"));
1378 }
1379
1380 #[gpui::test]
1381 fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
1382 cx.update(crate::init);
1383 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1384 cx.run_until_parked();
1385
1386 state.update(cx, |state, cx| {
1387 state.set_text("", cx);
1388 state.push_str("new", cx);
1389 state.push_str(" text", cx);
1390 });
1391 cx.run_until_parked();
1392
1393 state.read_with(cx, |state, _| {
1394 assert_eq!(state.text.as_str(), "new text");
1395 assert_eq!(state.source().as_str(), "new text");
1396 });
1397
1398 state.update(cx, |state, cx| {
1399 state.set_text("", cx);
1400 });
1401 cx.run_until_parked();
1402
1403 state.read_with(cx, |state, _| {
1404 assert_eq!(state.text.as_str(), "");
1405 assert_eq!(state.source().as_str(), "");
1406 });
1407 }
1408
1409 #[gpui::test]
1410 fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
1411 cx.update(crate::init);
1412 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1413 cx.run_until_parked();
1414
1415 state.update(cx, |state, cx| {
1416 state.set_text("new", cx);
1417 state.push_str(" text", cx);
1418 state.select_all(cx);
1419 });
1420 cx.run_until_parked();
1421
1422 state.read_with(cx, |state, _| {
1423 assert!(state.select_all);
1424 assert_eq!(state.selected_text().trim(), "new text");
1425 });
1426 }
1427
1428 #[test]
1429 fn update_options_merge_keeps_latest_full_text() {
1430 let mut options = UpdateOptions {
1431 revision: 1,
1432 pending_text: "old".to_string(),
1433 append: true,
1434 mode: ParseMode::Compatible,
1435 markdown_extensions: Arc::default(),
1436 };
1437
1438 options.merge(UpdateOptions {
1439 revision: 2,
1440 pending_text: "new".to_string(),
1441 append: false,
1442 mode: ParseMode::BaselineAck,
1443 markdown_extensions: Arc::default(),
1444 });
1445 options.merge(UpdateOptions {
1446 revision: 3,
1447 pending_text: " text".to_string(),
1448 append: true,
1449 mode: ParseMode::Compatible,
1450 markdown_extensions: Arc::default(),
1451 });
1452
1453 assert_eq!(options.revision, 3);
1454 assert_eq!(options.pending_text, "new text");
1455 assert!(!options.append);
1456 }
1457
1458 #[test]
1459 fn append_merged_into_async_replace_remains_a_replacement() {
1460 let mut options = UpdateOptions {
1461 revision: 1,
1462 pending_text: "new".to_string(),
1463 append: false,
1464 mode: ParseMode::Replace,
1465 markdown_extensions: Arc::default(),
1466 };
1467
1468 options.merge(UpdateOptions {
1469 revision: 2,
1470 pending_text: " text".to_string(),
1471 append: true,
1472 mode: ParseMode::Compatible,
1473 markdown_extensions: Arc::default(),
1474 });
1475
1476 assert_eq!(options.revision, 2);
1477 assert_eq!(options.pending_text, "new text");
1478 assert!(!options.append);
1479 assert_eq!(options.mode, ParseMode::Replace);
1480 }
1481
1482 #[test]
1483 fn update_future_yields_before_coalescing_all_queued_updates() {
1484 let (tx, rx) = unbounded::<UpdateOptions>();
1485 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
1486 let total_updates = 128;
1487
1488 for revision in 1..=total_updates {
1489 tx.try_send(UpdateOptions {
1490 revision,
1491 pending_text: format!("{revision}\n"),
1492 append: revision != 1,
1493 mode: if revision == 1 {
1494 ParseMode::BaselineAck
1495 } else {
1496 ParseMode::Compatible
1497 },
1498 markdown_extensions: Arc::default(),
1499 })
1500 .unwrap();
1501 }
1502
1503 let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
1504 let waker = futures::task::noop_waker();
1505 let mut task_cx = std::task::Context::from_waker(&waker);
1506
1507 assert!(matches!(
1508 std::future::Future::poll(future.as_mut(), &mut task_cx),
1509 Poll::Pending
1510 ));
1511 let parsed_update = rx_result.try_recv().expect("parse result");
1512
1513 assert!(
1514 parsed_update.revision < total_updates,
1515 "single poll coalesced every queued update through revision {}",
1516 parsed_update.revision
1517 );
1518
1519 assert!(matches!(
1520 std::future::Future::poll(future.as_mut(), &mut task_cx),
1521 Poll::Pending
1522 ));
1523 let parsed_update = rx_result.try_recv().expect("next parse result");
1524 assert_eq!(parsed_update.revision, total_updates);
1525 }
1526
1527 #[gpui::test]
1528 fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
1529 cx.update(crate::init);
1530 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
1531 cx.run_until_parked();
1532
1533 state.update(cx, |state, cx| {
1534 state.select_all(cx);
1535 });
1536
1537 state.read_with(cx, |state, _| {
1538 assert!(state.has_view_selection());
1539 assert_eq!(state.selected_text().trim(), "quick value");
1540 });
1541
1542 state.update(cx, |state, cx| {
1543 state.clear_selection(cx);
1544 });
1545
1546 state.read_with(cx, |state, _| {
1547 assert!(!state.has_view_selection());
1548 assert_eq!(state.selected_text(), "");
1549 });
1550 }
1551
1552 #[gpui::test]
1553 fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
1554 cx.update(crate::init);
1555 let markdown = "**quick** value";
1556 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1557 cx.run_until_parked();
1558
1559 state.update(cx, |state, cx| state.select_all(cx));
1560
1561 state.read_with(cx, |state, _| {
1563 assert_eq!(state.selected_text().trim(), "quick value");
1564 });
1565
1566 state.update(cx, |state, cx| {
1567 state.set_selection_format(SelectionFormat::Source, cx)
1568 });
1569
1570 state.read_with(cx, |state, _| {
1572 assert_eq!(state.selected_text().trim(), markdown);
1573 });
1574 }
1575
1576 #[gpui::test]
1577 fn parser_revision_reparses_same_name_inline_configuration(cx: &mut TestAppContext) {
1578 cx.update(crate::init);
1579 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("@member", cx)));
1580 for (revision, label) in [(1, "Alice"), (2, "Bob")] {
1581 let extensions = MarkdownExtensions::default()
1582 .parser_revision(revision)
1583 .plugin(
1584 crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with(
1585 move |node, _| {
1586 matches!(node, markdown::mdast::Node::Text(_))
1587 .then(|| MarkdownNode::new("mention", label).text(label))
1588 },
1589 ),
1590 );
1591 state.update(cx, |state, cx| {
1592 state.set_markdown_extensions(Arc::new(extensions), cx)
1593 });
1594 cx.run_until_parked();
1595 state.read_with(cx, |state, _| {
1596 let node::BlockNode::Paragraph(paragraph) =
1597 &state.parsed_content.document.blocks[0]
1598 else {
1599 panic!()
1600 };
1601 assert_eq!(
1602 paragraph.children[0].custom.as_ref().unwrap().as_text(),
1603 label
1604 );
1605 });
1606 }
1607 }
1608
1609 #[gpui::test]
1610 fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
1611 cx.update(crate::init);
1612 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
1613 cx.run_until_parked();
1614
1615 let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
1616 let markdown::mdast::Node::Paragraph(paragraph) = node else {
1617 return None;
1618 };
1619 let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
1620 return None;
1621 };
1622 let symbol = text.value.strip_prefix('$')?.to_string();
1623 let node_text = format!("${symbol}");
1624
1625 Some(
1626 MarkdownNode::new("ticker", symbol)
1627 .text(node_text)
1628 .markdown(cx.node_source(node).unwrap_or_default()),
1629 )
1630 });
1631
1632 state.update(cx, |state, cx| {
1633 state.set_markdown_extensions(Arc::new(extensions), cx);
1634 });
1635 cx.run_until_parked();
1636
1637 state.read_with(cx, |state, _| {
1638 let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
1639 panic!("expected custom markdown node");
1640 };
1641 assert_eq!(node.name(), "ticker");
1642 assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
1643 });
1644 }
1645}