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