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