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