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