@@ -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