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