project_panel: Add markdown preview context menu item (#57112)

Opening a markdown file from the project panel used to require four
steps:
- click the file (opens raw editor)
- right-click the editor tab
- select "Open Markdown Preview"
- then close the raw editor tab

This PR shortcuts that to a single right-click (or keypress) from the
project panel.

Right-clicking a markdown file in the project panel now shows an **Open
Markdown Preview** option. Selecting it opens the rendered markdown
preview directly — no raw editor tab is left behind. The same shortcut
used in the editor (Cmd+Shift+V on macOS, Ctrl+Shift+V on Windows/Linux)
also works when focus is in the project panel.

## How it works

- **Context menu**: `OpenMarkdownPreview` action is registered and
surfaced in the right-click menu only for `.md`/`.markdown` files.
- **Keybinding**: `cmd-shift-v` / `ctrl-shift-v` bound to
`project_panel::OpenMarkdownPreview` in the `ProjectPanel` context,
mirroring the existing editor binding.
- **Loading**: The file is opened via `open_path_preview` with
`focus_item: false, activate: false`, so the raw editor is loaded into
the buffer system but never becomes the visible tab.
- **Preview construction**: `MarkdownPreviewView::create_markdown_view`
is called directly (no `dispatch_action` indirection, which would have
been deferred and caused a race). The preview is added to the active
pane and the raw editor tab is removed atomically in one synchronous
pane update.
- **Pre-existing tabs**: Before loading, all panes are checked for an
existing item at the file's project path. If the file is already open in
raw mode, `remove_item` is skipped — the existing tab is left untouched.

Release Notes:

- Added "Open Markdown Preview" context menu item to Project Panel
markdown entries.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
Co-authored-by: Tom Houlé <tom@tomhoule.com>
This commit is contained in:
Eli Stark 2026-06-26 08:30:51 -04:00 committed by GitHub
parent eb87750323
commit db30c67ed2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 76 additions and 2 deletions

1
Cargo.lock generated
View file

@ -14057,6 +14057,7 @@ dependencies = [
"gpui",
"itertools 0.14.0",
"language",
"markdown_preview",
"menu",
"notifications",
"pretty_assertions",

View file

@ -28,6 +28,7 @@ use ui::utils::WithRemSize;
use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu};
use util::markdown::split_local_url_fragment;
use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem};
use workspace::notifications::NotifyResultExt;
use workspace::searchable::{
Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
};
@ -208,7 +209,7 @@ impl MarkdownPreviewView {
None
}
fn create_markdown_view(
pub fn create_markdown_view(
workspace: &mut Workspace,
editor: Entity<Editor>,
window: &mut Window,
@ -337,6 +338,43 @@ impl MarkdownPreviewView {
}
}
pub fn is_markdown_path(path: impl AsRef<Path>) -> bool {
path.as_ref().extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown")
})
}
pub fn open_for_project_path(
project_path: ProjectPath,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let open_buffer = workspace
.project()
.update(cx, |project, cx| project.open_buffer(project_path, cx));
cx.spawn_in(window, async move |workspace, mut cx| {
let Some(buffer) = open_buffer
.await
.notify_workspace_async_err(workspace.clone(), &mut cx)
else {
return;
};
workspace
.update_in(cx, |workspace, window, cx| {
let project = workspace.project().clone();
let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
let preview = Self::create_markdown_view(workspace, editor, window, cx);
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(Box::new(preview), true, true, None, window, cx);
});
})
.ok();
})
.detach();
}
pub fn is_markdown_file<V>(editor: &Entity<Editor>, cx: &mut Context<V>) -> bool {
let buffer = editor.read(cx).buffer().read(cx);
if let Some(buffer) = buffer.as_singleton()

View file

@ -45,6 +45,7 @@ client.workspace = true
worktree.workspace = true
workspace.workspace = true
language.workspace = true
markdown_preview.workspace = true
zed_actions.workspace = true
telemetry.workspace = true
notifications.workspace = true

View file

@ -30,6 +30,7 @@ use gpui::{
point, px, size, transparent_white, uniform_list,
};
use language::DiagnosticSeverity;
use markdown_preview::markdown_preview_view::MarkdownPreviewView;
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use notifications::status_toast::StatusToast;
use project::{
@ -405,6 +406,8 @@ actions!(
Undo,
/// Redoes the last undone file operation.
Redo,
/// Opens a markdown preview for the selected file.
OpenMarkdownPreview,
]
);
@ -1091,6 +1094,7 @@ impl ProjectPanel {
let is_remote = project.is_remote();
let is_collab = project.is_via_collab();
let is_local = project.is_local() || project.is_via_wsl_with_host_interop(cx);
let is_markdown = !is_dir && MarkdownPreviewView::is_markdown_path(&*entry.path);
let settings = ProjectPanelSettings::get_global(cx);
let visible_worktrees_count = project.visible_worktrees(cx).count();
@ -1120,7 +1124,10 @@ impl ProjectPanel {
let context_menu = ContextMenu::build(window, cx, |menu, _, cx| {
menu.context(self.focus_handle.clone()).map(|menu| {
if is_read_only {
menu.when(is_dir, |menu| {
menu.when(is_markdown, |menu| {
menu.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
})
.when(is_dir, |menu| {
menu.action("Search Inside", Box::new(NewSearchInDirectory))
})
} else {
@ -1137,6 +1144,9 @@ impl ProjectPanel {
menu.action("Open in Default App", Box::new(OpenWithSystem))
})
.action("Open in Terminal", Box::new(OpenInTerminal))
.when(is_markdown, |menu| {
menu.action("Open Markdown Preview", Box::new(OpenMarkdownPreview))
})
.when(is_dir, |menu| {
menu.separator()
.action("Find in Folder…", Box::new(NewSearchInDirectory))
@ -1685,6 +1695,29 @@ impl ProjectPanel {
);
}
fn open_markdown_preview(
&mut self,
_: &OpenMarkdownPreview,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((worktree, entry)) = self.selected_entry(cx) else {
return;
};
if !entry.is_file() || !MarkdownPreviewView::is_markdown_path(&*entry.path) {
return;
}
let project_path = ProjectPath {
worktree_id: worktree.id(),
path: entry.path.clone(),
};
self.workspace
.update(cx, |workspace, cx| {
MarkdownPreviewView::open_for_project_path(project_path, workspace, window, cx);
})
.ok();
}
fn open_internal(
&mut self,
allow_preview: bool,
@ -6769,6 +6802,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::open_permanent))
.on_action(cx.listener(Self::open_split_vertical))
.on_action(cx.listener(Self::open_split_horizontal))
.on_action(cx.listener(Self::open_markdown_preview))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::copy_path))