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:
1 parent
5d41d8f0b7
commit
f233ed4687
5 files changed
+223
-5
No files matched your search
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user