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