diff --git a/assets/settings/default.json b/assets/settings/default.json index 38ba01b388..439e82943f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -79,12 +79,6 @@ "agent_buffer_font_size": 12, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, - // The default font size for the markdown preview. Falls back to the UI font size if unset. - "markdown_preview_font_size": null, - // The font family for the markdown preview. Falls back to the UI font family if unset. - "markdown_preview_font_family": null, - // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. - "markdown_preview_code_font_family": null, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -118,6 +112,14 @@ }, // Markdown preview settings "markdown_preview": { + // The default font size for the markdown preview. Falls back to the UI font size if unset. + "font_size": null, + // The font family for the markdown preview. Falls back to the UI font family if unset. + "font_family": null, + // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. + "code_font_family": null, + // Whether to automatically open Markdown files in the preview. + "open_markdown_files_in_preview": false, // Whether to limit the width of the rendered markdown content. When // enabled, content is constrained to `max_width` and centered // horizontally within the preview pane. diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 95e17cf30c..c8f10d3117 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -6836,7 +6836,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(16.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6864,7 +6864,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(20.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6925,7 +6925,7 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(14.0.into()); + settings.markdown_preview.get_or_insert_default().font_size = Some(14.0.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(1.5)); }); @@ -7130,7 +7130,7 @@ mod tests { /// Note that this does not reproduce the `WithRemSize` wrapper the real /// preview renders inside, so rem-derived lengths (such as the `rems(1.3)` /// prose leading) resolve against the default rem size rather than - /// `markdown_preview_font_size`. Code block metrics are unaffected, since + /// `markdown_preview.font_size`. Code block metrics are unaffected, since /// the code font size is set as absolute pixels. fn rendered_prose_and_code_line_heights( cx: &mut TestAppContext, @@ -7141,7 +7141,8 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(font_size.into()); + settings.markdown_preview.get_or_insert_default().font_size = + Some(font_size.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(buffer_line_height)); }); diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 69addd9034..662b3b95ff 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -34,6 +34,7 @@ zed_actions.workspace = true [dev-dependencies] buffer_diff.workspace = true +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } fs.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_preview_settings.rs b/crates/markdown_preview/src/markdown_preview_settings.rs index 94bd4ab597..ab22cd6476 100644 --- a/crates/markdown_preview/src/markdown_preview_settings.rs +++ b/crates/markdown_preview/src/markdown_preview_settings.rs @@ -4,6 +4,8 @@ use settings::{IntoGpui, RegisterSetting, Settings}; /// The settings for the markdown preview. #[derive(Clone, Copy, Debug, Default, RegisterSetting)] pub struct MarkdownPreviewSettings { + /// Whether to automatically open Markdown files in the preview. + pub open_markdown_files_in_preview: bool, /// The maximum width of the rendered markdown content, or `None` to render /// content edge to edge. pub max_width: Option, @@ -17,6 +19,9 @@ impl Settings for MarkdownPreviewSettings { } else { None }; - Self { max_width } + Self { + open_markdown_files_in_preview: content.open_markdown_files_in_preview.unwrap_or(false), + max_width, + } } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 2f18d914a3..0dcc13077f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,6 +1,7 @@ use std::any::TypeId; use std::borrow::Cow; use std::cmp::min; +use std::collections::HashSet; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -13,9 +14,10 @@ use editor::{ Editor, EditorEvent, EditorSettingsScrollbarProxy, MultiBufferOffset, SelectionEffects, }; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, - InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, - ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, + App, ClipboardItem, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, + ImageSource, InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, + RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, + Window, point, px, }; use language::{Buffer, LanguageRegistry}; use markdown::{ @@ -55,6 +57,12 @@ use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); +fn reset_persisted_font_size(settings: &mut settings::SettingsContent) { + if let Some(markdown_preview) = settings.markdown_preview.as_mut() { + markdown_preview.font_size = None; + } +} + pub struct MarkdownPreviewView { workspace: WeakEntity, active_editor: Option, @@ -102,6 +110,11 @@ struct EditorState { _subscription: Subscription, } +#[derive(Default)] +struct SuppressedAutoPreviews(HashSet); + +impl Global for SuppressedAutoPreviews {} + #[derive(Clone, Copy, Debug)] pub enum MarkdownPreviewEvent { SourceEditorChanged, @@ -114,7 +127,69 @@ enum PreviewLinkTarget { } impl MarkdownPreviewView { - pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { + pub fn register(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + cx.default_global::(); + let workspace_entity = cx.entity(); + cx.subscribe_in( + &workspace_entity, + window, + |workspace, _, event: &workspace::Event, window, cx| { + let workspace::Event::ItemAdded { item } = event else { + return; + }; + if cx + .global_mut::() + .0 + .remove(&item.item_id()) + { + return; + } + if !MarkdownPreviewSettings::get_global(cx).open_markdown_files_in_preview + || workspace.is_restoring() + { + return; + } + let Some(editor) = item.downcast::() else { + return; + }; + if !Self::is_markdown_editor(workspace, &editor, cx) { + return; + } + let Some(pane) = workspace.pane_for_item_id(item.item_id()) else { + return; + }; + let replaces_editor = !item.is_dirty(cx); + let replaces_temporary_editor = + replaces_editor && pane.read(cx).is_active_preview_item(item.item_id()); + let activate = workspace.active_pane() == &pane + && pane + .read(cx) + .active_item() + .is_some_and(|active_item| active_item.item_id() == item.item_id()); + let focus = editor.focus_handle(cx).contains_focused(window, cx); + let preview = Self::activate_or_add_preview( + workspace, + editor, + pane.clone(), + activate, + focus, + window, + cx, + ); + if !replaces_editor { + return; + } + pane.update(cx, |pane, cx| { + if replaces_temporary_editor { + pane.replace_preview_item_id(preview.entity_id(), window, cx); + } else { + pane.remove_item(item.item_id(), false, false, window, cx); + } + }); + }, + ) + .detach(); + workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { let pane = workspace.active_pane().clone(); @@ -162,7 +237,7 @@ impl MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { - Self::activate_or_add_preview(workspace, editor, pane, true, window, cx); + Self::activate_or_add_preview(workspace, editor, pane, true, true, window, cx); } pub fn open_preview_to_the_side_of_pane( @@ -173,7 +248,15 @@ impl MarkdownPreviewView { cx: &mut Context, ) { let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); - Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx); + Self::activate_or_add_preview( + workspace, + editor.clone(), + target_pane, + false, + false, + window, + cx, + ); editor.focus_handle(cx).focus(window, cx); } @@ -181,42 +264,46 @@ impl MarkdownPreviewView { workspace: &mut Workspace, editor: Entity, pane: Entity, + activate: bool, focus: bool, window: &mut Window, cx: &mut Context, - ) { - let existing_view_idx = + ) -> Entity { + let existing_view = Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx); - if let Some(existing_view_idx) = existing_view_idx { + let view = if let Some((existing_view, existing_view_idx)) = existing_view { pane.update(cx, |pane, cx| { - pane.activate_item(existing_view_idx, focus, focus, window, cx); + pane.activate_item(existing_view_idx, activate, focus, window, cx); }); + existing_view } else { let view = Self::create_markdown_view(workspace, editor, window, cx); pane.update(cx, |pane, cx| { - pane.add_item(Box::new(view), focus, focus, None, window, cx) + pane.add_item(Box::new(view.clone()), activate, focus, None, window, cx) }); - } + view + }; cx.notify(); + view } fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, cx: &App, - ) -> Option { + ) -> Option<(Entity, usize)> { let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; - pane.items_of_type::() - .find(|view| { - // Only look for independent (Default mode) previews, not Follow previews. - // Match by buffer entity rather than editor entity so the lookup survives - // workspace restoration, where the preview's bound editor may differ from - // the editor the user is currently invoking the action on even though both - // wrap the same source buffer. - view.read(cx).mode == MarkdownPreviewMode::Default - && view.read(cx).is_previewing(&target_buffer, cx) - }) - .and_then(|view| pane.index_for_item(&view)) + let view = pane.items_of_type::().find(|view| { + // Only look for independent (Default mode) previews, not Follow previews. + // Match by buffer entity rather than editor entity so the lookup survives + // workspace restoration, where the preview's bound editor may differ from + // the editor the user is currently invoking the action on even though both + // wrap the same source buffer. + view.read(cx).mode == MarkdownPreviewMode::Default + && view.read(cx).is_previewing(&target_buffer, cx) + })?; + let index = pane.index_for_item(&view)?; + Some((view, index)) } fn is_previewing(&self, buffer: &Entity, cx: &App) -> bool { @@ -433,6 +520,24 @@ impl MarkdownPreviewView { false } + fn is_markdown_editor(workspace: &Workspace, editor: &Entity, cx: &App) -> bool { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + return false; + }; + let buffer = buffer.read(cx); + if let Some(language) = buffer.language() { + return language.name() == "Markdown"; + } + let Some(file) = buffer.file() else { + return false; + }; + let languages = workspace.project().read(cx).languages().clone(); + let Some(markdown) = languages.available_language_for_name("Markdown") else { + return false; + }; + languages.language_for_file(file, None, cx) == Some(markdown.id()) + } + fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(active) = &self.active_editor && active.editor == editor @@ -466,11 +571,8 @@ impl MarkdownPreviewView { (index, focused) }); if let Some(selection_start) = selection_start { - this.sync_preview_to_source_index( - selection_start, - editor_is_focused, - cx, - ); + let reveal = editor_is_focused || this.focus_handle.is_focused(window); + this.sync_preview_to_source_index(selection_start, reveal, cx); cx.notify(); } } @@ -758,7 +860,7 @@ impl MarkdownPreviewView { }; update_settings_file(fs, cx, move |settings, cx| { let size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx) + delta; - settings.theme.markdown_preview_font_size = + settings.markdown_preview.get_or_insert_default().font_size = Some(f32::from(theme_settings::clamp_font_size(size)).into()); }); } else { @@ -780,7 +882,7 @@ impl MarkdownPreviewView { return; }; update_settings_file(fs, cx, move |settings, _| { - settings.theme.markdown_preview_font_size = None; + reset_persisted_font_size(settings); }); } else { theme_settings::reset_markdown_preview_font_size(cx); @@ -922,6 +1024,10 @@ impl MarkdownPreviewView { window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if !workspace.activate_item(&editor, true, true, window, cx) { + // Re-adding the source emits `ItemAdded`; suppress its automatic preview once. + cx.global_mut::() + .0 + .insert(editor.entity_id()); workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); } @@ -935,7 +1041,7 @@ impl MarkdownPreviewView { }); } - /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the + /// 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> { let theme_settings = ThemeSettings::get_global(cx); @@ -2092,7 +2198,9 @@ mod tests { use crate::markdown_preview_view::resolve_project_path_for_preview_image; use buffer_diff::BufferDiff; use editor::Editor; + use editor::items::open_resolved_target; use fs::FakeFs; + use gpui::UpdateGlobal as _; use gpui::{ App, AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, }; @@ -2103,15 +2211,19 @@ mod tests { use std::sync::Arc; use std::time::Duration; use util::path; - use util::paths::PathStyle; + use util::paths::{PathStyle, PathWithPosition}; use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; - use workspace::item::SerializableItem; + use workspace::item::{ItemHandle, SerializableItem}; + use workspace::path_link::{OpenTarget, OpenTargetFoundBy}; use workspace::{ - AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, + AppState, ItemId, MultiWorkspace, Pane, SaveIntent, Workspace, WorkspaceId, open_paths, }; - use super::{MarkdownPreviewView, filter_non_rendered_matches, open_preview_url}; + use super::{ + MarkdownPreviewView, filter_non_rendered_matches, open_preview_url, + reset_persisted_font_size, + }; #[test] fn filters_matches_in_non_rendered_link_source() { @@ -2134,6 +2246,32 @@ mod tests { ); } + #[gpui::test] + fn resetting_persisted_font_size_preserves_unrelated_settings(cx: &mut App) { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + let without_markdown_preview = "{\n \"buffer_font_size\": 14\n}\n".to_string(); + assert_eq!( + cx.global::() + .new_text_for_update(without_markdown_preview.clone(), reset_persisted_font_size) + .unwrap(), + without_markdown_preview + ); + + let updated = cx + .global::() + .new_text_for_update( + r#"{"markdown_preview":{"font_size":18,"open_markdown_files_in_preview":true}}"# + .into(), + reset_persisted_font_size, + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&updated).unwrap(), + json!({"markdown_preview": {"open_markdown_files_in_preview": true}}) + ); + } + #[gpui::test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing(cx: &mut App) { let tree = TempTree::new(json!({ @@ -3044,6 +3182,34 @@ mod tests { assert_no_markdown_preview_items(cx, &multi_workspace); } + #[gpui::test] + async fn close_and_return_to_editor_returns_to_source_when_auto_preview_is_enabled( + cx: &mut TestAppContext, + ) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + open_project_file(cx, &project, &multi_workspace, "note.md", None, true).await; + + let preview = cx.update(|cx| { + assert_eq!(workspace.read(cx).items_of_type::(cx).count(), 0); + workspace + .read(cx) + .active_item_as::(cx) + .unwrap() + }); + let editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + + 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); @@ -3504,6 +3670,377 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn opens_markdown_files_in_preview_when_enabled(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "automatic.md": "# Automatic\n", + "plain.txt": "Plain text\n", + "targeted.md": "# Targeted\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + let automatic_editor = + open_project_file(cx, &project, &multi_workspace, "automatic.md", None, true).await; + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), automatic_editor.as_ref(), cx); + }); + + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true).await; + cx.update(|cx| { + assert!(workspace.read(cx).active_item_as::(cx).is_some()); + }); + + let (target_pane, other_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let target_pane = workspace.active_pane().clone(); + let other_pane = workspace.split_pane( + target_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + (target_pane, other_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "targeted.md", + Some(&target_pane), + true, + ) + .await; + let preview = cx.update(|cx| { + let preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("the Markdown preview should open in the requested pane"); + assert_eq!( + other_pane + .read(cx) + .items_of_type::() + .count(), + 0 + ); + preview + }); + assert_eq!( + preview_source_path(cx, &preview).as_ref(), + rel_path("targeted.md") + ); + } + + #[gpui::test] + async fn first_markdown_file_opens_in_preview_before_language_load(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), true).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &editor, cx); + }); + } + + #[gpui::test] + async fn workspace_restore_does_not_auto_preview_markdown(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "restored.md": "# Restored\n", + "ordinary.md": "# Ordinary\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(true)); + + let restored_editor = + open_project_file(cx, &project, &multi_workspace, "restored.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!( + workspace.active_item_as::(cx), + Some(restored_editor) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 0 + ); + }); + + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(false)); + let ordinary_editor = + open_project_file(cx, &project, &multi_workspace, "ordinary.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &ordinary_editor, cx); + }); + } + + #[gpui::test] + async fn dirty_markdown_editor_is_retained_when_auto_preview_runs(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + let end = editor.buffer().read(cx).snapshot(cx).len(); + editor.edit([(end..end, "dirty\n")], cx); + }); + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane( + Box::new(editor.clone()), + None, + true, + window, + cx, + ); + }); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert!( + workspace + .panes() + .iter() + .any(|pane| pane.read(cx).index_for_item(&editor).is_some()) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 1 + ); + }); + } + + #[gpui::test] + async fn automatic_previews_preserve_temporary_tab_semantics(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "first.md": "# First\n", + "second.md": "# Second\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + for path in ["first.md", "second.md"] { + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, path, cx), + None, + true, + true, + true, + window, + cx, + ) + }) + }) + .unwrap(); + open.await.unwrap(); + cx.run_until_parked(); + + let preview = cx.update(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let preview = pane + .preview_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert_eq!(pane.items_len(), 1); + preview + }); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path(path)); + } + } + + #[gpui::test] + async fn automatic_preview_preserves_pane_activation_and_focus(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "plain.txt": "Plain\n", + "active.md": "# Active\n", + "background.md": "# Background\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let plain_editor = + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true) + .await + .downcast::() + .unwrap(); + + let (plain_pane, target_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let plain_pane = workspace.active_pane().clone(); + let target_pane = workspace.split_pane( + plain_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + plain_editor.focus_handle(cx).focus(window, cx); + (plain_pane, target_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "active.md", + Some(&target_pane), + false, + ) + .await; + + let target_preview = multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + let target_preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert!(plain_editor.focus_handle(cx).contains_focused(window, cx)); + target_preview + }) + .unwrap(); + multi_workspace + .update(cx, |_, window, cx| { + target_preview.focus_handle(cx).focus(window, cx); + }) + .unwrap(); + + let open_background = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, "background.md", cx), + Some(plain_pane.downgrade()), + false, + false, + false, + window, + cx, + ) + }) + }) + .unwrap(); + open_background.await.unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + assert!( + plain_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some() + ); + assert!(target_preview.focus_handle(cx).contains_focused(window, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn targeted_markdown_open_reveals_the_source_position(cx: &mut TestAppContext) { + let (_project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ "note.md": "# Heading\n\nFirst line\nTarget line\n" }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let open_target = OpenTarget::Path( + PathWithPosition { + path: PathBuf::from(path!("/project/note.md")), + row: Some(4), + column: Some(3), + }, + false, + OpenTargetFoundBy::BackgroundPathResolution, + ); + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |_, cx| { + cx.spawn_in(window, async move |workspace, cx| { + open_resolved_target(&workspace, &open_target, cx).await + }) + }) + }) + .unwrap(); + assert!(open.await.unwrap()); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + let preview = workspace + .read(cx) + .active_item_as::(cx) + .unwrap(); + let editor = preview + .read(cx) + .active_editor + .as_ref() + .unwrap() + .editor + .clone(); + let source_index = editor + .update(cx, |editor, cx| { + MarkdownPreviewView::selected_source_index(editor, cx) + }) + .unwrap(); + assert_eq!(preview.read(cx).active_source_index, Some(source_index)); + assert!(preview.read(cx).focus_handle.is_focused(window)); + assert!( + workspace + .read(cx) + .panes() + .iter() + .all(|pane| { pane.read(cx).index_for_item(&editor).is_none() }) + ); + }) + .unwrap(); + } + #[gpui::test] async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -3654,6 +4191,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { + cx.set_global(db::AppDatabase::test_new()); let state = AppState::test(cx); editor::init(cx); crate::init(cx); @@ -3661,21 +4199,118 @@ mod tests { }) } + async fn markdown_workspace( + cx: &mut TestAppContext, + files: serde_json::Value, + lazy_language: bool, + ) -> ( + Entity, + Entity, + WindowHandle, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), files).await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + if lazy_language { + register_lazy_markdown_language(&project, cx); + } else { + register_markdown_language(&project, cx); + } + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + (project, workspace, multi_workspace) + } + + async fn open_project_file( + cx: &mut TestAppContext, + project: &Entity, + multi_workspace: &WindowHandle, + path: &str, + pane: Option<&Entity>, + focus_item: bool, + ) -> Box { + let open = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path( + test_project_path(project, path, cx), + pane.map(Entity::downgrade), + focus_item, + window, + cx, + ) + }) + }) + .unwrap(); + let item = open.await.unwrap(); + cx.run_until_parked(); + item + } + + fn assert_editor_replaced_by_preview(workspace: &Workspace, editor: &dyn ItemHandle, cx: &App) { + assert!( + workspace + .active_item_as::(cx) + .is_some() + ); + assert!( + workspace + .panes() + .iter() + .all(|pane| pane.read(cx).index_for_item(editor).is_none()) + ); + } + + fn set_auto_preview_enabled(cx: &mut App, enabled: bool) { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = Some(enabled); + }); + }); + } + + fn test_project_path(project: &Entity, path: &str, cx: &App) -> ProjectPath { + let worktree_id = project.read(cx).worktrees(cx).next().unwrap().read(cx).id(); + ProjectPath { + worktree_id, + path: rel_path(path).into(), + } + } + + fn markdown_language_config() -> language::LanguageConfig { + language::LanguageConfig { + name: "Markdown".into(), + matcher: Arc::new(language::LanguageMatcher { + path_suffixes: vec!["md".to_string(), "markdown".to_string()], + ..Default::default() + }), + ..Default::default() + } + } + fn register_markdown_language(project: &Entity, cx: &mut TestAppContext) { let languages = project.read_with(cx, |project, _| project.languages().clone()); languages.add(Arc::new(language::Language::new( - language::LanguageConfig { - name: "Markdown".into(), - matcher: Arc::new(language::LanguageMatcher { - path_suffixes: vec!["md".to_string(), "markdown".to_string()], - ..Default::default() - }), - ..Default::default() - }, + markdown_language_config(), None, ))); } + fn register_lazy_markdown_language(project: &Entity, cx: &mut TestAppContext) { + project + .read_with(cx, |project, _| project.languages().clone()) + .register_test_language(markdown_language_config()); + } + async fn wait_for_preview_serialization(cx: &mut TestAppContext) { cx.run_until_parked(); cx.executor().advance_clock(Duration::from_millis(250)); diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 5592449c87..2088dfc4ec 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -370,3 +370,9 @@ pub(crate) mod m_2026_08_26 { pub(crate) use settings::rename_folder_icons_to_folder_indicator; } + +pub(crate) mod m_2026_08_30 { + mod settings; + + pub(crate) use settings::nest_markdown_preview_settings; +} diff --git a/crates/migrator/src/migrations/m_2026_08_30/settings.rs b/crates/migrator/src/migrations/m_2026_08_30/settings.rs new file mode 100644 index 0000000000..142ab0a093 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_08_30/settings.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const MARKDOWN_PREVIEW_KEY: &str = "markdown_preview"; +const KEY_MAPPINGS: &[(&str, &str)] = &[ + ("markdown_preview_font_size", "font_size"), + ("markdown_preview_font_family", "font_family"), + ("markdown_preview_code_font_family", "code_font_family"), + ("markdown_preview_theme", "theme"), +]; + +pub fn nest_markdown_preview_settings(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + let has_legacy_settings = KEY_MAPPINGS + .iter() + .any(|(old_key, _)| object.contains_key(*old_key)); + match object.get(MARKDOWN_PREVIEW_KEY) { + Some(Value::Null) if has_legacy_settings => { + object.remove(MARKDOWN_PREVIEW_KEY); + } + Some(markdown_preview) if !markdown_preview.is_object() => return Ok(()), + _ => {} + } + + let migrated_settings = KEY_MAPPINGS + .iter() + .filter_map(|(old_key, new_key)| { + object + .remove(*old_key) + .map(|value| ((*new_key).to_string(), value)) + }) + .collect::>(); + + if migrated_settings.is_empty() { + return Ok(()); + } + + let Some(markdown_preview) = object + .entry(MARKDOWN_PREVIEW_KEY) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + else { + return Ok(()); + }; + + for (key, value) in migrated_settings { + markdown_preview.entry(key).or_insert(value); + } + + Ok(()) +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index acf5289e0c..eba88345a9 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -255,6 +255,7 @@ pub fn migrate_settings(text: &str) -> Result> { ), MigrationType::Json(migrations::m_2026_08_17::make_git_gutter_width_an_enum), MigrationType::Json(migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator), + MigrationType::Json(migrations::m_2026_08_30::nest_markdown_preview_settings), ]; run_migrations(text, migrations) } @@ -5543,4 +5544,198 @@ mod tests { ), ); } + + #[test] + fn test_nest_markdown_preview_settings_across_all_scopes() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 14, + "macos": { + "markdown_preview_font_size": 15 + }, + "preview": { + "markdown_preview_font_size": 16 + }, + "profiles": { + "work": { + "settings": { + "markdown_preview_font_size": 17 + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 14 + }, + "macos": { + "markdown_preview": { + "font_size": 15 + } + }, + "preview": { + "markdown_preview": { + "font_size": 16 + } + }, + "profiles": { + "work": { + "settings": { + "markdown_preview": { + "font_size": 17 + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_merges_without_clobbering() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark", + "markdown_preview": { + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "theme": "One Dark", + "code_font_family": "Zed Mono", + "font_family": "Zed Sans", + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_malformed_object() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview": false + } + "# + .unindent(), + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_replaces_null_when_legacy_settings_exist() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview": null, + "markdown_preview_font_size": 15, + "preview": { + "markdown_preview": null, + "markdown_preview_theme": "One Dark" + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 15 + }, + "preview": { + "markdown_preview": { + "theme": "One Dark" + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_null_without_legacy_settings() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview": null, + "preview": { + "markdown_preview": null + } + } + "# + .unindent(), + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_is_registered() { + assert_migrate_settings( + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark" + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 15, + "font_family": "Zed Sans", + "code_font_family": "Zed Mono", + "theme": "One Dark" + } + } + "# + .unindent(), + ), + ); + } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0b35e9705b..a490b80518 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1011,10 +1011,6 @@ impl VsCodeSettings { agent_buffer_font_family: None, agent_buffer_font_size: None, git_commit_buffer_font_size: None, - markdown_preview_font_family: None, - markdown_preview_code_font_family: None, - markdown_preview_font_size: None, - markdown_preview_theme: None, theme: None, icon_theme: None, ui_density: None, diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 919f946507..2f3efc04ca 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1278,6 +1278,22 @@ pub enum LineIndicatorFormat { #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, Default, PartialEq)] pub struct MarkdownPreviewSettingsContent { + /// The name of a font to use for rendering in the markdown preview. + /// Falls back to the UI font if unset. + pub font_family: Option, + /// The name of a font to use for code (code blocks and inline code) in the + /// markdown preview. Falls back to the buffer font if unset. + pub code_font_family: Option, + /// The font size to use for rendering in the markdown preview. + /// Falls back to the UI font size if unset. + pub font_size: Option, + /// The theme to use for the markdown preview. + /// Falls back to the main editor theme if unset. + pub theme: Option, + /// Whether to automatically open Markdown files in the preview. + /// + /// Default: false + pub open_markdown_files_in_preview: Option, /// Whether to limit the width of the rendered markdown content. When /// enabled, content is constrained to `max_width` and centered /// horizontally within the preview pane, for optimal readability. diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index cf12cce971..649153e600 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -214,18 +214,6 @@ pub struct ThemeSettingsContent { /// The font size for user messages in the agent panel. pub agent_buffer_font_size: Option, pub git_commit_buffer_font_size: Option, - /// The name of a font to use for rendering in the markdown preview. - /// Falls back to the UI font if unset. - pub markdown_preview_font_family: Option, - /// The name of a font to use for code (code blocks and inline code) in the - /// markdown preview. Falls back to the buffer font if unset. - pub markdown_preview_code_font_family: Option, - /// The font size to use for rendering in the markdown preview. - /// Falls back to the UI font size if unset. - pub markdown_preview_font_size: Option, - /// The theme to use for the markdown preview. - /// Falls back to the main editor theme if unset. - pub markdown_preview_theme: Option, /// The name of the Zed theme to use. pub theme: Option, /// The name of the icon theme to use. diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 31a556feee..890fbaf2a5 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1254,12 +1254,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for the markdown preview. Falls back to the UI font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_family"), + json_path: Some("markdown_preview.font_family"), pick: |settings_content| { - settings_content.theme.markdown_preview_font_family.as_ref() + settings_content + .markdown_preview + .as_ref()? + .font_family + .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_family = value; }, }), metadata: None, @@ -1270,15 +1277,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for code blocks in the markdown preview. Falls back to the editor font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_code_font_family"), + json_path: Some("markdown_preview.code_font_family"), pick: |settings_content| { settings_content - .theme - .markdown_preview_code_font_family + .markdown_preview + .as_ref()? + .code_font_family .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_code_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .code_font_family = value; }, }), metadata: None, @@ -1289,16 +1300,19 @@ fn appearance_page() -> SettingsPage { description: "Font size for the markdown preview. Falls back to the editor font size.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_size"), + json_path: Some("markdown_preview.font_size"), pick: |settings_content| { settings_content - .theme - .markdown_preview_font_size + .markdown_preview .as_ref() + .and_then(|preview| preview.font_size.as_ref()) .or(settings_content.theme.buffer_font_size.as_ref()) }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_size = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_size = value; }, }), metadata: None, @@ -10271,7 +10285,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 3] { + fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SettingItem(SettingItem { title: "Image Viewer", @@ -10292,6 +10306,29 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Open Markdown Files in Preview", + description: "Whether to automatically open Markdown files in the preview.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.open_markdown_files_in_preview"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .open_markdown_files_in_preview + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::DynamicItem(DynamicItem { discriminant: SettingItem { files: USER, diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index d9a3270ef3..cd632bb018 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -702,8 +702,9 @@ fn font_fallbacks_from_settings( } impl settings::Settings for ThemeSettings { - fn from_settings(content: &settings::SettingsContent) -> Self { - let content = &content.theme; + fn from_settings(settings_content: &settings::SettingsContent) -> Self { + let content = &settings_content.theme; + let markdown_preview = settings_content.markdown_preview.as_ref(); let theme_selection: ThemeSelection = content.theme.clone().unwrap().into(); let icon_theme_selection: IconThemeSelection = content.icon_theme.clone().unwrap().into(); Self { @@ -743,18 +744,17 @@ impl settings::Settings for ThemeSettings { .map(|font| font.0.clone().into()), agent_buffer_font_size: content.agent_buffer_font_size.map(|s| s.into_gpui()), git_commit_buffer_font_size: content.git_commit_buffer_font_size.map(|s| s.into_gpui()), - markdown_preview_font_family: content - .markdown_preview_font_family - .as_ref() + markdown_preview_font_family: markdown_preview + .and_then(|preview| preview.font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_code_font_family: content - .markdown_preview_code_font_family - .as_ref() + markdown_preview_code_font_family: markdown_preview + .and_then(|preview| preview.code_font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_font_size: content.markdown_preview_font_size.map(|s| s.into_gpui()), - markdown_preview_theme: content - .markdown_preview_theme - .clone() + markdown_preview_font_size: markdown_preview + .and_then(|preview| preview.font_size) + .map(|size| size.into_gpui()), + markdown_preview_theme: markdown_preview + .and_then(|preview| preview.theme.clone()) .map(ThemeSelection::from), theme: theme_selection, experimental_theme_overrides: content.experimental_theme_overrides.clone(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 452f627798..d939f9bfa1 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2733,6 +2733,15 @@ impl Workspace { &self.app_state } + pub fn is_restoring(&self) -> bool { + self.restoring_workspace + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_restoring_workspace(&mut self, restoring: bool) { + self.restoring_workspace = restoring; + } + pub fn set_panels_task(&mut self, task: Task>) { self._panels_task = Some(task); } diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index afb301efc4..f40ebd36fc 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -78,15 +78,17 @@ If you would like to use distinct themes for light mode/dark mode that can be se // and any other snippet of code. "agent_buffer_font_size": 12, - // Controls the font size for the markdown preview. - // If not specified, it falls back to the editor font size. - "markdown_preview_font_size": null, - // Controls the font family for the markdown preview. - // If not specified, it falls back to the UI font family. - "markdown_preview_font_family": null, - // Controls the font family for code blocks in the markdown preview. - // If not specified, it falls back to the editor font family. - "markdown_preview_code_font_family": null + "markdown_preview": { + // Controls the font size for the markdown preview. + // If not specified, it falls back to the editor font size. + "font_size": null, + // Controls the font family for the markdown preview. + // If not specified, it falls back to the UI font family. + "font_family": null, + // Controls the font family for code blocks in the markdown preview. + // If not specified, it falls back to the editor font family. + "code_font_family": null + } ``` ### Font ligatures