Add Markdown Preview Toggle (#15215)

Nate Butler created

Add a "Preview Markdown" button to the quick action bar when in a
markdown editor.

While it isn't my favorite, I went with the basic eye icon to be a bit
more generic so we can extend this control to allow opening other
previews such as SVGs like @jansol mentioned.

![CleanShot 2024-07-26 at 11 02
16@2x](https://github.com/user-attachments/assets/415963ce-d19e-432d-b8c2-37e7c6e52683)


https://github.com/user-attachments/assets/5980272c-eab9-4f69-86b6-0c593c25b525

---

Release Notes:

- Added a button to preview Markdown files in the toolbar.
`Option|Alt+Click` will open the preview to the side.

Change summary

Cargo.lock                                             |  1 
assets/icons/eye.svg                                   |  1 
assets/icons/file_code.svg                             |  1 
assets/icons/file_text.svg                             |  1 
crates/markdown_preview/src/markdown_preview_view.rs   |  4 
crates/quick_action_bar/Cargo.toml                     |  3 
crates/quick_action_bar/src/quick_action_bar.rs        | 30 ++--
crates/quick_action_bar/src/toggle_markdown_preview.rs | 61 ++++++++++++
crates/ui/src/components/icon.rs                       |  6 +
9 files changed, 90 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8270,6 +8270,7 @@ dependencies = [
  "assistant",
  "editor",
  "gpui",
+ "markdown_preview",
  "repl",
  "search",
  "settings",

assets/icons/eye.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>

assets/icons/file_code.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code"><path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/></svg>

assets/icons/file_text.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -99,7 +99,7 @@ impl MarkdownPreviewView {
             .and_then(|view| pane.index_for_item(&view))
     }
 
-    fn resolve_active_item_as_markdown_editor(
+    pub fn resolve_active_item_as_markdown_editor(
         workspace: &Workspace,
         cx: &mut ViewContext<Workspace>,
     ) -> Option<View<Editor>> {
@@ -278,7 +278,7 @@ impl MarkdownPreviewView {
         }
     }
 
-    fn is_markdown_file<V>(editor: &View<Editor>, cx: &mut ViewContext<V>) -> bool {
+    pub 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")

crates/quick_action_bar/Cargo.toml 🔗

@@ -16,12 +16,13 @@ doctest = false
 assistant.workspace = true
 editor.workspace = true
 gpui.workspace = true
+markdown_preview.workspace = true
+repl.workspace = true
 search.workspace = true
 settings.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
-repl.workspace = true
 zed_actions.workspace = true
 
 [dev-dependencies]

crates/quick_action_bar/src/quick_action_bar.rs 🔗

@@ -21,16 +21,18 @@ use workspace::{
 };
 
 mod repl_menu;
+mod toggle_markdown_preview;
 
 pub struct QuickActionBar {
+    _inlay_hints_enabled_subscription: Option<Subscription>,
+    active_item: Option<Box<dyn ItemHandle>>,
     buffer_search_bar: View<BufferSearchBar>,
+    platform_style: PlatformStyle,
     repl_menu: Option<View<ContextMenu>>,
-    toggle_settings_menu: Option<View<ContextMenu>>,
+    show: bool,
     toggle_selections_menu: Option<View<ContextMenu>>,
-    active_item: Option<Box<dyn ItemHandle>>,
-    _inlay_hints_enabled_subscription: Option<Subscription>,
+    toggle_settings_menu: Option<View<ContextMenu>>,
     workspace: WeakView<Workspace>,
-    show: bool,
 }
 
 impl QuickActionBar {
@@ -40,14 +42,15 @@ impl QuickActionBar {
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let mut this = Self {
+            _inlay_hints_enabled_subscription: None,
+            active_item: None,
             buffer_search_bar,
-            toggle_settings_menu: None,
-            toggle_selections_menu: None,
+            platform_style: PlatformStyle::platform(),
             repl_menu: None,
-            active_item: None,
-            _inlay_hints_enabled_subscription: None,
-            workspace: workspace.weak_handle(),
             show: true,
+            toggle_selections_menu: None,
+            toggle_settings_menu: None,
+            workspace: workspace.weak_handle(),
         };
         this.apply_settings(cx);
         cx.observe_global::<SettingsStore>(|this, cx| this.apply_settings(cx))
@@ -300,22 +303,19 @@ impl Render for QuickActionBar {
 
         h_flex()
             .id("quick action bar")
-            .gap(Spacing::Large.rems(cx))
+            .gap(Spacing::XXLarge.rems(cx))
             .child(
                 h_flex()
                     .gap(Spacing::Medium.rems(cx))
                     .children(self.render_repl_menu(cx))
+                    .children(self.render_toggle_markdown_preview(self.workspace.clone(), cx))
+                    .children(search_button)
                     .when(
                         AssistantSettings::get_global(cx).enabled
                             && AssistantSettings::get_global(cx).button,
                         |bar| bar.child(assistant_button),
                     ),
             )
-            .child(
-                h_flex()
-                    .gap(Spacing::Medium.rems(cx))
-                    .children(search_button),
-            )
             .child(
                 h_flex()
                     .gap(Spacing::Medium.rems(cx))

crates/quick_action_bar/src/toggle_markdown_preview.rs 🔗

@@ -0,0 +1,61 @@
+use gpui::{AnyElement, WeakView};
+use markdown_preview::{
+    markdown_preview_view::MarkdownPreviewView, OpenPreview, OpenPreviewToTheSide,
+};
+use ui::{prelude::*, IconButtonShape, Tooltip};
+use workspace::Workspace;
+
+use crate::QuickActionBar;
+
+impl QuickActionBar {
+    pub fn render_toggle_markdown_preview(
+        &self,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement> {
+        let mut active_editor_is_markdown = false;
+
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                active_editor_is_markdown =
+                    MarkdownPreviewView::resolve_active_item_as_markdown_editor(workspace, cx)
+                        .is_some();
+            });
+        }
+
+        if !active_editor_is_markdown {
+            return None;
+        }
+
+        let tooltip_meta = match self.platform_style {
+            PlatformStyle::Mac => "Option+Click to open in a split",
+            _ => "Alt+Click to open in a split",
+        };
+
+        let button = IconButton::new("toggle-markdown-preview", IconName::Eye)
+            .shape(IconButtonShape::Square)
+            .icon_size(IconSize::Small)
+            .style(ButtonStyle::Subtle)
+            .tooltip(move |cx| {
+                Tooltip::with_meta(
+                    "Preview Markdown",
+                    Some(&markdown_preview::OpenPreview),
+                    tooltip_meta,
+                    cx,
+                )
+            })
+            .on_click(move |_, cx| {
+                if let Some(workspace) = workspace.upgrade() {
+                    workspace.update(cx, |_, cx| {
+                        if cx.modifiers().alt {
+                            cx.dispatch_action(Box::new(OpenPreviewToTheSide));
+                        } else {
+                            cx.dispatch_action(Box::new(OpenPreview));
+                        }
+                    });
+                }
+            });
+
+        Some(button.into_any_element())
+    }
+}

crates/ui/src/components/icon.rs 🔗

@@ -158,6 +158,7 @@ pub enum IconName {
     Exit,
     ExpandVertical,
     ExternalLink,
+    Eye,
     File,
     FileDoc,
     FileGeneric,
@@ -166,6 +167,8 @@ pub enum IconName {
     FileRust,
     FileToml,
     FileTree,
+    FileText,
+    FileCode,
     Filter,
     Folder,
     FolderOpen,
@@ -309,6 +312,7 @@ impl IconName {
             IconName::Exit => "icons/exit.svg",
             IconName::ExpandVertical => "icons/expand_vertical.svg",
             IconName::ExternalLink => "icons/external_link.svg",
+            IconName::Eye => "icons/eye.svg",
             IconName::File => "icons/file.svg",
             IconName::FileDoc => "icons/file_icons/book.svg",
             IconName::FileGeneric => "icons/file_icons/file.svg",
@@ -317,6 +321,8 @@ impl IconName {
             IconName::FileRust => "icons/file_icons/rust.svg",
             IconName::FileToml => "icons/file_icons/toml.svg",
             IconName::FileTree => "icons/project.svg",
+            IconName::FileCode => "icons/file_code.svg",
+            IconName::FileText => "icons/file_text.svg",
             IconName::Filter => "icons/filter.svg",
             IconName::Folder => "icons/file_icons/folder.svg",
             IconName::FolderOpen => "icons/file_icons/folder_open.svg",