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