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