1use futures::Stream as _;
2use std::{
3 ops::RangeInclusive,
4 pin::Pin,
5 sync::{Arc, Mutex},
6 task::Poll,
7};
8
9use gpui::{
10 App, AppContext as _, Bounds, Context, FocusHandle, IntoElement, KeyBinding, ListState,
11 ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window,
12 prelude::FluentBuilder as _, px,
13};
14
15use crate::{
16 AutoScroll, ElementExt, TextSelection,
17 async_util::{Receiver, Sender, unbounded},
18 input::{self, SelectAll},
19 text::{
20 CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions,
21 TableActionsFn, TextViewStyle,
22 document::ParsedDocument,
23 format,
24 node::{self, NodeContext},
25 selection_adapter::TextViewSelectionAdapter,
26 },
27 v_flex,
28};
29
30const CONTEXT: &'static str = "TextView";
31const MAX_COALESCED_UPDATES_PER_PARSE: usize = 64;
33const MAX_SYNC_FULL_REPLACE_BYTES: usize = 4 * 1024;
36
37pub(crate) fn init(cx: &mut App) {
38 cx.bind_keys(vec![
39 #[cfg(target_os = "macos")]
40 KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
41 #[cfg(not(target_os = "macos"))]
42 KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
43 #[cfg(target_os = "macos")]
44 KeyBinding::new("cmd-a", input::SelectAll, Some(CONTEXT)),
45 #[cfg(not(target_os = "macos"))]
46 KeyBinding::new("ctrl-a", input::SelectAll, Some(CONTEXT)),
47 ]);
48}
49
50#[derive(Clone, Copy, PartialEq, Eq)]
52pub(super) enum TextViewFormat {
53 Markdown,
55 Html,
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub enum SelectionFormat {
64 #[default]
66 Plain,
67 Source,
73}
74
75#[derive(Clone, Copy)]
79pub(super) struct LineSpan {
80 pub(super) top: Pixels,
81 pub(super) bottom: Pixels,
82 pub(super) line_height: Pixels,
83}
84
85pub struct TextViewState {
87 pub(super) focus_handle: FocusHandle,
88 pub(super) list_state: ListState,
89
90 bounds: Bounds<Pixels>,
92
93 pub(super) selectable: bool,
94 pub(super) selection_format: SelectionFormat,
95 pub(super) scrollable: bool,
96 pub(super) max_lines: Option<usize>,
97 pub(super) line_spans: Arc<Mutex<Vec<LineSpan>>>,
100 pub(super) clamped: bool,
102 pub(super) text_view_style: TextViewStyle,
103 pub(super) code_block_actions: Option<std::sync::Arc<CodeBlockActionsFn>>,
104 pub(super) code_block_highlighter: Option<std::sync::Arc<CodeBlockHighlighterFn>>,
105 pub(super) table_actions: Option<std::sync::Arc<TableActionsFn>>,
106 pub(super) link_click_handler: Option<std::sync::Arc<LinkClickHandlerFn>>,
107 pub(super) markdown_extensions: Arc<MarkdownExtensions>,
108
109 pub(super) is_selecting: bool,
110 multi_click_selection: Option<TextViewMultiClickSelection>,
111 selected_text_override: Option<String>,
112 select_all: bool,
113 pub(super) auto_scroll: AutoScroll,
114 pub(super) selection_adapter: TextViewSelectionAdapter,
115
116 pub(super) parsed_content: ParsedContent,
117 format: TextViewFormat,
120 text: String,
121 revision: usize,
122 pub(super) selection_revision: usize,
123 compatible_layout_update: bool,
124 parsed_error: Option<SharedString>,
125 tx: Sender<UpdateOptions>,
126 _parse_task: Task<()>,
127 _receive_task: Task<()>,
128}
129
130impl TextViewState {
131 pub fn markdown(text: &str, cx: &mut Context<Self>) -> Self {
133 Self::new(TextViewFormat::Markdown, text, cx)
134 }
135
136 pub fn html(text: &str, cx: &mut Context<Self>) -> Self {
138 Self::new(TextViewFormat::Html, text, cx)
139 }
140
141 fn new(format: TextViewFormat, text: &str, cx: &mut Context<Self>) -> Self {
143 let focus_handle = cx.focus_handle();
144 let selection_adapter = TextViewSelectionAdapter::new(cx.entity().downgrade(), cx);
145
146 let (tx, rx) = unbounded::<UpdateOptions>();
147 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
148 let _receive_task = cx.spawn({
149 async move |weak_self, cx| {
150 while let Ok(parsed_update) = rx_result.recv().await {
151 _ = weak_self.update(cx, |state, cx| {
152 if parsed_update.revision != state.revision {
153 return;
154 }
155 if parsed_update.baseline_ack {
156 debug_assert!(parsed_update.full_parse);
157 return;
158 }
159
160 match parsed_update.result {
161 Ok(content) => {
162 state.parsed_content = content;
163 state.parsed_error = None;
164 state.compatible_layout_update = parsed_update.selection_compatible;
165 }
166 Err(err) => {
167 state.parsed_error = Some(err);
168 }
169 }
170 if !parsed_update.selection_compatible && !state.is_selecting {
174 state.reset_selection_and_adapter(cx);
175 }
176 cx.notify();
177 });
178 }
179 }
180 });
181
182 let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
183
184 let mut this = Self {
185 focus_handle,
186 bounds: Bounds::default(),
187 multi_click_selection: None,
188 selected_text_override: None,
189 select_all: false,
190 selectable: false,
191 selection_format: SelectionFormat::default(),
192 scrollable: false,
193 max_lines: None,
194 line_spans: Arc::default(),
195 clamped: false,
196 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
201 text_view_style: TextViewStyle::default(),
202 code_block_actions: None,
203 code_block_highlighter: None,
204 table_actions: None,
205 link_click_handler: None,
206 markdown_extensions: Arc::default(),
207 is_selecting: false,
208 auto_scroll: AutoScroll::default(),
209 selection_adapter,
210 parsed_content: Default::default(),
211 format,
212 parsed_error: None,
213 text: text.to_string(),
214 revision: 0,
215 selection_revision: 0,
216 compatible_layout_update: false,
217 tx,
218 _parse_task,
219 _receive_task,
220 };
221 this.increment_update(&text, false, cx);
222 this
223 }
224
225 pub(crate) fn source(&self) -> SharedString {
227 self.parsed_content.document.source.clone()
228 }
229
230 pub fn selectable(mut self, selectable: bool) -> Self {
232 self.selectable = selectable;
233 self
234 }
235
236 pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
238 self.selectable = selectable;
239 cx.notify();
240 }
241
242 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
244 self.selection_format = selection_format;
245 self
246 }
247
248 pub fn set_selection_format(
250 &mut self,
251 selection_format: SelectionFormat,
252 cx: &mut Context<Self>,
253 ) {
254 self.selection_format = selection_format;
255 cx.notify();
256 }
257
258 pub fn scrollable(mut self, scrollable: bool) -> Self {
260 self.scrollable = scrollable;
261 self
262 }
263
264 pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
266 if !scrollable {
267 self.reset_selection_and_adapter(cx);
268 }
269 self.scrollable = scrollable;
270 cx.notify();
271 }
272
273 pub fn is_clamped(&self) -> bool {
276 self.clamped
277 }
278
279 pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
281 if self.text.as_str() == text {
282 return;
283 }
284
285 self.text.clear();
286 self.text.push_str(text);
287 self.parsed_error = None;
288 self.increment_update(text, false, cx);
289 }
290
291 pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
293 if new_text.is_empty() {
294 return;
295 }
296 self.text.push_str(new_text);
297 self.increment_update(new_text, true, cx);
298 }
299
300 pub(crate) fn set_markdown_extensions(
301 &mut self,
302 markdown_extensions: Arc<MarkdownExtensions>,
303 cx: &mut Context<Self>,
304 ) {
305 if self.markdown_extensions.revision() == markdown_extensions.revision() {
306 return;
307 }
308
309 let parser_configuration_changed = !self
310 .markdown_extensions
311 .has_same_parser_configuration(&markdown_extensions);
312 self.markdown_extensions = markdown_extensions;
313 if parser_configuration_changed && self.format == TextViewFormat::Markdown {
314 let text = self.text.clone();
315 self.increment_update(&text, false, cx);
316 }
317 }
318
319 pub fn selected_text(&self) -> String {
321 self.selected_text_in(None)
322 }
323
324 fn effective_format(&self) -> SelectionFormat {
334 match self.format {
335 TextViewFormat::Markdown => self.selection_format,
336 TextViewFormat::Html => SelectionFormat::Plain,
337 }
338 }
339
340 pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
347 let format = self.effective_format();
348
349 if self.select_all {
350 if format == SelectionFormat::Source {
351 return self.source().to_string();
352 }
353
354 return self.parsed_content.document.text();
355 }
356
357 if format != SelectionFormat::Source
362 && let Some(text) = &self.selected_text_override
363 {
364 return text.clone();
365 }
366
367 self.parsed_content.document.selected_text(format, blocks)
368 }
369
370 fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
371 self.revision += 1;
372 if !append {
373 self.selection_revision = self.selection_revision.wrapping_add(1);
374 }
375 let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
376 let update_options = UpdateOptions {
377 revision: self.revision,
378 append,
379 mode: if append {
380 ParseMode::Compatible
381 } else if parse_synchronously {
382 ParseMode::BaselineAck
383 } else {
384 ParseMode::Replace
385 },
386 pending_text: text.to_string(),
387 markdown_extensions: self.markdown_extensions.clone(),
388 };
389
390 if parse_synchronously {
394 match parse_content(self.format, ParsedContent::default(), &update_options) {
395 Ok(content) => {
396 self.parsed_content = content;
397 self.parsed_error = None;
398 if !self.is_selecting {
399 self.reset_selection_and_adapter(cx);
400 }
401 }
402 Err(err) => {
403 self.parsed_error = Some(err);
404 }
405 }
406 _ = self.tx.try_send(update_options);
410 cx.notify();
411 return;
412 }
413
414 _ = self.tx.try_send(update_options);
415 }
416
417 pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
419 self.bounds = bounds;
420 }
421
422 pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
430 if !self.scrollable {
431 return None;
432 }
433
434 let origin = self.bounds.origin.y + self.scroll_offset().y;
435 let count = self.list_state.item_count();
436 let mut ix = self.list_state.logical_scroll_top().item_ix;
437 while ix < count {
438 let bounds = self.list_state.bounds_for_item(ix)?;
439 if content_y < bounds.bottom() - origin {
440 return Some(ix);
441 }
442 ix += 1;
443 }
444
445 count.checked_sub(1)
446 }
447
448 #[doc(hidden)]
449 pub fn bounds(&self) -> Bounds<Pixels> {
450 self.bounds
451 }
452
453 #[doc(hidden)]
454 pub fn list_state(&self) -> &ListState {
455 &self.list_state
456 }
457
458 #[doc(hidden)]
459 pub fn is_selecting(&self) -> bool {
460 self.is_selecting
461 }
462
463 #[doc(hidden)]
464 pub fn focus_handle(&self) -> &FocusHandle {
465 &self.focus_handle
466 }
467
468 pub(super) fn has_view_selection(&self) -> bool {
471 self.select_all
472 || self.multi_click_selection.is_some()
473 || self.selected_text_override.is_some()
474 }
475
476 pub(super) fn stop_auto_scroll(&mut self) {
477 self.auto_scroll.stop();
478 }
479
480 pub(super) fn reset_selection(&mut self) {
481 self.multi_click_selection = None;
482 self.selected_text_override = None;
483 self.select_all = false;
484 self.is_selecting = false;
485 self.auto_scroll.stop();
486 self.parsed_content.document.clear_selection();
490 }
491
492 fn reset_selection_and_adapter(&mut self, cx: &mut App) {
493 self.reset_selection();
494 self.selection_adapter.set_local_selection(false, cx);
495 }
496
497 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
499 self.reset_selection_and_adapter(cx);
500 cx.notify();
501 }
502
503 pub(super) fn scroll_offset(&self) -> Point<Pixels> {
504 if self.scrollable {
505 self.list_state.scroll_px_offset_for_scrollbar()
506 } else {
507 Point::default()
508 }
509 }
510
511 pub fn select_all(&mut self, cx: &mut Context<Self>) {
513 self.multi_click_selection = None;
514 self.selected_text_override = None;
515 self.select_all = true;
516 self.is_selecting = false;
517 self.auto_scroll.stop();
518 self.selection_adapter.set_local_selection(true, cx);
519 cx.notify();
520 }
521
522 pub(crate) fn set_multi_click_selection(
523 &mut self,
524 pos: Point<Pixels>,
525 kind: TextViewMultiClickKind,
526 selected_text: String,
527 cx: &mut App,
528 ) {
529 let scroll_offset = self.scroll_offset();
530 let pos = pos - self.bounds.origin - scroll_offset;
531 self.multi_click_selection = Some(TextViewMultiClickSelection { pos, kind });
532 self.selected_text_override = Some(selected_text);
533 self.select_all = false;
534 self.is_selecting = false;
535 self.auto_scroll.stop();
536 self.selection_adapter.set_local_selection(true, cx);
537 }
538
539 pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
540 self.auto_scroll.set(delta, cx, |delta, state, cx| {
541 state.list_state.scroll_by(delta);
542 cx.notify();
543 });
544 }
545
546 pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
553 if !self.selectable {
554 return None;
555 }
556 self.selection_adapter.selection_points(cx)
557 }
558
559 pub(crate) fn has_selection(&self, cx: &App) -> bool {
560 self.has_view_selection() || self.selection_points(cx).is_some()
561 }
562
563 pub(super) fn on_action_select_all(
564 &mut self,
565 _: &SelectAll,
566 _: &mut Window,
567 cx: &mut Context<Self>,
568 ) {
569 if !self.selectable {
570 cx.propagate();
571 return;
572 }
573
574 self.select_all(cx);
575 }
576
577 pub(crate) fn is_selectable(&self) -> bool {
578 self.selectable
579 }
580
581 pub(crate) fn is_all_selected(&self) -> bool {
582 self.select_all
583 }
584
585 pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
586 let scroll_offset = self.scroll_offset();
587 self.multi_click_selection.map(|selection| {
588 let pos = selection.pos + scroll_offset + self.bounds.origin;
589 TextViewMultiClickSelection { pos, ..selection }
590 })
591 }
592}
593
594#[derive(Clone, Copy, Debug, PartialEq)]
595pub(crate) struct TextViewMultiClickSelection {
596 pub(crate) pos: Point<Pixels>,
597 pub(crate) kind: TextViewMultiClickKind,
598}
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
601pub(crate) enum TextViewMultiClickKind {
602 Word,
603 Paragraph,
604}
605
606impl Render for TextViewState {
607 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
608 let state = cx.entity();
609 let document = self.parsed_content.document.clone();
610 let mut node_cx = self.parsed_content.node_cx.clone();
611
612 node_cx.code_block_actions = self.code_block_actions.clone();
613 node_cx.code_block_highlighter = self.code_block_highlighter.clone();
614 node_cx.table_actions = self.table_actions.clone();
615 node_cx.link_click_handler = self.link_click_handler.clone();
616 node_cx.markdown_extensions = self.markdown_extensions.clone();
617 node_cx.style = self.text_view_style.clone();
618
619 v_flex()
620 .w_full()
621 .when(self.max_lines.is_none(), |this| this.h_full())
624 .map(|this| match &mut self.parsed_error {
625 None => this.child(document.render_root(
626 if self.scrollable {
627 Some(self.list_state.clone())
628 } else {
629 None
630 },
631 &node_cx,
632 window,
633 cx,
634 )),
635 Some(err) => this.child(
636 v_flex()
637 .gap_1()
638 .child("Failed to parse content")
639 .child(err.to_string()),
640 ),
641 })
642 .on_prepaint(move |bounds, window, cx| {
643 let (
644 size_changed,
645 selection_involves_view,
646 has_selection_snapshot,
647 is_selecting,
648 compatible_layout_update,
649 ) = {
650 let state = state.read(cx);
651 (
652 state.bounds().size != bounds.size,
653 state.selection_adapter.is_part_of_window_selection(cx),
654 state.selection_adapter.has_selection_snapshot(cx),
655 state.is_selecting,
656 state.compatible_layout_update,
657 )
658 };
659 let mut revision_changed = false;
660 state.update(cx, |state, cx| {
661 revision_changed = state
662 .selection_adapter
663 .update_layout_revision(state.selection_revision, state.is_selecting);
664 state.update_bounds(bounds, cx);
665 state.compatible_layout_update = false;
666 });
667 if !is_selecting
668 && ((size_changed && selection_involves_view && !compatible_layout_update)
669 || (revision_changed && has_selection_snapshot))
670 {
671 TextSelection::clear(window, cx);
672 }
673 })
674 }
675}
676
677#[derive(Clone, PartialEq, Default)]
678pub(crate) struct ParsedContent {
679 pub(crate) document: ParsedDocument,
680 pub(crate) node_cx: node::NodeContext,
681}
682
683struct UpdateFuture {
684 format: TextViewFormat,
685 content: ParsedContent,
686 rx: Pin<Box<Receiver<UpdateOptions>>>,
687 tx_result: Sender<ParsedUpdate>,
688}
689
690impl UpdateFuture {
691 fn new(
692 format: TextViewFormat,
693 rx: Receiver<UpdateOptions>,
694 tx_result: Sender<ParsedUpdate>,
695 ) -> Self {
696 Self {
697 format,
698 content: Default::default(),
699 rx: Box::pin(rx),
700 tx_result,
701 }
702 }
703}
704
705impl Future for UpdateFuture {
706 type Output = ();
707
708 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
709 loop {
710 match self.rx.as_mut().poll_next(cx) {
711 Poll::Ready(Some(mut options)) => {
712 let hit_coalesce_budget =
713 merge_pending_options(&mut options, self.rx.as_ref().get_ref());
714
715 let res = parse_content(self.format, self.content.clone(), &options);
716 if let Ok(content) = &res {
717 self.content = content.clone();
718 }
719 _ = self.tx_result.try_send(ParsedUpdate {
720 revision: options.revision,
721 full_parse: !options.append,
722 selection_compatible: options.mode == ParseMode::Compatible,
723 baseline_ack: options.mode == ParseMode::BaselineAck,
724 result: res,
725 });
726 if hit_coalesce_budget {
727 cx.waker().wake_by_ref();
728 return Poll::Pending;
729 }
730 continue;
731 }
732 Poll::Ready(None) => return Poll::Ready(()),
733 Poll::Pending => return Poll::Pending,
734 }
735 }
736 }
737}
738
739#[derive(Clone)]
740struct UpdateOptions {
741 revision: usize,
742 pending_text: String,
743 append: bool,
744 mode: ParseMode,
745 markdown_extensions: Arc<MarkdownExtensions>,
746}
747
748impl UpdateOptions {
749 fn merge(&mut self, next: UpdateOptions) {
750 if next.append {
751 self.pending_text.push_str(&next.pending_text);
752 self.revision = next.revision;
753 if self.mode != ParseMode::Replace {
754 self.mode = ParseMode::Compatible;
755 }
756 } else {
757 *self = next;
758 }
759 }
760}
761
762struct ParsedUpdate {
763 revision: usize,
764 full_parse: bool,
765 selection_compatible: bool,
766 baseline_ack: bool,
767 result: Result<ParsedContent, SharedString>,
768}
769
770#[derive(Clone, Copy, Debug, PartialEq, Eq)]
771enum ParseMode {
772 BaselineAck,
773 Replace,
774 Compatible,
775}
776
777fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
778 let mut update_count = 1;
779
780 while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
781 match rx.try_recv() {
782 Ok(next_options) => {
783 options.merge(next_options);
784 update_count += 1;
785 }
786 Err(_) => return false,
787 }
788 }
789
790 true
791}
792
793fn parse_content(
794 format: TextViewFormat,
795 mut content: ParsedContent,
796 options: &UpdateOptions,
797) -> Result<ParsedContent, SharedString> {
798 let mut node_cx = NodeContext {
799 markdown_extensions: options.markdown_extensions.clone(),
800 ..NodeContext::default()
801 };
802
803 let last_span = options
809 .append
810 .then(|| {
811 content
812 .document
813 .blocks
814 .last()
815 .and_then(|block| block.span())
816 })
817 .flatten();
818
819 let mut source = String::new();
820 if let Some(span) = last_span {
821 Arc::make_mut(&mut content.document.blocks).pop();
822 node_cx.offset = span.start;
823 source.push_str(&content.document.source[span.start..]);
824 source.push_str(&options.pending_text);
825 } else {
826 if options.append {
827 node_cx.offset = content.document.source.len();
828 }
829 source.push_str(&options.pending_text);
830 }
831
832 let new_document = match format {
833 TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
834 TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
835 }?;
836
837 if options.append {
838 content.document.source =
839 format!("{}{}", content.document.source, options.pending_text).into();
840 Arc::make_mut(&mut content.document.blocks)
841 .extend(Arc::unwrap_or_clone(new_document.blocks));
842 } else {
843 content.document = new_document;
844 }
845
846 Ok(content)
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use crate::text::MarkdownNode;
853 use gpui::TestAppContext;
854
855 #[gpui::test]
856 fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
857 cx.update(crate::init);
858 let markdown = "# ready";
859 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
860
861 state.read_with(cx, |state, _| {
862 assert_eq!(state.source().as_str(), markdown);
863 assert_eq!(state.parsed_content.document.blocks.len(), 1);
864 });
865 }
866
867 #[gpui::test]
868 fn large_markdown_and_html_full_replacements_wait_for_background_executor(
869 cx: &mut TestAppContext,
870 ) {
871 cx.update(crate::init);
872 let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
873 let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
874 assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
875 assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
876
877 let (markdown_state, html_state) = cx.update(|cx| {
878 (
879 cx.new(|cx| TextViewState::markdown(&markdown, cx)),
880 cx.new(|cx| TextViewState::html(&html, cx)),
881 )
882 });
883
884 markdown_state.read_with(cx, |state, _| {
885 assert_eq!(state.text.as_str(), markdown.as_str());
886 assert!(state.source().as_str().is_empty());
887 assert!(state.parsed_content.document.blocks.is_empty());
888 });
889 html_state.read_with(cx, |state, _| {
890 assert_eq!(state.text.as_str(), html.as_str());
891 assert!(state.source().as_str().is_empty());
892 assert!(state.parsed_content.document.blocks.is_empty());
893 });
894
895 cx.run_until_parked();
896
897 markdown_state.read_with(cx, |state, _| {
898 assert_eq!(state.source().as_str(), markdown.as_str());
899 assert!(!state.parsed_content.document.blocks.is_empty());
900 });
901 html_state.read_with(cx, |state, _| {
902 assert_eq!(state.source().as_str(), html.as_str());
903 assert!(!state.parsed_content.document.blocks.is_empty());
904 });
905 }
906
907 #[gpui::test]
908 fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
909 cx.update(crate::init);
910 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
911 cx.run_until_parked();
912
913 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
914 let expected = format!("{replacement} tail");
915 state.update(cx, |state, cx| {
916 state.set_text(&replacement, cx);
917 state.push_str(" tail", cx);
918 });
919 cx.run_until_parked();
920
921 state.read_with(cx, |state, _| {
922 assert_eq!(state.text.as_str(), expected.as_str());
923 assert_eq!(state.source().as_str(), expected.as_str());
924 });
925 }
926
927 #[gpui::test]
928 fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
929 cx.update(crate::init);
930 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
931 cx.run_until_parked();
932
933 state.update(cx, |state, cx| {
934 state.push_str("<p>second</p>", cx);
935 });
936 cx.run_until_parked();
937
938 state.read_with(cx, |state, _| {
939 assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
940 let text = state
941 .parsed_content
942 .document
943 .blocks
944 .iter()
945 .map(|block| block.text())
946 .collect::<String>();
947 assert!(text.contains("first"), "lost the first block: {text:?}");
948 assert!(text.contains("second"), "lost the appended block: {text:?}");
949 });
950 }
951
952 #[gpui::test]
953 fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
954 cx.update(crate::init);
955 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
956 cx.run_until_parked();
957
958 state.update(cx, |state, cx| {
959 state.set_text("", cx);
960 state.push_str("new", cx);
961 state.push_str(" text", cx);
962 });
963 cx.run_until_parked();
964
965 state.read_with(cx, |state, _| {
966 assert_eq!(state.text.as_str(), "new text");
967 assert_eq!(state.source().as_str(), "new text");
968 });
969
970 state.update(cx, |state, cx| {
971 state.set_text("", cx);
972 });
973 cx.run_until_parked();
974
975 state.read_with(cx, |state, _| {
976 assert_eq!(state.text.as_str(), "");
977 assert_eq!(state.source().as_str(), "");
978 });
979 }
980
981 #[gpui::test]
982 fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
983 cx.update(crate::init);
984 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
985 cx.run_until_parked();
986
987 state.update(cx, |state, cx| {
988 state.set_text("new", cx);
989 state.push_str(" text", cx);
990 state.select_all(cx);
991 });
992 cx.run_until_parked();
993
994 state.read_with(cx, |state, _| {
995 assert!(state.select_all);
996 assert_eq!(state.selected_text().trim(), "new text");
997 });
998 }
999
1000 #[test]
1001 fn update_options_merge_keeps_latest_full_text() {
1002 let mut options = UpdateOptions {
1003 revision: 1,
1004 pending_text: "old".to_string(),
1005 append: true,
1006 mode: ParseMode::Compatible,
1007 markdown_extensions: Arc::default(),
1008 };
1009
1010 options.merge(UpdateOptions {
1011 revision: 2,
1012 pending_text: "new".to_string(),
1013 append: false,
1014 mode: ParseMode::BaselineAck,
1015 markdown_extensions: Arc::default(),
1016 });
1017 options.merge(UpdateOptions {
1018 revision: 3,
1019 pending_text: " text".to_string(),
1020 append: true,
1021 mode: ParseMode::Compatible,
1022 markdown_extensions: Arc::default(),
1023 });
1024
1025 assert_eq!(options.revision, 3);
1026 assert_eq!(options.pending_text, "new text");
1027 assert!(!options.append);
1028 }
1029
1030 #[test]
1031 fn append_merged_into_async_replace_remains_a_replacement() {
1032 let mut options = UpdateOptions {
1033 revision: 1,
1034 pending_text: "new".to_string(),
1035 append: false,
1036 mode: ParseMode::Replace,
1037 markdown_extensions: Arc::default(),
1038 };
1039
1040 options.merge(UpdateOptions {
1041 revision: 2,
1042 pending_text: " text".to_string(),
1043 append: true,
1044 mode: ParseMode::Compatible,
1045 markdown_extensions: Arc::default(),
1046 });
1047
1048 assert_eq!(options.revision, 2);
1049 assert_eq!(options.pending_text, "new text");
1050 assert!(!options.append);
1051 assert_eq!(options.mode, ParseMode::Replace);
1052 }
1053
1054 #[test]
1055 fn update_future_yields_before_coalescing_all_queued_updates() {
1056 let (tx, rx) = unbounded::<UpdateOptions>();
1057 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
1058 let total_updates = 128;
1059
1060 for revision in 1..=total_updates {
1061 tx.try_send(UpdateOptions {
1062 revision,
1063 pending_text: format!("{revision}\n"),
1064 append: revision != 1,
1065 mode: if revision == 1 {
1066 ParseMode::BaselineAck
1067 } else {
1068 ParseMode::Compatible
1069 },
1070 markdown_extensions: Arc::default(),
1071 })
1072 .unwrap();
1073 }
1074
1075 let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
1076 let waker = futures::task::noop_waker();
1077 let mut task_cx = std::task::Context::from_waker(&waker);
1078
1079 assert!(matches!(
1080 std::future::Future::poll(future.as_mut(), &mut task_cx),
1081 Poll::Pending
1082 ));
1083 let parsed_update = rx_result.try_recv().expect("parse result");
1084
1085 assert!(
1086 parsed_update.revision < total_updates,
1087 "single poll coalesced every queued update through revision {}",
1088 parsed_update.revision
1089 );
1090
1091 assert!(matches!(
1092 std::future::Future::poll(future.as_mut(), &mut task_cx),
1093 Poll::Pending
1094 ));
1095 let parsed_update = rx_result.try_recv().expect("next parse result");
1096 assert_eq!(parsed_update.revision, total_updates);
1097 }
1098
1099 #[gpui::test]
1100 fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
1101 cx.update(crate::init);
1102 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
1103 cx.run_until_parked();
1104
1105 state.update(cx, |state, cx| {
1106 state.select_all(cx);
1107 });
1108
1109 state.read_with(cx, |state, _| {
1110 assert!(state.has_view_selection());
1111 assert_eq!(state.selected_text().trim(), "quick value");
1112 });
1113
1114 state.update(cx, |state, cx| {
1115 state.clear_selection(cx);
1116 });
1117
1118 state.read_with(cx, |state, _| {
1119 assert!(!state.has_view_selection());
1120 assert_eq!(state.selected_text(), "");
1121 });
1122 }
1123
1124 #[gpui::test]
1125 fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
1126 cx.update(crate::init);
1127 let markdown = "**quick** value";
1128 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1129 cx.run_until_parked();
1130
1131 state.update(cx, |state, cx| state.select_all(cx));
1132
1133 state.read_with(cx, |state, _| {
1135 assert_eq!(state.selected_text().trim(), "quick value");
1136 });
1137
1138 state.update(cx, |state, cx| {
1139 state.set_selection_format(SelectionFormat::Source, cx)
1140 });
1141
1142 state.read_with(cx, |state, _| {
1144 assert_eq!(state.selected_text().trim(), markdown);
1145 });
1146 }
1147
1148 #[gpui::test]
1149 fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
1150 cx.update(crate::init);
1151 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
1152 cx.run_until_parked();
1153
1154 let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
1155 let markdown::mdast::Node::Paragraph(paragraph) = node else {
1156 return None;
1157 };
1158 let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
1159 return None;
1160 };
1161 let symbol = text.value.strip_prefix('$')?.to_string();
1162 let node_text = format!("${symbol}");
1163
1164 Some(
1165 MarkdownNode::new("ticker", symbol)
1166 .text(node_text)
1167 .markdown(cx.node_source(node).unwrap_or_default()),
1168 )
1169 });
1170
1171 state.update(cx, |state, cx| {
1172 state.set_markdown_extensions(Arc::new(extensions), cx);
1173 });
1174 cx.run_until_parked();
1175
1176 state.read_with(cx, |state, _| {
1177 let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
1178 panic!("expected custom markdown node");
1179 };
1180 assert_eq!(node.name(), "ticker");
1181 assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
1182 });
1183 }
1184}