feature: toggle markdown preview and source (#60867)

Toggle Markdown Preview with the Existing Preview Shortcut
 
## Context

Markdown files already expose actions for opening a rendered preview in
the current pane or to the side. The current-pane preview action focuses
the preview, but there was no matching keyboard path from the preview
back to the source editor.

Users expect the existing preview shortcut to behave like a mode toggle:
press it from the editor to preview, then press it again from the
preview to return to editing. The existing preview view keeps a handle
to its source editor, so the preview can return focus to that editor
without adding a broader workspace-level toggle.

## Feature

Add a markdown-preview-scoped action,
`markdown::CloseAndReturnToEditor`, and bind the existing preview
shortcut to complementary actions:

- In a markdown editor, `ctrl-shift-v` on Linux and Windows, or
`cmd-shift-v` on macOS, dispatches `markdown::OpenPreview`.
- In a markdown preview, the same platform shortcut dispatches
`markdown::CloseAndReturnToEditor`.

The close action first activates and focuses the source editor. If the
source editor entity is no longer open in any pane, the action adds it
back to the active pane before closing the preview. The preview is
closed with `SaveIntent::Skip` because the preview is not the owner of
source-buffer changes.

Existing markdown preview shortcuts remain available.

## Alternatives

- Bind `escape` to close the preview. This is easy to discover, but it
does not provide a symmetric editor/preview toggle and can overlap with
transient UI such as search.
- Add a new dedicated toggle chord, such as `ctrl-alt-v` / `cmd-alt-v`.
This avoids changing the preview shortcut's meaning in the preview
context, but it makes users learn a second shortcut for the same
preview/edit workflow.
- Teach `markdown::OpenPreview` to toggle based on the active item. That
would mix editor-scoped preview creation with preview-scoped close
behavior and make the action depend on more workspace state.

## Consequences

- The toggle behavior is implemented by context-specific keybindings
rather than one action that changes meaning globally.
- Returning to edit mode works even when the original editor tab has
been closed while the preview remains open.
- The preview close path has focused GPUI tests because it relies on
pane activation, focus restoration, and asynchronous item closing.

Release Notes:

- Add a markdown action to close the current preview and focus the
related source file
This commit is contained in:
Nils Petersohn authored and GitHub committed 2026-07-12 22:11:56 +00:00
1 parent 5d41d8f0b7
commit f233ed4687
5 files changed
+223 -5

No files matched your search

+1
View File
@@ -1325,6 +1325,7 @@
"alt-down": "markdown::ScrollDownByItem",
"ctrl-home": "markdown::ScrollToTop",
"ctrl-end": "markdown::ScrollToBottom",
"ctrl-shift-v": "markdown::CloseAndReturnToEditor",
"find": "buffer_search::Deploy",
"ctrl-f": "buffer_search::Deploy",
},
+2 -1
View File
@@ -650,7 +650,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-k v": "markdown::OpenPreviewToTheSide",
"cmd-shift-v": "markdown::OpenPreview",
"cmd-shift-v": "markdown::OpenPreview"
},
},
{
@@ -1416,6 +1416,7 @@
"alt-down": "markdown::ScrollDownByItem",
"cmd-up": "markdown::ScrollToTop",
"cmd-down": "markdown::ScrollToBottom",
"cmd-shift-v": "markdown::CloseAndReturnToEditor",
"cmd-f": "buffer_search::Deploy",
},
},
+1
View File
@@ -1347,6 +1347,7 @@
"alt-down": "markdown::ScrollDownByItem",
"ctrl-home": "markdown::ScrollToTop",
"ctrl-end": "markdown::ScrollToBottom",
"ctrl-shift-v": "markdown::CloseAndReturnToEditor",
"find": "buffer_search::Deploy",
"ctrl-f": "buffer_search::Deploy",
},
@@ -30,7 +30,9 @@ actions!(
/// Scrolls to the bottom of the markdown preview.
ScrollToBottom,
/// Opens a following markdown preview that syncs with the editor.
OpenFollowingPreview
OpenFollowingPreview,
/// Closes the markdown preview and returns focus to the source editor.
CloseAndReturnToEditor
]
);
@@ -32,12 +32,13 @@ use workspace::notifications::NotifyResultExt;
use workspace::searchable::{
Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
};
use workspace::{ItemId, Pane, Workspace, WorkspaceId, delete_unloaded_items};
use workspace::{ItemId, Pane, SaveIntent, Workspace, WorkspaceId, delete_unloaded_items};
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
use crate::markdown_preview_settings::MarkdownPreviewSettings;
use crate::{
OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem,
CloseAndReturnToEditor, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown,
ScrollDownByItem,
};
use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
@@ -806,6 +807,40 @@ impl MarkdownPreviewView {
cx.notify();
}
fn close_and_return_to_editor(
&mut self,
_: &CloseAndReturnToEditor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(editor) = self
.active_editor
.as_ref()
.map(|state| state.editor.clone())
else {
return;
};
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let preview_id = cx.entity_id();
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
if !workspace.activate_item(&editor, true, true, window, cx) {
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
}
if let Some(pane) = workspace.pane_for_item_id(preview_id) {
pane.update(cx, |pane, cx| {
pane.close_item_by_id(preview_id, SaveIntent::Skip, window, cx)
})
.detach_and_log_err(cx);
}
});
});
}
/// Returns the theme chosen in `markdown_preview_theme`, or `None` if the
/// user hasn't set one or it can't be resolved.
fn resolve_preview_theme(&self, cx: &App) -> Option<Arc<Theme>> {
@@ -1246,6 +1281,7 @@ impl Render for MarkdownPreviewView {
.on_action(cx.listener(MarkdownPreviewView::scroll_down_by_item))
.on_action(cx.listener(MarkdownPreviewView::scroll_to_top))
.on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
.on_action(cx.listener(MarkdownPreviewView::close_and_return_to_editor))
.on_action(cx.listener(MarkdownPreviewView::increase_font_size))
.on_action(cx.listener(MarkdownPreviewView::decrease_font_size))
.on_action(cx.listener(MarkdownPreviewView::reset_font_size))
@@ -1626,12 +1662,13 @@ mod persistence {
#[cfg(test)]
mod tests {
use crate::CloseAndReturnToEditor;
use crate::markdown_preview_view::ImageSource;
use crate::markdown_preview_view::Resource;
use crate::markdown_preview_view::resolve_preview_image;
use buffer_diff::BufferDiff;
use editor::Editor;
use gpui::{AppContext as _, Entity, TestAppContext};
use gpui::{AppContext as _, Entity, Focusable as _, TestAppContext, WindowHandle};
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
@@ -1913,6 +1950,69 @@ mod tests {
);
}
#[gpui::test]
async fn close_and_return_to_editor_closes_preview_and_focuses_source_editor(
cx: &mut TestAppContext,
) {
let (multi_workspace, editor) =
open_markdown_file(cx, "note.md", "# Note\n\nBody text\n").await;
let preview = open_preview_for_active_editor(cx, &multi_workspace);
cx.run_until_parked();
dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview);
cx.run_until_parked();
assert_editor_is_active_and_focused(cx, &multi_workspace, &editor);
assert_no_markdown_preview_items(cx, &multi_workspace);
}
#[gpui::test]
async fn close_and_return_to_editor_reopens_source_editor_when_editor_tab_was_closed(
cx: &mut TestAppContext,
) {
let (multi_workspace, editor) = open_markdown_file(cx, "note.md", "# Note\n").await;
let preview = open_preview_for_active_editor(cx, &multi_workspace);
cx.run_until_parked();
let close_editor_task = multi_workspace
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_item_by_id(editor.entity_id(), SaveIntent::Skip, window, cx)
})
})
})
.unwrap();
close_editor_task.await.unwrap();
cx.run_until_parked();
multi_workspace
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().read(cx);
assert!(
preview.read(cx).focus_handle.contains_focused(window, cx),
"preview should remain focused after closing the source editor tab"
);
assert!(
workspace
.items_of_type::<Editor>(cx)
.all(|open_editor| open_editor != editor),
"source editor should no longer be open in any pane"
);
assert_eq!(
workspace.items_of_type::<MarkdownPreviewView>(cx).count(),
1
);
})
.unwrap();
dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview);
cx.run_until_parked();
assert_editor_is_active_and_focused(cx, &multi_workspace, &editor);
assert_no_markdown_preview_items(cx, &multi_workspace);
}
#[gpui::test]
async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) {
let app_state = init_test(cx);
@@ -2260,6 +2360,119 @@ mod tests {
);
}
async fn open_markdown_file(
cx: &mut TestAppContext,
file_name: &str,
contents: &str,
) -> (WindowHandle<MultiWorkspace>, Entity<Editor>) {
let app_state = init_test(cx);
let mut entries = serde_json::Map::new();
entries.insert(file_name.to_string(), json!(contents));
app_state
.fs
.as_fake()
.insert_tree(path!("/dir"), serde_json::Value::Object(entries))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir")).join(file_name)],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
let editor = multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace
.workspace()
.read(cx)
.active_item_as::<Editor>(cx)
.unwrap()
})
.unwrap();
(multi_workspace, editor)
}
fn open_preview_for_active_editor(
cx: &mut TestAppContext,
multi_workspace: &WindowHandle<MultiWorkspace>,
) -> Entity<MarkdownPreviewView> {
multi_workspace
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
let editor: Entity<Editor> = workspace.active_item_as(cx).unwrap();
let preview =
MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx);
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(Box::new(preview.clone()), true, true, None, window, cx)
});
preview
})
})
.unwrap()
}
fn dispatch_close_and_return_to_editor(
cx: &mut TestAppContext,
multi_workspace: &WindowHandle<MultiWorkspace>,
preview: &Entity<MarkdownPreviewView>,
) {
multi_workspace
.update(cx, |_, window, cx| {
assert!(
preview.read(cx).focus_handle.contains_focused(window, cx),
"preview must be focused for the keyboard action to dispatch to it"
);
window.dispatch_action(Box::new(CloseAndReturnToEditor), cx);
})
.unwrap();
}
fn assert_editor_is_active_and_focused(
cx: &mut TestAppContext,
multi_workspace: &WindowHandle<MultiWorkspace>,
expected_editor: &Entity<Editor>,
) {
multi_workspace
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().read(cx);
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
assert_eq!(active_editor, *expected_editor);
assert!(
expected_editor
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx),
"source editor should be focused"
);
})
.unwrap();
}
fn assert_no_markdown_preview_items(
cx: &mut TestAppContext,
multi_workspace: &WindowHandle<MultiWorkspace>,
) {
multi_workspace
.update(cx, |multi_workspace, _, cx| {
assert_eq!(
multi_workspace
.workspace()
.read(cx)
.items_of_type::<MarkdownPreviewView>(cx)
.count(),
0
);
})
.unwrap();
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);