1use std::cmp::min;
2use std::sync::Arc;
3use std::time::Duration;
4use std::{ops::Range, path::PathBuf};
5
6use anyhow::Result;
7use editor::scroll::Autoscroll;
8use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
9use gpui::{
10 App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
11 IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled,
12 Subscription, Task, WeakEntity, Window, list,
13};
14use language::LanguageRegistry;
15use settings::Settings;
16use theme::ThemeSettings;
17use ui::{WithScrollbar, prelude::*};
18use workspace::item::{Item, ItemHandle};
19use workspace::{Pane, Workspace};
20
21use crate::markdown_elements::ParsedMarkdownElement;
22use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
23use crate::{
24 OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
25 markdown_elements::ParsedMarkdown,
26 markdown_parser::parse_markdown,
27 markdown_renderer::{RenderContext, render_markdown_block},
28};
29use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
30
31const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
32
33pub struct MarkdownPreviewView {
34 workspace: WeakEntity<Workspace>,
35 image_cache: Entity<RetainAllImageCache>,
36 active_editor: Option<EditorState>,
37 focus_handle: FocusHandle,
38 contents: Option<ParsedMarkdown>,
39 selected_block: usize,
40 list_state: ListState,
41 language_registry: Arc<LanguageRegistry>,
42 mermaid_state: MermaidState,
43 parsing_markdown_task: Option<Task<Result<()>>>,
44 mode: MarkdownPreviewMode,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum MarkdownPreviewMode {
49 /// The preview will always show the contents of the provided editor.
50 Default,
51 /// The preview will "follow" the currently active editor.
52 Follow,
53}
54
55struct EditorState {
56 editor: Entity<Editor>,
57 _subscription: Subscription,
58}
59
60impl MarkdownPreviewView {
61 pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
62 workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
63 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
64 let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
65 workspace.active_pane().update(cx, |pane, cx| {
66 if let Some(existing_view_idx) =
67 Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
68 {
69 pane.activate_item(existing_view_idx, true, true, window, cx);
70 } else {
71 pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
72 }
73 });
74 cx.notify();
75 }
76 });
77
78 workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
79 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
80 let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
81 let pane = workspace
82 .find_pane_in_direction(workspace::SplitDirection::Right, cx)
83 .unwrap_or_else(|| {
84 workspace.split_pane(
85 workspace.active_pane().clone(),
86 workspace::SplitDirection::Right,
87 window,
88 cx,
89 )
90 });
91 pane.update(cx, |pane, cx| {
92 if let Some(existing_view_idx) =
93 Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
94 {
95 pane.activate_item(existing_view_idx, true, true, window, cx);
96 } else {
97 pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
98 }
99 });
100 editor.focus_handle(cx).focus(window, cx);
101 cx.notify();
102 }
103 });
104
105 workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
106 if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
107 // Check if there's already a following preview
108 let existing_follow_view_idx = {
109 let active_pane = workspace.active_pane().read(cx);
110 active_pane
111 .items_of_type::<MarkdownPreviewView>()
112 .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow)
113 .and_then(|view| active_pane.index_for_item(&view))
114 };
115
116 if let Some(existing_follow_view_idx) = existing_follow_view_idx {
117 workspace.active_pane().update(cx, |pane, cx| {
118 pane.activate_item(existing_follow_view_idx, true, true, window, cx);
119 });
120 } else {
121 let view = Self::create_following_markdown_view(workspace, editor, window, cx);
122 workspace.active_pane().update(cx, |pane, cx| {
123 pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
124 });
125 }
126 cx.notify();
127 }
128 });
129 }
130
131 fn find_existing_independent_preview_item_idx(
132 pane: &Pane,
133 editor: &Entity<Editor>,
134 cx: &App,
135 ) -> Option<usize> {
136 pane.items_of_type::<MarkdownPreviewView>()
137 .find(|view| {
138 let view_read = view.read(cx);
139 // Only look for independent (Default mode) previews, not Follow previews
140 view_read.mode == MarkdownPreviewMode::Default
141 && view_read
142 .active_editor
143 .as_ref()
144 .is_some_and(|active_editor| active_editor.editor == *editor)
145 })
146 .and_then(|view| pane.index_for_item(&view))
147 }
148
149 pub fn resolve_active_item_as_markdown_editor(
150 workspace: &Workspace,
151 cx: &mut Context<Workspace>,
152 ) -> Option<Entity<Editor>> {
153 if let Some(editor) = workspace
154 .active_item(cx)
155 .and_then(|item| item.act_as::<Editor>(cx))
156 && Self::is_markdown_file(&editor, cx)
157 {
158 return Some(editor);
159 }
160 None
161 }
162
163 fn create_markdown_view(
164 workspace: &mut Workspace,
165 editor: Entity<Editor>,
166 window: &mut Window,
167 cx: &mut Context<Workspace>,
168 ) -> Entity<MarkdownPreviewView> {
169 let language_registry = workspace.project().read(cx).languages().clone();
170 let workspace_handle = workspace.weak_handle();
171 MarkdownPreviewView::new(
172 MarkdownPreviewMode::Default,
173 editor,
174 workspace_handle,
175 language_registry,
176 window,
177 cx,
178 )
179 }
180
181 fn create_following_markdown_view(
182 workspace: &mut Workspace,
183 editor: Entity<Editor>,
184 window: &mut Window,
185 cx: &mut Context<Workspace>,
186 ) -> Entity<MarkdownPreviewView> {
187 let language_registry = workspace.project().read(cx).languages().clone();
188 let workspace_handle = workspace.weak_handle();
189 MarkdownPreviewView::new(
190 MarkdownPreviewMode::Follow,
191 editor,
192 workspace_handle,
193 language_registry,
194 window,
195 cx,
196 )
197 }
198
199 pub fn new(
200 mode: MarkdownPreviewMode,
201 active_editor: Entity<Editor>,
202 workspace: WeakEntity<Workspace>,
203 language_registry: Arc<LanguageRegistry>,
204 window: &mut Window,
205 cx: &mut Context<Workspace>,
206 ) -> Entity<Self> {
207 cx.new(|cx| {
208 let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
209
210 let mut this = Self {
211 selected_block: 0,
212 active_editor: None,
213 focus_handle: cx.focus_handle(),
214 workspace: workspace.clone(),
215 contents: None,
216 list_state,
217 language_registry,
218 mermaid_state: Default::default(),
219 parsing_markdown_task: None,
220 image_cache: RetainAllImageCache::new(cx),
221 mode,
222 };
223
224 this.set_editor(active_editor, window, cx);
225
226 if mode == MarkdownPreviewMode::Follow {
227 if let Some(workspace) = &workspace.upgrade() {
228 cx.observe_in(workspace, window, |this, workspace, window, cx| {
229 let item = workspace.read(cx).active_item(cx);
230 this.workspace_updated(item, window, cx);
231 })
232 .detach();
233 } else {
234 log::error!("Failed to listen to workspace updates");
235 }
236 }
237
238 this
239 })
240 }
241
242 fn workspace_updated(
243 &mut self,
244 active_item: Option<Box<dyn ItemHandle>>,
245 window: &mut Window,
246 cx: &mut Context<Self>,
247 ) {
248 if let Some(item) = active_item
249 && item.item_id() != cx.entity_id()
250 && let Some(editor) = item.act_as::<Editor>(cx)
251 && Self::is_markdown_file(&editor, cx)
252 {
253 self.set_editor(editor, window, cx);
254 }
255 }
256
257 pub fn is_markdown_file<V>(editor: &Entity<Editor>, cx: &mut Context<V>) -> bool {
258 let buffer = editor.read(cx).buffer().read(cx);
259 if let Some(buffer) = buffer.as_singleton()
260 && let Some(language) = buffer.read(cx).language()
261 {
262 return language.name() == "Markdown";
263 }
264 false
265 }
266
267 fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
268 if let Some(active) = &self.active_editor
269 && active.editor == editor
270 {
271 return;
272 }
273
274 let subscription = cx.subscribe_in(
275 &editor,
276 window,
277 |this, editor, event: &EditorEvent, window, cx| {
278 match event {
279 EditorEvent::Edited { .. }
280 | EditorEvent::BufferEdited { .. }
281 | EditorEvent::DirtyChanged
282 | EditorEvent::ExcerptsEdited { .. } => {
283 this.parse_markdown_from_active_editor(true, window, cx);
284 }
285 EditorEvent::SelectionsChanged { .. } => {
286 let selection_range = editor.update(cx, |editor, cx| {
287 editor
288 .selections
289 .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
290 .range()
291 });
292 this.selected_block = this.get_block_index_under_cursor(selection_range);
293 this.list_state.scroll_to_reveal_item(this.selected_block);
294 cx.notify();
295 }
296 _ => {}
297 };
298 },
299 );
300
301 self.active_editor = Some(EditorState {
302 editor,
303 _subscription: subscription,
304 });
305
306 self.parse_markdown_from_active_editor(false, window, cx);
307 }
308
309 fn parse_markdown_from_active_editor(
310 &mut self,
311 wait_for_debounce: bool,
312 window: &mut Window,
313 cx: &mut Context<Self>,
314 ) {
315 if let Some(state) = &self.active_editor {
316 // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
317 if wait_for_debounce && self.parsing_markdown_task.is_some() {
318 return;
319 }
320 self.parsing_markdown_task = Some(self.parse_markdown_in_background(
321 wait_for_debounce,
322 state.editor.clone(),
323 window,
324 cx,
325 ));
326 }
327 }
328
329 fn parse_markdown_in_background(
330 &mut self,
331 wait_for_debounce: bool,
332 editor: Entity<Editor>,
333 window: &mut Window,
334 cx: &mut Context<Self>,
335 ) -> Task<Result<()>> {
336 let language_registry = self.language_registry.clone();
337
338 cx.spawn_in(window, async move |view, cx| {
339 if wait_for_debounce {
340 // Wait for the user to stop typing
341 cx.background_executor().timer(REPARSE_DEBOUNCE).await;
342 }
343
344 let (contents, file_location) = view.update(cx, |_, cx| {
345 let editor = editor.read(cx);
346 let contents = editor.buffer().read(cx).snapshot(cx).text();
347 let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
348 (contents, file_location)
349 })?;
350
351 let parsing_task = cx.background_spawn(async move {
352 parse_markdown(&contents, file_location, Some(language_registry)).await
353 });
354 let contents = parsing_task.await;
355
356 view.update(cx, move |view, cx| {
357 view.mermaid_state.update(&contents, cx);
358 let markdown_blocks_count = contents.children.len();
359 view.contents = Some(contents);
360 let scroll_top = view.list_state.logical_scroll_top();
361 view.list_state.reset(markdown_blocks_count);
362 view.list_state.scroll_to(scroll_top);
363 view.parsing_markdown_task = None;
364 cx.notify();
365 })
366 })
367 }
368
369 fn move_cursor_to_block(
370 &self,
371 window: &mut Window,
372 cx: &mut Context<Self>,
373 selection: Range<MultiBufferOffset>,
374 ) {
375 if let Some(state) = &self.active_editor {
376 state.editor.update(cx, |editor, cx| {
377 editor.change_selections(
378 SelectionEffects::scroll(Autoscroll::center()),
379 window,
380 cx,
381 |selections| selections.select_ranges(vec![selection]),
382 );
383 window.focus(&editor.focus_handle(cx), cx);
384 });
385 }
386 }
387
388 /// The absolute path of the file that is currently being previewed.
389 fn get_folder_for_active_editor(editor: &Editor, cx: &App) -> Option<PathBuf> {
390 if let Some(file) = editor.file_at(MultiBufferOffset(0), cx) {
391 if let Some(file) = file.as_local() {
392 file.abs_path(cx).parent().map(|p| p.to_path_buf())
393 } else {
394 None
395 }
396 } else {
397 None
398 }
399 }
400
401 fn get_block_index_under_cursor(&self, selection_range: Range<MultiBufferOffset>) -> usize {
402 let mut block_index = None;
403 let cursor = selection_range.start.0;
404
405 let mut last_end = 0;
406 if let Some(content) = &self.contents {
407 for (i, block) in content.children.iter().enumerate() {
408 let Some(Range { start, end }) = block.source_range() else {
409 continue;
410 };
411
412 // Check if the cursor is between the last block and the current block
413 if last_end <= cursor && cursor < start {
414 block_index = Some(i.saturating_sub(1));
415 break;
416 }
417
418 if start <= cursor && end >= cursor {
419 block_index = Some(i);
420 break;
421 }
422 last_end = end;
423 }
424
425 if block_index.is_none() && last_end < cursor {
426 block_index = Some(content.children.len().saturating_sub(1));
427 }
428 }
429
430 block_index.unwrap_or_default()
431 }
432
433 fn should_apply_padding_between(
434 current_block: &ParsedMarkdownElement,
435 next_block: Option<&ParsedMarkdownElement>,
436 ) -> bool {
437 !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
438 }
439
440 fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
441 let viewport_height = self.list_state.viewport_bounds().size.height;
442 if viewport_height.is_zero() {
443 return;
444 }
445
446 self.list_state.scroll_by(-viewport_height);
447 cx.notify();
448 }
449
450 fn scroll_page_down(
451 &mut self,
452 _: &ScrollPageDown,
453 _window: &mut Window,
454 cx: &mut Context<Self>,
455 ) {
456 let viewport_height = self.list_state.viewport_bounds().size.height;
457 if viewport_height.is_zero() {
458 return;
459 }
460
461 self.list_state.scroll_by(viewport_height);
462 cx.notify();
463 }
464
465 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
466 let scroll_top = self.list_state.logical_scroll_top();
467 if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
468 let item_height = bounds.size.height;
469 // Scroll no more than the rough equivalent of a large headline
470 let max_height = window.rem_size() * 2;
471 let scroll_height = min(item_height, max_height);
472 self.list_state.scroll_by(-scroll_height);
473 }
474 cx.notify();
475 }
476
477 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
478 let scroll_top = self.list_state.logical_scroll_top();
479 if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
480 let item_height = bounds.size.height;
481 // Scroll no more than the rough equivalent of a large headline
482 let max_height = window.rem_size() * 2;
483 let scroll_height = min(item_height, max_height);
484 self.list_state.scroll_by(scroll_height);
485 }
486 cx.notify();
487 }
488
489 fn scroll_up_by_item(
490 &mut self,
491 _: &ScrollUpByItem,
492 _window: &mut Window,
493 cx: &mut Context<Self>,
494 ) {
495 let scroll_top = self.list_state.logical_scroll_top();
496 if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
497 self.list_state.scroll_by(-bounds.size.height);
498 }
499 cx.notify();
500 }
501
502 fn scroll_down_by_item(
503 &mut self,
504 _: &ScrollDownByItem,
505 _window: &mut Window,
506 cx: &mut Context<Self>,
507 ) {
508 let scroll_top = self.list_state.logical_scroll_top();
509 if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
510 self.list_state.scroll_by(bounds.size.height);
511 }
512 cx.notify();
513 }
514
515 fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
516 self.list_state.scroll_to(ListOffset {
517 item_ix: 0,
518 offset_in_item: px(0.),
519 });
520 cx.notify();
521 }
522
523 fn scroll_to_bottom(
524 &mut self,
525 _: &ScrollToBottom,
526 _window: &mut Window,
527 cx: &mut Context<Self>,
528 ) {
529 let count = self.list_state.item_count();
530 if count > 0 {
531 self.list_state.scroll_to(ListOffset {
532 item_ix: count - 1,
533 offset_in_item: px(0.),
534 });
535 }
536 cx.notify();
537 }
538}
539
540impl Focusable for MarkdownPreviewView {
541 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
542 self.focus_handle.clone()
543 }
544}
545
546impl EventEmitter<()> for MarkdownPreviewView {}
547
548impl Item for MarkdownPreviewView {
549 type Event = ();
550
551 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
552 Some(Icon::new(IconName::FileDoc))
553 }
554
555 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
556 self.active_editor
557 .as_ref()
558 .map(|editor_state| {
559 let buffer = editor_state.editor.read(cx).buffer().read(cx);
560 let title = buffer.title(cx);
561 format!("Preview {}", title).into()
562 })
563 .unwrap_or_else(|| SharedString::from("Markdown Preview"))
564 }
565
566 fn telemetry_event_text(&self) -> Option<&'static str> {
567 Some("Markdown Preview Opened")
568 }
569
570 fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {}
571}
572
573impl Render for MarkdownPreviewView {
574 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
575 let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
576 let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height;
577
578 v_flex()
579 .image_cache(self.image_cache.clone())
580 .id("MarkdownPreview")
581 .key_context("MarkdownPreview")
582 .track_focus(&self.focus_handle(cx))
583 .on_action(cx.listener(MarkdownPreviewView::scroll_page_up))
584 .on_action(cx.listener(MarkdownPreviewView::scroll_page_down))
585 .on_action(cx.listener(MarkdownPreviewView::scroll_up))
586 .on_action(cx.listener(MarkdownPreviewView::scroll_down))
587 .on_action(cx.listener(MarkdownPreviewView::scroll_up_by_item))
588 .on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
589 .on_action(cx.listener(MarkdownPreviewView::scroll_to_top))
590 .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
591 .size_full()
592 .bg(cx.theme().colors().editor_background)
593 .p_4()
594 .text_size(buffer_size)
595 .line_height(relative(buffer_line_height.value()))
596 .child(div().flex_grow().map(|this| {
597 this.child(
598 list(
599 self.list_state.clone(),
600 cx.processor(|this, ix, window, cx| {
601 let Some(contents) = &this.contents else {
602 return div().into_any();
603 };
604
605 let mut render_cx = RenderContext::new(
606 Some(this.workspace.clone()),
607 &this.mermaid_state,
608 window,
609 cx,
610 )
611 .with_checkbox_clicked_callback(cx.listener(
612 move |this, e: &CheckboxClickedEvent, window, cx| {
613 if let Some(editor) =
614 this.active_editor.as_ref().map(|s| s.editor.clone())
615 {
616 editor.update(cx, |editor, cx| {
617 let task_marker =
618 if e.checked() { "[x]" } else { "[ ]" };
619
620 editor.edit(
621 [(
622 MultiBufferOffset(e.source_range().start)
623 ..MultiBufferOffset(e.source_range().end),
624 task_marker,
625 )],
626 cx,
627 );
628 });
629 this.parse_markdown_from_active_editor(false, window, cx);
630 cx.notify();
631 }
632 },
633 ));
634
635 let block = contents.children.get(ix).unwrap();
636 let rendered_block = render_markdown_block(block, &mut render_cx);
637
638 let should_apply_padding = Self::should_apply_padding_between(
639 block,
640 contents.children.get(ix + 1),
641 );
642
643 let selected_block = this.selected_block;
644 let scaled_rems = render_cx.scaled_rems(1.0);
645 div()
646 .id(ix)
647 .when(should_apply_padding, |this| {
648 this.pb(render_cx.scaled_rems(0.75))
649 })
650 .group("markdown-block")
651 .on_click(cx.listener(
652 move |this, event: &ClickEvent, window, cx| {
653 if event.click_count() == 2
654 && let Some(source_range) = this
655 .contents
656 .as_ref()
657 .and_then(|c| c.children.get(ix))
658 .and_then(|block: &ParsedMarkdownElement| {
659 block.source_range()
660 })
661 {
662 this.move_cursor_to_block(
663 window,
664 cx,
665 MultiBufferOffset(source_range.start)
666 ..MultiBufferOffset(source_range.start),
667 );
668 }
669 },
670 ))
671 .map(move |container| {
672 let indicator = div()
673 .h_full()
674 .w(px(4.0))
675 .when(ix == selected_block, |this| {
676 this.bg(cx.theme().colors().border)
677 })
678 .group_hover("markdown-block", |s| {
679 if ix == selected_block {
680 s
681 } else {
682 s.bg(cx.theme().colors().border_variant)
683 }
684 })
685 .rounded_xs();
686
687 container.child(
688 div()
689 .relative()
690 .child(div().pl(scaled_rems).child(rendered_block))
691 .child(indicator.absolute().left_0().top_0()),
692 )
693 })
694 .into_any()
695 }),
696 )
697 .size_full(),
698 )
699 }))
700 .vertical_scrollbar_for(&self.list_state, window, cx)
701 }
702}