From f233ed4687aa79cfa4679bd0d953bf879bbce7bb Mon Sep 17 00:00:00 2001 From: Nils Petersohn Date: Mon, 13 Jul 2026 00:11:56 +0200 Subject: [PATCH] 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 --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 3 +- assets/keymaps/default-windows.json | 1 + .../markdown_preview/src/markdown_preview.rs | 4 +- .../src/markdown_preview_view.rs | 219 +++++++++++++++++- 5 files changed, 223 insertions(+), 5 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4c3507b746..0e9ec9eda7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 012314c9a2..e1f30d0a03 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d9c01221c7..0e3932a7db 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", }, diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index caf516b212..0734b4c501 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -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 ] ); diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 631b9511f8..d8cfc4f26c 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -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, + ) { + 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> { @@ -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::(cx) + .all(|open_editor| open_editor != editor), + "source editor should no longer be open in any pane" + ); + assert_eq!( + workspace.items_of_type::(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, Entity) { + 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::().unwrap()); + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .read(cx) + .active_item_as::(cx) + .unwrap() + }) + .unwrap(); + (multi_workspace, editor) + } + + fn open_preview_for_active_editor( + cx: &mut TestAppContext, + multi_workspace: &WindowHandle, + ) -> Entity { + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let editor: Entity = 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, + preview: &Entity, + ) { + 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, + expected_editor: &Entity, + ) { + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().read(cx); + let active_editor = workspace.active_item_as::(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, + ) { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert_eq!( + multi_workspace + .workspace() + .read(cx) + .items_of_type::(cx) + .count(), + 0 + ); + }) + .unwrap(); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx);