markdown preview: Improve live preview (#10205)

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
This commit is contained in:
Bennet Bo Fenner 2024-04-08 21:17:40 +02:00 committed by GitHub
parent d009d84ead
commit 7dccbd8e3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 295 additions and 109 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -270,7 +270,7 @@ impl<'a> MarkdownParser<'a> {
regions.push(ParsedRegion { regions.push(ParsedRegion {
code: false, code: false,
link: Some(Link::Web { link: Some(Link::Web {
url: t[range].to_string(), url: link.as_str().to_string(),
}), }),
}); });

View file

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

View file

@ -1,16 +1,21 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use std::{ops::Range, path::PathBuf}; use std::{ops::Range, path::PathBuf};
use anyhow::Result;
use editor::scroll::{Autoscroll, AutoscrollStrategy};
use editor::{Editor, EditorEvent}; use editor::{Editor, EditorEvent};
use gpui::{ use gpui::{
list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, list, AnyElement, AppContext, ClickEvent, EventEmitter, FocusHandle, FocusableView,
IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView, InteractiveElement, IntoElement, ListState, ParentElement, Render, Styled, Subscription, Task,
View, ViewContext, WeakView,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use ui::prelude::*; use ui::prelude::*;
use workspace::item::{Item, ItemHandle}; use workspace::item::{Item, ItemHandle};
use workspace::Workspace; use workspace::{Pane, Workspace};
use crate::OpenPreviewToTheSide;
use crate::{ use crate::{
markdown_elements::ParsedMarkdown, markdown_elements::ParsedMarkdown,
markdown_parser::parse_markdown, markdown_parser::parse_markdown,
@ -18,109 +23,123 @@ use crate::{
OpenPreview, OpenPreview,
}; };
const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
pub struct MarkdownPreviewView { pub struct MarkdownPreviewView {
workspace: WeakView<Workspace>, workspace: WeakView<Workspace>,
active_editor: Option<EditorState>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
contents: Option<ParsedMarkdown>, contents: Option<ParsedMarkdown>,
selected_block: usize, selected_block: usize,
list_state: ListState, 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 { impl MarkdownPreviewView {
pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) { pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
workspace.register_action(move |workspace, _: &OpenPreview, cx| { workspace.register_action(move |workspace, _: &OpenPreview, cx| {
if workspace.has_active_modal(cx) { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
cx.propagate(); let view = Self::create_markdown_view(workspace, editor, cx);
return; 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) { workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, cx| {
let language_registry = workspace.project().read(cx).languages().clone(); if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
let workspace_handle = workspace.weak_handle(); let view = Self::create_markdown_view(workspace, editor.clone(), cx);
let tab_description = editor.tab_description(0, cx); let pane = workspace
let view: View<MarkdownPreviewView> = MarkdownPreviewView::new( .find_pane_in_direction(workspace::SplitDirection::Right, cx)
editor, .unwrap_or_else(|| {
workspace_handle, workspace.split_pane(
tab_description, workspace.active_pane().clone(),
language_registry, workspace::SplitDirection::Right,
cx, cx,
); )
workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), 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(); 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( pub fn new(
mode: MarkdownPreviewMode,
active_editor: View<Editor>, active_editor: View<Editor>,
workspace: WeakView<Workspace>, workspace: WeakView<Workspace>,
tab_description: Option<SharedString>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
fallback_description: Option<SharedString>,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) -> View<Self> { ) -> View<Self> {
cx.new_view(|cx: &mut ViewContext<Self>| { cx.new_view(|cx: &mut ViewContext<Self>| {
let view = cx.view().downgrade(); 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 = let list_state =
ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| {
@ -132,43 +151,200 @@ impl MarkdownPreviewView {
let mut render_cx = let mut render_cx =
RenderContext::new(Some(view.workspace.clone()), cx); RenderContext::new(Some(view.workspace.clone()), cx);
let block = contents.children.get(ix).unwrap(); let block = contents.children.get(ix).unwrap();
let block = render_markdown_block(block, &mut render_cx); let rendered_block = render_markdown_block(block, &mut render_cx);
let block = div().child(block).pl_4().pb_3();
if ix == view.selected_block { 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() let indicator = div()
.h_full() .h_full()
.w(px(4.0)) .w(px(4.0))
.bg(cx.theme().colors().border) .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(); .rounded_sm();
return div() this.child(
div()
.relative() .relative()
.child(block) .child(div().pl_4().child(rendered_block))
.child(indicator.absolute().left_0().top_0()) .child(indicator.absolute().left_0().top_0()),
.into_any(); )
} })
.into_any()
block.into_any()
}) })
} else { } else {
div().into_any() div().into_any()
} }
}); });
let tab_description = tab_description let mut this = Self {
.map(|tab_description| format!("Preview {}", tab_description))
.unwrap_or("Markdown preview".to_string());
Self {
selected_block: 0, selected_block: 0,
active_editor: None,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
workspace, workspace: workspace.clone(),
contents: None, contents: None,
list_state, 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. /// The absolute path of the file that is currently being previewed.
@ -246,7 +422,12 @@ impl Item for MarkdownPreviewView {
Color::Muted Color::Muted
})) }))
.child( .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 Color::Default
} else { } else {
Color::Muted Color::Muted

View file

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