diff --git a/Cargo.lock b/Cargo.lock index 609313363ec80bef1d686120eae4dfea975e0b11..0b9f0185aec26582ba3c49db3f130a76715ec96b 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index f3acdecd48e2fc2fc904a4dcce6233ff2be1df35..51e2f23567cf0e54cc8b2128d59e6ee59b0eec75 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/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 = 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); diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 82af756e13049834a7e49f519fe491861d9e7b4b..3a46d2b46df0ccd8a22cfafe7a91ba617effb85b 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/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 diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 47db011cef8681b2b05288931fae9f11a0812fdb..2ebc7406042c68edb002b5b06f7a6d794df8cca7 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -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(), }), }); diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index e29f977d716d9b5fc338488ab5418c1bcd44736d..7d6cf325179c4c48db27f1f8e9fd0b00564c584f 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/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| { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 10382e8bce6dcdf81710d9ee4704204ba319283c..0b91aaa22e81355f5ea0a0bc0e26f01b169f1bae 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/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, + active_editor: Option, focus_handle: FocusHandle, contents: Option, selected_block: usize, list_state: ListState, - tab_description: String, + tab_description: Option, + fallback_tab_description: SharedString, + language_registry: Arc, + parsing_markdown_task: Option>>, +} + +#[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, + _subscription: Subscription, } impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext) { 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::(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::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 { + pane.items_of_type::() + .nth(0) + .and_then(|view| pane.index_for_item(&view)) + } + + fn resolve_active_item_as_markdown_editor( + workspace: &Workspace, + cx: &mut ViewContext, + ) -> Option> { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + if Self::is_markdown_file(&editor, cx) { + return Some(editor); + } + } + None + } + + fn create_markdown_view( + workspace: &mut Workspace, + editor: View, + cx: &mut ViewContext, + ) -> View { + 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, workspace: WeakView, - tab_description: Option, language_registry: Arc, + fallback_description: Option, cx: &mut ViewContext, ) -> View { cx.new_view(|cx: &mut ViewContext| { 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::(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>, + cx: &mut ViewContext, + ) { + if let Some(item) = active_item { + if item.item_id() != cx.entity_id() { + if let Some(editor) = item.act_as::(cx) { + if Self::is_markdown_file(&editor, cx) { + self.set_editor(editor, cx); + } + } } + } + } + + fn is_markdown_file(editor: &View, cx: &mut ViewContext) -> 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, cx: &mut ViewContext) { + 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::(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) { + 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, + cx: &mut ViewContext, + ) -> Task> { + 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, selection: Range) { + 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 diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e2909593339a14fb79c190248b827cba32a30bc5..a882c435e947af20886436d818d0c0562ba7cf90 100644 --- a/crates/workspace/src/workspace.rs +++ b/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,