markdown preview: Improve live preview (#10205)

Bennet Bo Fenner created

This PR contains various improvements for the markdown preview (some of
which were originally part of #7601).
Some improvements can be seen in the video (see also release notes down
below):


https://github.com/zed-industries/zed/assets/53836821/93324ee8-d366-464a-9728-981eddbfdaf7

Release Notes:
- Added action to open markdown preview in the same pane
- Added support for displaying channel notes in markdown preview
- Added support for displaying the current active editor when opening
markdown preview
- Added support for scrolling the editor to the corresponding block when
double clicking an element in markdown preview
- Improved pane creation handling when opening markdown preview
- Fixed markdown preview displaying non-markdown files

Change summary

Cargo.lock                                           |   2 
crates/auto_update/src/auto_update.rs                |   5 
crates/markdown_preview/Cargo.toml                   |   2 
crates/markdown_preview/src/markdown_parser.rs       |   2 
crates/markdown_preview/src/markdown_preview.rs      |   2 
crates/markdown_preview/src/markdown_preview_view.rs | 393 ++++++++++---
crates/workspace/src/workspace.rs                    |   2 
7 files changed, 297 insertions(+), 111 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5675,11 +5675,13 @@ dependencies = [
 name = "markdown_preview"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "async-recursion 1.0.5",
  "editor",
  "gpui",
  "language",
  "linkify",
+ "log",
  "pretty_assertions",
  "pulldown-cmark",
  "theme",

crates/auto_update/src/auto_update.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
 };
 use isahc::AsyncBody;
 
-use markdown_preview::markdown_preview_view::MarkdownPreviewView;
+use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use serde_derive::Serialize;
@@ -238,10 +238,11 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Wo
                                 .new_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx));
                             let workspace_handle = workspace.weak_handle();
                             let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
+                                MarkdownPreviewMode::Default,
                                 editor,
                                 workspace_handle,
-                                Some(tab_description),
                                 language_registry,
+                                Some(tab_description),
                                 cx,
                             );
                             workspace.add_item_to_active_pane(Box::new(view.clone()), cx);

crates/markdown_preview/Cargo.toml 🔗

@@ -15,11 +15,13 @@ path = "src/markdown_preview.rs"
 test-support = []
 
 [dependencies]
+anyhow.workspace = true
 async-recursion.workspace = true
 editor.workspace = true
 gpui.workspace = true
 language.workspace = true
 linkify.workspace = true
+log.workspace = true
 pretty_assertions.workspace = true
 pulldown-cmark.workspace = true
 theme.workspace = true

crates/markdown_preview/src/markdown_preview.rs 🔗

@@ -6,7 +6,7 @@ pub mod markdown_parser;
 pub mod markdown_preview_view;
 pub mod markdown_renderer;
 
-actions!(markdown, [OpenPreview]);
+actions!(markdown, [OpenPreview, OpenPreviewToTheSide]);
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(|workspace: &mut Workspace, cx| {

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -1,16 +1,21 @@
 use std::sync::Arc;
+use std::time::Duration;
 use std::{ops::Range, path::PathBuf};
 
+use anyhow::Result;
+use editor::scroll::{Autoscroll, AutoscrollStrategy};
 use editor::{Editor, EditorEvent};
 use gpui::{
-    list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
-    IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView,
+    list, AnyElement, AppContext, ClickEvent, EventEmitter, FocusHandle, FocusableView,
+    InteractiveElement, IntoElement, ListState, ParentElement, Render, Styled, Subscription, Task,
+    View, ViewContext, WeakView,
 };
 use language::LanguageRegistry;
 use ui::prelude::*;
 use workspace::item::{Item, ItemHandle};
-use workspace::Workspace;
+use workspace::{Pane, Workspace};
 
+use crate::OpenPreviewToTheSide;
 use crate::{
     markdown_elements::ParsedMarkdown,
     markdown_parser::parse_markdown,
@@ -18,109 +23,123 @@ use crate::{
     OpenPreview,
 };
 
+const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
+
 pub struct MarkdownPreviewView {
     workspace: WeakView<Workspace>,
+    active_editor: Option<EditorState>,
     focus_handle: FocusHandle,
     contents: Option<ParsedMarkdown>,
     selected_block: usize,
     list_state: ListState,
-    tab_description: String,
+    tab_description: Option<String>,
+    fallback_tab_description: SharedString,
+    language_registry: Arc<LanguageRegistry>,
+    parsing_markdown_task: Option<Task<Result<()>>>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum MarkdownPreviewMode {
+    /// The preview will always show the contents of the provided editor.
+    Default,
+    /// The preview will "follow" the currently active editor.
+    Follow,
+}
+
+struct EditorState {
+    editor: View<Editor>,
+    _subscription: Subscription,
 }
 
 impl MarkdownPreviewView {
     pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
         workspace.register_action(move |workspace, _: &OpenPreview, cx| {
-            if workspace.has_active_modal(cx) {
-                cx.propagate();
-                return;
+            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
+                let view = Self::create_markdown_view(workspace, editor, cx);
+                workspace.active_pane().update(cx, |pane, cx| {
+                    if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
+                        pane.activate_item(existing_view_idx, true, true, cx);
+                    } else {
+                        pane.add_item(Box::new(view.clone()), true, true, None, cx)
+                    }
+                });
+                cx.notify();
             }
+        });
 
-            if let Some(editor) = workspace.active_item_as::<Editor>(cx) {
-                let language_registry = workspace.project().read(cx).languages().clone();
-                let workspace_handle = workspace.weak_handle();
-                let tab_description = editor.tab_description(0, cx);
-                let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
-                    editor,
-                    workspace_handle,
-                    tab_description,
-                    language_registry,
-                    cx,
-                );
-                workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx);
+        workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, cx| {
+            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
+                let view = Self::create_markdown_view(workspace, editor.clone(), cx);
+                let pane = workspace
+                    .find_pane_in_direction(workspace::SplitDirection::Right, cx)
+                    .unwrap_or_else(|| {
+                        workspace.split_pane(
+                            workspace.active_pane().clone(),
+                            workspace::SplitDirection::Right,
+                            cx,
+                        )
+                    });
+                pane.update(cx, |pane, cx| {
+                    if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
+                        pane.activate_item(existing_view_idx, true, true, cx);
+                    } else {
+                        pane.add_item(Box::new(view.clone()), false, false, None, cx)
+                    }
+                });
+                editor.focus_handle(cx).focus(cx);
                 cx.notify();
             }
         });
     }
 
+    fn find_existing_preview_item_idx(pane: &Pane) -> Option<usize> {
+        pane.items_of_type::<MarkdownPreviewView>()
+            .nth(0)
+            .and_then(|view| pane.index_for_item(&view))
+    }
+
+    fn resolve_active_item_as_markdown_editor(
+        workspace: &Workspace,
+        cx: &mut ViewContext<Workspace>,
+    ) -> Option<View<Editor>> {
+        if let Some(editor) = workspace
+            .active_item(cx)
+            .and_then(|item| item.act_as::<Editor>(cx))
+        {
+            if Self::is_markdown_file(&editor, cx) {
+                return Some(editor);
+            }
+        }
+        None
+    }
+
+    fn create_markdown_view(
+        workspace: &mut Workspace,
+        editor: View<Editor>,
+        cx: &mut ViewContext<Workspace>,
+    ) -> View<MarkdownPreviewView> {
+        let language_registry = workspace.project().read(cx).languages().clone();
+        let workspace_handle = workspace.weak_handle();
+        MarkdownPreviewView::new(
+            MarkdownPreviewMode::Follow,
+            editor,
+            workspace_handle,
+            language_registry,
+            None,
+            cx,
+        )
+    }
+
     pub fn new(
+        mode: MarkdownPreviewMode,
         active_editor: View<Editor>,
         workspace: WeakView<Workspace>,
-        tab_description: Option<SharedString>,
         language_registry: Arc<LanguageRegistry>,
+        fallback_description: Option<SharedString>,
         cx: &mut ViewContext<Workspace>,
     ) -> View<Self> {
         cx.new_view(|cx: &mut ViewContext<Self>| {
             let view = cx.view().downgrade();
-            let editor = active_editor.read(cx);
-            let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
-            let contents = editor.buffer().read(cx).snapshot(cx).text();
-
-            let language_registry_copy = language_registry.clone();
-            cx.spawn(|view, mut cx| async move {
-                let contents =
-                    parse_markdown(&contents, file_location, Some(language_registry_copy)).await;
-
-                view.update(&mut cx, |view, cx| {
-                    let markdown_blocks_count = contents.children.len();
-                    view.contents = Some(contents);
-                    view.list_state.reset(markdown_blocks_count);
-                    cx.notify();
-                })
-            })
-            .detach();
-
-            cx.subscribe(
-                &active_editor,
-                move |this, editor, event: &EditorEvent, cx| {
-                    match event {
-                        EditorEvent::Edited => {
-                            let editor = editor.read(cx);
-                            let contents = editor.buffer().read(cx).snapshot(cx).text();
-                            let file_location =
-                                MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
-                            let language_registry = language_registry.clone();
-                            cx.spawn(move |view, mut cx| async move {
-                                let contents = parse_markdown(
-                                    &contents,
-                                    file_location,
-                                    Some(language_registry.clone()),
-                                )
-                                .await;
-                                view.update(&mut cx, move |view, cx| {
-                                    let markdown_blocks_count = contents.children.len();
-                                    view.contents = Some(contents);
-
-                                    let scroll_top = view.list_state.logical_scroll_top();
-                                    view.list_state.reset(markdown_blocks_count);
-                                    view.list_state.scroll_to(scroll_top);
-                                    cx.notify();
-                                })
-                            })
-                            .detach();
-                        }
-                        EditorEvent::SelectionsChanged { .. } => {
-                            let editor = editor.read(cx);
-                            let selection_range = editor.selections.last::<usize>(cx).range();
-                            this.selected_block =
-                                this.get_block_index_under_cursor(selection_range);
-                            this.list_state.scroll_to_reveal_item(this.selected_block);
-                            cx.notify();
-                        }
-                        _ => {}
-                    };
-                },
-            )
-            .detach();
 
             let list_state =
                 ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
@@ -132,45 +151,202 @@ impl MarkdownPreviewView {
                             let mut render_cx =
                                 RenderContext::new(Some(view.workspace.clone()), cx);
                             let block = contents.children.get(ix).unwrap();
-                            let block = render_markdown_block(block, &mut render_cx);
-                            let block = div().child(block).pl_4().pb_3();
-
-                            if ix == view.selected_block {
-                                let indicator = div()
-                                    .h_full()
-                                    .w(px(4.0))
-                                    .bg(cx.theme().colors().border)
-                                    .rounded_sm();
-
-                                return div()
-                                    .relative()
-                                    .child(block)
-                                    .child(indicator.absolute().left_0().top_0())
-                                    .into_any();
-                            }
-
-                            block.into_any()
+                            let rendered_block = render_markdown_block(block, &mut render_cx);
+
+                            div()
+                                .id(ix)
+                                .pb_3()
+                                .group("markdown-block")
+                                .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
+                                    if event.down.click_count == 2 {
+                                        if let Some(block) =
+                                            this.contents.as_ref().and_then(|c| c.children.get(ix))
+                                        {
+                                            let start = block.source_range().start;
+                                            this.move_cursor_to_block(cx, start..start);
+                                        }
+                                    }
+                                }))
+                                .map(move |this| {
+                                    let indicator = div()
+                                        .h_full()
+                                        .w(px(4.0))
+                                        .when(ix == view.selected_block, |this| {
+                                            this.bg(cx.theme().colors().border)
+                                        })
+                                        .group_hover("markdown-block", |s| {
+                                            if ix != view.selected_block {
+                                                s.bg(cx.theme().colors().border_variant)
+                                            } else {
+                                                s
+                                            }
+                                        })
+                                        .rounded_sm();
+
+                                    this.child(
+                                        div()
+                                            .relative()
+                                            .child(div().pl_4().child(rendered_block))
+                                            .child(indicator.absolute().left_0().top_0()),
+                                    )
+                                })
+                                .into_any()
                         })
                     } else {
                         div().into_any()
                     }
                 });
 
-            let tab_description = tab_description
-                .map(|tab_description| format!("Preview {}", tab_description))
-                .unwrap_or("Markdown preview".to_string());
-
-            Self {
+            let mut this = Self {
                 selected_block: 0,
+                active_editor: None,
                 focus_handle: cx.focus_handle(),
-                workspace,
+                workspace: workspace.clone(),
                 contents: None,
                 list_state,
-                tab_description,
+                tab_description: None,
+                language_registry,
+                fallback_tab_description: fallback_description
+                    .unwrap_or_else(|| "Markdown Preview".into()),
+                parsing_markdown_task: None,
+            };
+
+            this.set_editor(active_editor, cx);
+
+            if mode == MarkdownPreviewMode::Follow {
+                if let Some(workspace) = &workspace.upgrade() {
+                    cx.observe(workspace, |this, workspace, cx| {
+                        let item = workspace.read(cx).active_item(cx);
+                        this.workspace_updated(item, cx);
+                    })
+                    .detach();
+                } else {
+                    log::error!("Failed to listen to workspace updates");
+                }
+            }
+
+            this
+        })
+    }
+
+    fn workspace_updated(
+        &mut self,
+        active_item: Option<Box<dyn ItemHandle>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(item) = active_item {
+            if item.item_id() != cx.entity_id() {
+                if let Some(editor) = item.act_as::<Editor>(cx) {
+                    if Self::is_markdown_file(&editor, cx) {
+                        self.set_editor(editor, cx);
+                    }
+                }
             }
+        }
+    }
+
+    fn is_markdown_file<V>(editor: &View<Editor>, cx: &mut ViewContext<V>) -> bool {
+        let language = editor.read(cx).buffer().read(cx).language_at(0, cx);
+        language
+            .map(|l| l.name().as_ref() == "Markdown")
+            .unwrap_or(false)
+    }
+
+    fn set_editor(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
+        if let Some(active) = &self.active_editor {
+            if active.editor == editor {
+                return;
+            }
+        }
+
+        let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
+            match event {
+                EditorEvent::Edited => {
+                    this.on_editor_edited(cx);
+                }
+                EditorEvent::SelectionsChanged { .. } => {
+                    let editor = editor.read(cx);
+                    let selection_range = editor.selections.last::<usize>(cx).range();
+                    this.selected_block = this.get_block_index_under_cursor(selection_range);
+                    this.list_state.scroll_to_reveal_item(this.selected_block);
+                    cx.notify();
+                }
+                _ => {}
+            };
+        });
+
+        self.tab_description = editor
+            .read(cx)
+            .tab_description(0, cx)
+            .map(|tab_description| format!("Preview {}", tab_description));
+
+        self.active_editor = Some(EditorState {
+            editor,
+            _subscription: subscription,
+        });
+
+        if let Some(state) = &self.active_editor {
+            self.parsing_markdown_task =
+                Some(self.parse_markdown_in_background(false, state.editor.clone(), cx));
+        }
+    }
+
+    fn on_editor_edited(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(state) = &self.active_editor {
+            self.parsing_markdown_task =
+                Some(self.parse_markdown_in_background(true, state.editor.clone(), cx));
+        }
+    }
+
+    fn parse_markdown_in_background(
+        &mut self,
+        wait_for_debounce: bool,
+        editor: View<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        let language_registry = self.language_registry.clone();
+
+        cx.spawn(move |view, mut cx| async move {
+            if wait_for_debounce {
+                // Wait for the user to stop typing
+                cx.background_executor().timer(REPARSE_DEBOUNCE).await;
+            }
+
+            let (contents, file_location) = view.update(&mut cx, |_, cx| {
+                let editor = editor.read(cx);
+                let contents = editor.buffer().read(cx).snapshot(cx).text();
+                let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
+                (contents, file_location)
+            })?;
+
+            let parsing_task = cx.background_executor().spawn(async move {
+                parse_markdown(&contents, file_location, Some(language_registry)).await
+            });
+            let contents = parsing_task.await;
+            view.update(&mut cx, move |view, cx| {
+                let markdown_blocks_count = contents.children.len();
+                view.contents = Some(contents);
+                let scroll_top = view.list_state.logical_scroll_top();
+                view.list_state.reset(markdown_blocks_count);
+                view.list_state.scroll_to(scroll_top);
+                cx.notify();
+            })
         })
     }
 
+    fn move_cursor_to_block(&self, cx: &mut ViewContext<Self>, selection: Range<usize>) {
+        if let Some(state) = &self.active_editor {
+            state.editor.update(cx, |editor, cx| {
+                editor.change_selections(
+                    Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
+                    cx,
+                    |selections| selections.select_ranges(vec![selection]),
+                );
+                editor.focus(cx);
+            });
+        }
+    }
+
     /// The absolute path of the file that is currently being previewed.
     fn get_folder_for_active_editor(
         editor: &Editor,
@@ -246,7 +422,12 @@ impl Item for MarkdownPreviewView {
                 Color::Muted
             }))
             .child(
-                Label::new(self.tab_description.to_string()).color(if selected {
+                Label::new(if let Some(description) = &self.tab_description {
+                    description.clone().into()
+                } else {
+                    self.fallback_tab_description.clone()
+                })
+                .color(if selected {
                     Color::Default
                 } else {
                     Color::Muted

crates/workspace/src/workspace.rs 🔗

@@ -2267,7 +2267,7 @@ impl Workspace {
         }
     }
 
-    fn find_pane_in_direction(
+    pub fn find_pane_in_direction(
         &mut self,
         direction: SplitDirection,
         cx: &WindowContext,