mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-28 09:54:33 +00:00
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:
parent
d009d84ead
commit
7dccbd8e3b
7 changed files with 295 additions and 109 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -270,7 +270,7 @@ impl<'a> MarkdownParser<'a> {
|
|||
regions.push(ParsedRegion {
|
||||
code: false,
|
||||
link: Some(Link::Web {
|
||||
url: t[range].to_string(),
|
||||
url: link.as_str().to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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();
|
||||
let rendered_block = render_markdown_block(block, &mut render_cx);
|
||||
|
||||
if ix == view.selected_block {
|
||||
let indicator = div()
|
||||
.h_full()
|
||||
.w(px(4.0))
|
||||
.bg(cx.theme().colors().border)
|
||||
.rounded_sm();
|
||||
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();
|
||||
|
||||
return div()
|
||||
.relative()
|
||||
.child(block)
|
||||
.child(indicator.absolute().left_0().top_0())
|
||||
.into_any();
|
||||
}
|
||||
|
||||
block.into_any()
|
||||
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
|
||||
|
|
|
@ -2267,7 +2267,7 @@ impl Workspace {
|
|||
}
|
||||
}
|
||||
|
||||
fn find_pane_in_direction(
|
||||
pub fn find_pane_in_direction(
|
||||
&mut self,
|
||||
direction: SplitDirection,
|
||||
cx: &WindowContext,
|
||||
|
|
Loading…
Reference in a new issue