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);