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 if parsed_update.full_parse {
166 state.invalidate_measured_heights();
167 }
168 }
169 Err(err) => {
170 state.parsed_error = Some(err);
171 }
172 }
173 if !parsed_update.selection_compatible && !state.is_selecting {
177 state.reset_selection_and_adapter(cx);
178 }
179 cx.notify();
180 });
181 }
182 }
183 });
184
185 let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result));
186
187 let mut this = Self {
188 focus_handle,
189 bounds: Bounds::default(),
190 multi_click_selection: None,
191 selected_text_override: None,
192 select_all: false,
193 selectable: false,
194 selection_format: SelectionFormat::default(),
195 scrollable: false,
196 max_lines: None,
197 line_spans: Arc::default(),
198 clamped: false,
199 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)).measure_all(),
204 text_view_style: TextViewStyle::default(),
205 code_block_actions: None,
206 code_block_highlighter: None,
207 table_actions: None,
208 link_click_handler: None,
209 markdown_extensions: Arc::default(),
210 is_selecting: false,
211 auto_scroll: AutoScroll::default(),
212 selection_adapter,
213 parsed_content: Default::default(),
214 format,
215 parsed_error: None,
216 text: text.to_string(),
217 revision: 0,
218 selection_revision: 0,
219 compatible_layout_update: false,
220 tx,
221 _parse_task,
222 _receive_task,
223 };
224 this.increment_update(&text, false, cx);
225 this
226 }
227
228 pub(crate) fn source(&self) -> SharedString {
230 self.parsed_content.document.source.clone()
231 }
232
233 pub fn selectable(mut self, selectable: bool) -> Self {
235 self.selectable = selectable;
236 self
237 }
238
239 pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
241 self.selectable = selectable;
242 cx.notify();
243 }
244
245 pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self {
247 self.selection_format = selection_format;
248 self
249 }
250
251 pub fn set_selection_format(
253 &mut self,
254 selection_format: SelectionFormat,
255 cx: &mut Context<Self>,
256 ) {
257 self.selection_format = selection_format;
258 cx.notify();
259 }
260
261 pub fn scrollable(mut self, scrollable: bool) -> Self {
263 self.scrollable = scrollable;
264 self
265 }
266
267 pub fn set_scrollable(&mut self, scrollable: bool, cx: &mut Context<Self>) {
269 if !scrollable {
270 self.reset_selection_and_adapter(cx);
271 }
272 self.scrollable = scrollable;
273 cx.notify();
274 }
275
276 pub fn is_clamped(&self) -> bool {
279 self.clamped
280 }
281
282 pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
284 if self.text.as_str() == text {
285 return;
286 }
287
288 self.text.clear();
289 self.text.push_str(text);
290 self.parsed_error = None;
291 self.increment_update(text, false, cx);
292 }
293
294 pub fn push_str(&mut self, new_text: &str, cx: &mut Context<Self>) {
296 if new_text.is_empty() {
297 return;
298 }
299 self.text.push_str(new_text);
300 self.increment_update(new_text, true, cx);
301 }
302
303 pub(crate) fn set_markdown_extensions(
304 &mut self,
305 markdown_extensions: Arc<MarkdownExtensions>,
306 cx: &mut Context<Self>,
307 ) {
308 if self.markdown_extensions.revision() == markdown_extensions.revision() {
309 return;
310 }
311
312 let parser_configuration_changed = !self
313 .markdown_extensions
314 .has_same_parser_configuration(&markdown_extensions);
315 self.markdown_extensions = markdown_extensions;
316 if parser_configuration_changed && self.format == TextViewFormat::Markdown {
317 let text = self.text.clone();
318 self.increment_update(&text, false, cx);
319 }
320 }
321
322 pub fn selected_text(&self) -> String {
324 self.selected_text_in(None)
325 }
326
327 fn effective_format(&self) -> SelectionFormat {
337 match self.format {
338 TextViewFormat::Markdown => self.selection_format,
339 TextViewFormat::Html => SelectionFormat::Plain,
340 }
341 }
342
343 pub(super) fn selected_text_in(&self, blocks: Option<RangeInclusive<usize>>) -> String {
350 let format = self.effective_format();
351
352 if self.select_all {
353 if format == SelectionFormat::Source {
354 return self.source().to_string();
355 }
356
357 return self.parsed_content.document.text();
358 }
359
360 if format != SelectionFormat::Source
365 && let Some(text) = &self.selected_text_override
366 {
367 return text.clone();
368 }
369
370 self.parsed_content.document.selected_text(format, blocks)
371 }
372
373 fn invalidate_measured_heights(&self) {
389 let count = self.list_state.item_count();
390 if count > 0 {
391 self.list_state.remeasure_items(0..count);
392 }
393 }
394
395 fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context<Self>) {
396 self.revision += 1;
397 if !append {
398 self.selection_revision = self.selection_revision.wrapping_add(1);
399 }
400 let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES;
401 let update_options = UpdateOptions {
402 revision: self.revision,
403 append,
404 mode: if append {
405 ParseMode::Compatible
406 } else if parse_synchronously {
407 ParseMode::BaselineAck
408 } else {
409 ParseMode::Replace
410 },
411 pending_text: text.to_string(),
412 markdown_extensions: self.markdown_extensions.clone(),
413 };
414
415 if parse_synchronously {
419 match parse_content(self.format, ParsedContent::default(), &update_options) {
420 Ok(content) => {
421 self.parsed_content = content;
422 self.parsed_error = None;
423 self.invalidate_measured_heights();
424 if !self.is_selecting {
425 self.reset_selection_and_adapter(cx);
426 }
427 }
428 Err(err) => {
429 self.parsed_error = Some(err);
430 }
431 }
432 _ = self.tx.try_send(update_options);
436 cx.notify();
437 return;
438 }
439
440 _ = self.tx.try_send(update_options);
441 }
442
443 pub(super) fn update_bounds(&mut self, bounds: Bounds<Pixels>, _cx: &mut App) {
445 self.bounds = bounds;
446 }
447
448 pub(super) fn block_ix_at(&self, content_y: Pixels) -> Option<usize> {
456 if !self.scrollable {
457 return None;
458 }
459
460 let origin = self.bounds.origin.y + self.scroll_offset().y;
461 let count = self.list_state.item_count();
462 let mut ix = self.list_state.logical_scroll_top().item_ix;
463 while ix < count {
464 let bounds = self.list_state.bounds_for_item(ix)?;
465 if content_y < bounds.bottom() - origin {
466 return Some(ix);
467 }
468 ix += 1;
469 }
470
471 count.checked_sub(1)
472 }
473
474 #[doc(hidden)]
475 pub fn bounds(&self) -> Bounds<Pixels> {
476 self.bounds
477 }
478
479 #[doc(hidden)]
480 pub fn list_state(&self) -> &ListState {
481 &self.list_state
482 }
483
484 #[doc(hidden)]
485 pub fn is_selecting(&self) -> bool {
486 self.is_selecting
487 }
488
489 #[doc(hidden)]
490 pub fn focus_handle(&self) -> &FocusHandle {
491 &self.focus_handle
492 }
493
494 pub(super) fn has_view_selection(&self) -> bool {
497 self.select_all
498 || self.multi_click_selection.is_some()
499 || self.selected_text_override.is_some()
500 }
501
502 pub(super) fn stop_auto_scroll(&mut self) {
503 self.auto_scroll.stop();
504 }
505
506 pub(super) fn reset_selection(&mut self) {
507 self.multi_click_selection = None;
508 self.selected_text_override = None;
509 self.select_all = false;
510 self.is_selecting = false;
511 self.auto_scroll.stop();
512 self.parsed_content.document.clear_selection();
516 }
517
518 fn reset_selection_and_adapter(&mut self, cx: &mut App) {
519 self.reset_selection();
520 self.selection_adapter.set_local_selection(false, cx);
521 }
522
523 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
525 self.reset_selection_and_adapter(cx);
526 cx.notify();
527 }
528
529 pub(super) fn scroll_offset(&self) -> Point<Pixels> {
530 if self.scrollable {
531 self.list_state.scroll_px_offset_for_scrollbar()
532 } else {
533 Point::default()
534 }
535 }
536
537 pub fn select_all(&mut self, cx: &mut Context<Self>) {
539 self.multi_click_selection = None;
540 self.selected_text_override = None;
541 self.select_all = true;
542 self.is_selecting = false;
543 self.auto_scroll.stop();
544 self.selection_adapter.set_local_selection(true, cx);
545 cx.notify();
546 }
547
548 pub(crate) fn set_multi_click_selection(
549 &mut self,
550 pos: Point<Pixels>,
551 kind: TextViewMultiClickKind,
552 selected_text: String,
553 cx: &mut App,
554 ) {
555 let scroll_offset = self.scroll_offset();
556 let pos = pos - self.bounds.origin - scroll_offset;
557 self.multi_click_selection = Some(TextViewMultiClickSelection { pos, kind });
558 self.selected_text_override = Some(selected_text);
559 self.select_all = false;
560 self.is_selecting = false;
561 self.auto_scroll.stop();
562 self.selection_adapter.set_local_selection(true, cx);
563 }
564
565 pub(super) fn set_auto_scroll(&mut self, delta: Option<Pixels>, cx: &mut Context<Self>) {
566 self.auto_scroll.set(delta, cx, |delta, state, cx| {
567 state.list_state.scroll_by(delta);
568 cx.notify();
569 });
570 }
571
572 pub(crate) fn selection_points(&self, cx: &App) -> Option<(Point<Pixels>, Point<Pixels>)> {
579 if !self.selectable {
580 return None;
581 }
582 self.selection_adapter.selection_points(cx)
583 }
584
585 pub(crate) fn has_selection(&self, cx: &App) -> bool {
586 self.has_view_selection() || self.selection_points(cx).is_some()
587 }
588
589 pub(super) fn on_action_select_all(
590 &mut self,
591 _: &SelectAll,
592 _: &mut Window,
593 cx: &mut Context<Self>,
594 ) {
595 if !self.selectable {
596 cx.propagate();
597 return;
598 }
599
600 self.select_all(cx);
601 }
602
603 pub(crate) fn is_selectable(&self) -> bool {
604 self.selectable
605 }
606
607 pub(crate) fn is_all_selected(&self) -> bool {
608 self.select_all
609 }
610
611 pub(crate) fn multi_click_selection(&self) -> Option<TextViewMultiClickSelection> {
612 let scroll_offset = self.scroll_offset();
613 self.multi_click_selection.map(|selection| {
614 let pos = selection.pos + scroll_offset + self.bounds.origin;
615 TextViewMultiClickSelection { pos, ..selection }
616 })
617 }
618}
619
620#[derive(Clone, Copy, Debug, PartialEq)]
621pub(crate) struct TextViewMultiClickSelection {
622 pub(crate) pos: Point<Pixels>,
623 pub(crate) kind: TextViewMultiClickKind,
624}
625
626#[derive(Clone, Copy, Debug, PartialEq, Eq)]
627pub(crate) enum TextViewMultiClickKind {
628 Word,
629 Paragraph,
630}
631
632impl Render for TextViewState {
633 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
634 let state = cx.entity();
635 let document = self.parsed_content.document.clone();
636 let mut node_cx = self.parsed_content.node_cx.clone();
637
638 node_cx.code_block_actions = self.code_block_actions.clone();
639 node_cx.code_block_highlighter = self.code_block_highlighter.clone();
640 node_cx.table_actions = self.table_actions.clone();
641 node_cx.link_click_handler = self.link_click_handler.clone();
642 node_cx.markdown_extensions = self.markdown_extensions.clone();
643 node_cx.style = self.text_view_style.clone();
644
645 v_flex()
646 .w_full()
647 .when(self.max_lines.is_none(), |this| this.h_full())
650 .map(|this| match &mut self.parsed_error {
651 None => this.child(document.render_root(
652 if self.scrollable {
653 Some(self.list_state.clone())
654 } else {
655 None
656 },
657 &node_cx,
658 window,
659 cx,
660 )),
661 Some(err) => this.child(
662 v_flex()
663 .gap_1()
664 .child("Failed to parse content")
665 .child(err.to_string()),
666 ),
667 })
668 .on_prepaint(move |bounds, window, cx| {
669 let (
670 size_changed,
671 selection_involves_view,
672 has_selection_snapshot,
673 is_selecting,
674 compatible_layout_update,
675 ) = {
676 let state = state.read(cx);
677 (
678 state.bounds().size != bounds.size,
679 state.selection_adapter.is_part_of_window_selection(cx),
680 state.selection_adapter.has_selection_snapshot(cx),
681 state.is_selecting,
682 state.compatible_layout_update,
683 )
684 };
685 let mut revision_changed = false;
686 state.update(cx, |state, cx| {
687 revision_changed = state
688 .selection_adapter
689 .update_layout_revision(state.selection_revision, state.is_selecting);
690 state.update_bounds(bounds, cx);
691 state.compatible_layout_update = false;
692 });
693 if !is_selecting
694 && ((size_changed && selection_involves_view && !compatible_layout_update)
695 || (revision_changed && has_selection_snapshot))
696 {
697 TextSelection::clear(window, cx);
698 }
699 })
700 }
701}
702
703#[derive(Clone, PartialEq, Default)]
704pub(crate) struct ParsedContent {
705 pub(crate) document: ParsedDocument,
706 pub(crate) node_cx: node::NodeContext,
707}
708
709struct UpdateFuture {
710 format: TextViewFormat,
711 content: ParsedContent,
712 rx: Pin<Box<Receiver<UpdateOptions>>>,
713 tx_result: Sender<ParsedUpdate>,
714}
715
716impl UpdateFuture {
717 fn new(
718 format: TextViewFormat,
719 rx: Receiver<UpdateOptions>,
720 tx_result: Sender<ParsedUpdate>,
721 ) -> Self {
722 Self {
723 format,
724 content: Default::default(),
725 rx: Box::pin(rx),
726 tx_result,
727 }
728 }
729}
730
731impl Future for UpdateFuture {
732 type Output = ();
733
734 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
735 loop {
736 match self.rx.as_mut().poll_next(cx) {
737 Poll::Ready(Some(mut options)) => {
738 let hit_coalesce_budget =
739 merge_pending_options(&mut options, self.rx.as_ref().get_ref());
740
741 let res = parse_content(self.format, self.content.clone(), &options);
742 if let Ok(content) = &res {
743 self.content = content.clone();
744 }
745 _ = self.tx_result.try_send(ParsedUpdate {
746 revision: options.revision,
747 full_parse: !options.append,
748 selection_compatible: options.mode == ParseMode::Compatible,
749 baseline_ack: options.mode == ParseMode::BaselineAck,
750 result: res,
751 });
752 if hit_coalesce_budget {
753 cx.waker().wake_by_ref();
754 return Poll::Pending;
755 }
756 continue;
757 }
758 Poll::Ready(None) => return Poll::Ready(()),
759 Poll::Pending => return Poll::Pending,
760 }
761 }
762 }
763}
764
765#[derive(Clone)]
766struct UpdateOptions {
767 revision: usize,
768 pending_text: String,
769 append: bool,
770 mode: ParseMode,
771 markdown_extensions: Arc<MarkdownExtensions>,
772}
773
774impl UpdateOptions {
775 fn merge(&mut self, next: UpdateOptions) {
776 if next.append {
777 self.pending_text.push_str(&next.pending_text);
778 self.revision = next.revision;
779 if self.mode != ParseMode::Replace {
780 self.mode = ParseMode::Compatible;
781 }
782 } else {
783 *self = next;
784 }
785 }
786}
787
788struct ParsedUpdate {
789 revision: usize,
790 full_parse: bool,
791 selection_compatible: bool,
792 baseline_ack: bool,
793 result: Result<ParsedContent, SharedString>,
794}
795
796#[derive(Clone, Copy, Debug, PartialEq, Eq)]
797enum ParseMode {
798 BaselineAck,
799 Replace,
800 Compatible,
801}
802
803fn merge_pending_options(options: &mut UpdateOptions, rx: &Receiver<UpdateOptions>) -> bool {
804 let mut update_count = 1;
805
806 while update_count < MAX_COALESCED_UPDATES_PER_PARSE {
807 match rx.try_recv() {
808 Ok(next_options) => {
809 options.merge(next_options);
810 update_count += 1;
811 }
812 Err(_) => return false,
813 }
814 }
815
816 true
817}
818
819fn parse_content(
820 format: TextViewFormat,
821 mut content: ParsedContent,
822 options: &UpdateOptions,
823) -> Result<ParsedContent, SharedString> {
824 let mut node_cx = NodeContext {
825 markdown_extensions: options.markdown_extensions.clone(),
826 ..NodeContext::default()
827 };
828
829 let last_span = options
835 .append
836 .then(|| {
837 content
838 .document
839 .blocks
840 .last()
841 .and_then(|block| block.span())
842 })
843 .flatten();
844
845 let mut source = String::new();
846 if let Some(span) = last_span {
847 Arc::make_mut(&mut content.document.blocks).pop();
848 node_cx.offset = span.start;
849 source.push_str(&content.document.source[span.start..]);
850 source.push_str(&options.pending_text);
851 } else {
852 if options.append {
853 node_cx.offset = content.document.source.len();
854 }
855 source.push_str(&options.pending_text);
856 }
857
858 let new_document = match format {
859 TextViewFormat::Markdown => format::markdown::parse(&source, &mut node_cx),
860 TextViewFormat::Html => format::html::parse(&source, &mut node_cx),
861 }?;
862
863 if options.append {
864 content.document.source =
865 format!("{}{}", content.document.source, options.pending_text).into();
866 Arc::make_mut(&mut content.document.blocks)
867 .extend(Arc::unwrap_or_clone(new_document.blocks));
868 } else {
869 content.document = new_document;
870 }
871
872 Ok(content)
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use crate::text::MarkdownNode;
879 use gpui::TestAppContext;
880
881 #[gpui::test]
882 fn small_full_replace_parses_before_background_executor_runs(cx: &mut TestAppContext) {
883 cx.update(crate::init);
884 let markdown = "# ready";
885 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
886
887 state.read_with(cx, |state, _| {
888 assert_eq!(state.source().as_str(), markdown);
889 assert_eq!(state.parsed_content.document.blocks.len(), 1);
890 });
891 }
892
893 #[gpui::test]
894 fn large_markdown_and_html_full_replacements_wait_for_background_executor(
895 cx: &mut TestAppContext,
896 ) {
897 cx.update(crate::init);
898 let markdown = "# x\n\n".repeat(MAX_SYNC_FULL_REPLACE_BYTES / 5 + 1);
899 let html = format!("<p>{}</p>", "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1));
900 assert!(markdown.len() > MAX_SYNC_FULL_REPLACE_BYTES);
901 assert!(html.len() > MAX_SYNC_FULL_REPLACE_BYTES);
902
903 let (markdown_state, html_state) = cx.update(|cx| {
904 (
905 cx.new(|cx| TextViewState::markdown(&markdown, cx)),
906 cx.new(|cx| TextViewState::html(&html, cx)),
907 )
908 });
909
910 markdown_state.read_with(cx, |state, _| {
911 assert_eq!(state.text.as_str(), markdown.as_str());
912 assert!(state.source().as_str().is_empty());
913 assert!(state.parsed_content.document.blocks.is_empty());
914 });
915 html_state.read_with(cx, |state, _| {
916 assert_eq!(state.text.as_str(), html.as_str());
917 assert!(state.source().as_str().is_empty());
918 assert!(state.parsed_content.document.blocks.is_empty());
919 });
920
921 cx.run_until_parked();
922
923 markdown_state.read_with(cx, |state, _| {
924 assert_eq!(state.source().as_str(), markdown.as_str());
925 assert!(!state.parsed_content.document.blocks.is_empty());
926 });
927 html_state.read_with(cx, |state, _| {
928 assert_eq!(state.source().as_str(), html.as_str());
929 assert!(!state.parsed_content.document.blocks.is_empty());
930 });
931 }
932
933 #[gpui::test]
934 fn async_full_replace_then_push_str_preserves_complete_source(cx: &mut TestAppContext) {
935 cx.update(crate::init);
936 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
937 cx.run_until_parked();
938
939 let replacement = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1);
940 let expected = format!("{replacement} tail");
941 state.update(cx, |state, cx| {
942 state.set_text(&replacement, cx);
943 state.push_str(" tail", cx);
944 });
945 cx.run_until_parked();
946
947 state.read_with(cx, |state, _| {
948 assert_eq!(state.text.as_str(), expected.as_str());
949 assert_eq!(state.source().as_str(), expected.as_str());
950 });
951 }
952
953 #[gpui::test]
954 fn html_push_str_keeps_earlier_blocks(cx: &mut TestAppContext) {
955 cx.update(crate::init);
956 let state = cx.update(|cx| cx.new(|cx| TextViewState::html("<p>first</p>", cx)));
957 cx.run_until_parked();
958
959 state.update(cx, |state, cx| {
960 state.push_str("<p>second</p>", cx);
961 });
962 cx.run_until_parked();
963
964 state.read_with(cx, |state, _| {
965 assert_eq!(state.source().as_str(), "<p>first</p><p>second</p>");
966 let text = state
967 .parsed_content
968 .document
969 .blocks
970 .iter()
971 .map(|block| block.text())
972 .collect::<String>();
973 assert!(text.contains("first"), "lost the first block: {text:?}");
974 assert!(text.contains("second"), "lost the appended block: {text:?}");
975 });
976 }
977
978 #[gpui::test]
979 fn set_text_then_push_str_appends_to_replaced_content(cx: &mut TestAppContext) {
980 cx.update(crate::init);
981 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
982 cx.run_until_parked();
983
984 state.update(cx, |state, cx| {
985 state.set_text("", cx);
986 state.push_str("new", cx);
987 state.push_str(" text", cx);
988 });
989 cx.run_until_parked();
990
991 state.read_with(cx, |state, _| {
992 assert_eq!(state.text.as_str(), "new text");
993 assert_eq!(state.source().as_str(), "new text");
994 });
995
996 state.update(cx, |state, cx| {
997 state.set_text("", cx);
998 });
999 cx.run_until_parked();
1000
1001 state.read_with(cx, |state, _| {
1002 assert_eq!(state.text.as_str(), "");
1003 assert_eq!(state.source().as_str(), "");
1004 });
1005 }
1006
1007 #[gpui::test]
1008 fn full_parse_coalesced_with_append_preserves_new_select_all(cx: &mut TestAppContext) {
1009 cx.update(crate::init);
1010 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("old", cx)));
1011 cx.run_until_parked();
1012
1013 state.update(cx, |state, cx| {
1014 state.set_text("new", cx);
1015 state.push_str(" text", cx);
1016 state.select_all(cx);
1017 });
1018 cx.run_until_parked();
1019
1020 state.read_with(cx, |state, _| {
1021 assert!(state.select_all);
1022 assert_eq!(state.selected_text().trim(), "new text");
1023 });
1024 }
1025
1026 #[test]
1027 fn update_options_merge_keeps_latest_full_text() {
1028 let mut options = UpdateOptions {
1029 revision: 1,
1030 pending_text: "old".to_string(),
1031 append: true,
1032 mode: ParseMode::Compatible,
1033 markdown_extensions: Arc::default(),
1034 };
1035
1036 options.merge(UpdateOptions {
1037 revision: 2,
1038 pending_text: "new".to_string(),
1039 append: false,
1040 mode: ParseMode::BaselineAck,
1041 markdown_extensions: Arc::default(),
1042 });
1043 options.merge(UpdateOptions {
1044 revision: 3,
1045 pending_text: " text".to_string(),
1046 append: true,
1047 mode: ParseMode::Compatible,
1048 markdown_extensions: Arc::default(),
1049 });
1050
1051 assert_eq!(options.revision, 3);
1052 assert_eq!(options.pending_text, "new text");
1053 assert!(!options.append);
1054 }
1055
1056 #[test]
1057 fn append_merged_into_async_replace_remains_a_replacement() {
1058 let mut options = UpdateOptions {
1059 revision: 1,
1060 pending_text: "new".to_string(),
1061 append: false,
1062 mode: ParseMode::Replace,
1063 markdown_extensions: Arc::default(),
1064 };
1065
1066 options.merge(UpdateOptions {
1067 revision: 2,
1068 pending_text: " text".to_string(),
1069 append: true,
1070 mode: ParseMode::Compatible,
1071 markdown_extensions: Arc::default(),
1072 });
1073
1074 assert_eq!(options.revision, 2);
1075 assert_eq!(options.pending_text, "new text");
1076 assert!(!options.append);
1077 assert_eq!(options.mode, ParseMode::Replace);
1078 }
1079
1080 #[test]
1081 fn update_future_yields_before_coalescing_all_queued_updates() {
1082 let (tx, rx) = unbounded::<UpdateOptions>();
1083 let (tx_result, rx_result) = unbounded::<ParsedUpdate>();
1084 let total_updates = 128;
1085
1086 for revision in 1..=total_updates {
1087 tx.try_send(UpdateOptions {
1088 revision,
1089 pending_text: format!("{revision}\n"),
1090 append: revision != 1,
1091 mode: if revision == 1 {
1092 ParseMode::BaselineAck
1093 } else {
1094 ParseMode::Compatible
1095 },
1096 markdown_extensions: Arc::default(),
1097 })
1098 .unwrap();
1099 }
1100
1101 let mut future = Box::pin(UpdateFuture::new(TextViewFormat::Markdown, rx, tx_result));
1102 let waker = futures::task::noop_waker();
1103 let mut task_cx = std::task::Context::from_waker(&waker);
1104
1105 assert!(matches!(
1106 std::future::Future::poll(future.as_mut(), &mut task_cx),
1107 Poll::Pending
1108 ));
1109 let parsed_update = rx_result.try_recv().expect("parse result");
1110
1111 assert!(
1112 parsed_update.revision < total_updates,
1113 "single poll coalesced every queued update through revision {}",
1114 parsed_update.revision
1115 );
1116
1117 assert!(matches!(
1118 std::future::Future::poll(future.as_mut(), &mut task_cx),
1119 Poll::Pending
1120 ));
1121 let parsed_update = rx_result.try_recv().expect("next parse result");
1122 assert_eq!(parsed_update.revision, total_updates);
1123 }
1124
1125 #[gpui::test]
1126 fn select_all_returns_rendered_text(cx: &mut TestAppContext) {
1127 cx.update(crate::init);
1128 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("**quick** value", cx)));
1129 cx.run_until_parked();
1130
1131 state.update(cx, |state, cx| {
1132 state.select_all(cx);
1133 });
1134
1135 state.read_with(cx, |state, _| {
1136 assert!(state.has_view_selection());
1137 assert_eq!(state.selected_text().trim(), "quick value");
1138 });
1139
1140 state.update(cx, |state, cx| {
1141 state.clear_selection(cx);
1142 });
1143
1144 state.read_with(cx, |state, _| {
1145 assert!(!state.has_view_selection());
1146 assert_eq!(state.selected_text(), "");
1147 });
1148 }
1149
1150 #[gpui::test]
1151 fn select_all_in_source_format_returns_source(cx: &mut TestAppContext) {
1152 cx.update(crate::init);
1153 let markdown = "**quick** value";
1154 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx)));
1155 cx.run_until_parked();
1156
1157 state.update(cx, |state, cx| state.select_all(cx));
1158
1159 state.read_with(cx, |state, _| {
1161 assert_eq!(state.selected_text().trim(), "quick value");
1162 });
1163
1164 state.update(cx, |state, cx| {
1165 state.set_selection_format(SelectionFormat::Source, cx)
1166 });
1167
1168 state.read_with(cx, |state, _| {
1170 assert_eq!(state.selected_text().trim(), markdown);
1171 });
1172 }
1173
1174 #[gpui::test]
1175 fn set_markdown_extensions_reparses_existing_text(cx: &mut TestAppContext) {
1176 cx.update(crate::init);
1177 let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("$TSLA.US", cx)));
1178 cx.run_until_parked();
1179
1180 let extensions = MarkdownExtensions::default().block_parser(|node, cx| {
1181 let markdown::mdast::Node::Paragraph(paragraph) = node else {
1182 return None;
1183 };
1184 let [markdown::mdast::Node::Text(text)] = paragraph.children.as_slice() else {
1185 return None;
1186 };
1187 let symbol = text.value.strip_prefix('$')?.to_string();
1188 let node_text = format!("${symbol}");
1189
1190 Some(
1191 MarkdownNode::new("ticker", symbol)
1192 .text(node_text)
1193 .markdown(cx.node_source(node).unwrap_or_default()),
1194 )
1195 });
1196
1197 state.update(cx, |state, cx| {
1198 state.set_markdown_extensions(Arc::new(extensions), cx);
1199 });
1200 cx.run_until_parked();
1201
1202 state.read_with(cx, |state, _| {
1203 let node::BlockNode::Custom(node) = &state.parsed_content.document.blocks[0] else {
1204 panic!("expected custom markdown node");
1205 };
1206 assert_eq!(node.name(), "ticker");
1207 assert_eq!(node.data::<String>().map(String::as_str), Some("TSLA.US"));
1208 });
1209 }
1210}