From befb588c333f8db691195bff0f7402d5b01f492d Mon Sep 17 00:00:00 2001 From: Cameron Bedard Date: Sat, 16 May 2026 18:23:25 -0400 Subject: [PATCH 1/4] markdown_preview: Restore preview panes on workspace reload Implement SerializableItem for MarkdownPreviewView so preview tabs are restored alongside their source editors when Zed reopens a workspace. The view's source file path and mode are persisted; on load, the file is reopened as a buffer, wrapped in an Editor, and handed back to MarkdownPreviewView::new. Rebind the restored preview to the canonical workspace editor for its buffer (via Item::added_to_workspace and a workspace event subscriber) so cursor-driven scroll sync (EditorEvent::SelectionsChanged) continues to work after restart and after the user closes and reopens the source file. Match by buffer rather than by Editor entity in find_existing_independent_preview_item_idx so the "already open" lookup survives restoration and we don't create a duplicate preview. Closes #38581 --- Cargo.lock | 1 + crates/markdown_preview/Cargo.toml | 1 + .../markdown_preview/src/markdown_preview.rs | 4 + .../src/markdown_preview_view.rs | 289 ++++++++++++++++-- 4 files changed, 276 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2641cecc89e513..44d452164f8d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10637,6 +10637,7 @@ name = "markdown_preview" version = "0.1.0" dependencies = [ "anyhow", + "db", "editor", "fs", "gpui", diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 9b978ce14f507b..c182cfa3117afa 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -16,6 +16,7 @@ test-support = [] [dependencies] anyhow.workspace = true +db.workspace = true editor.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 982eff7c74513c..b16b18c24c9399 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -5,6 +5,8 @@ pub mod markdown_preview_view; pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide}; +use crate::markdown_preview_view::MarkdownPreviewView; + actions!( markdown, [ @@ -32,6 +34,8 @@ actions!( ); pub fn init(cx: &mut App) { + workspace::register_serializable_item::(cx); + cx.observe_new(|workspace: &mut Workspace, window, cx| { let Some(window) = window else { return; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index ec33f3d867ba43..e886bc7561e5e2 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{Context as _, Result}; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ @@ -19,18 +19,18 @@ use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; -use project::Project; use project::search::SearchQuery; +use project::{Project, ProjectPath}; use settings::{SeedQuerySetting, Settings}; use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; use util::markdown::split_local_url_fragment; -use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions}; +use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem}; use workspace::searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }; -use workspace::{Pane, Workspace}; +use workspace::{ItemId, Pane, Workspace, WorkspaceId, delete_unloaded_items}; use crate::{ OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, @@ -61,6 +61,22 @@ pub enum MarkdownPreviewMode { Follow, } +impl MarkdownPreviewMode { + fn to_db(self) -> i64 { + match self { + Self::Default => 0, + Self::Follow => 1, + } + } + + fn from_db(value: i64) -> Self { + match value { + 1 => Self::Follow, + _ => Self::Default, + } + } +} + struct EditorState { editor: Entity, _subscription: Subscription, @@ -142,15 +158,26 @@ impl MarkdownPreviewView { editor: &Entity, cx: &App, ) -> Option { + let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; pane.items_of_type::() .find(|view| { let view_read = view.read(cx); - // Only look for independent (Default mode) previews, not Follow previews + // 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.mode == MarkdownPreviewMode::Default - && view_read - .active_editor - .as_ref() - .is_some_and(|active_editor| active_editor.editor == *editor) + && view_read.active_editor.as_ref().is_some_and(|active_editor| { + active_editor + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .as_ref() + == Some(&target_buffer) + }) }) .and_then(|view| pane.index_for_item(&view)) } @@ -211,7 +238,7 @@ impl MarkdownPreviewView { workspace: WeakEntity, language_registry: Arc, window: &mut Window, - cx: &mut Context, + cx: &mut App, ) -> Entity { cx.new(|cx| { let markdown = cx.new(|cx| { @@ -250,15 +277,31 @@ impl MarkdownPreviewView { this.set_editor(active_editor, window, cx); - if mode == MarkdownPreviewMode::Follow { - if let Some(workspace) = &workspace.upgrade() { - cx.observe_in(workspace, window, |this, workspace, window, cx| { - let item = workspace.read(cx).active_item(cx); - this.workspace_updated(item, window, cx); - }) - .detach(); - } else { - log::error!("Failed to listen to workspace updates"); + match mode { + MarkdownPreviewMode::Follow => { + if let Some(workspace) = &workspace.upgrade() { + cx.observe_in(workspace, window, |this, workspace, window, cx| { + let item = workspace.read(cx).active_item(cx); + this.workspace_updated(item, window, cx); + }) + .detach(); + } else { + log::error!("Failed to listen to workspace updates"); + } + } + MarkdownPreviewMode::Default => { + // After workspace restoration the bound editor may be an orphan that + // wraps the right buffer but isn't the canonical Editor instance in + // any pane. Re-binding to the workspace's editor for our buffer is + // what restores cursor-driven scroll sync — `SelectionsChanged` only + // fires from the editor the user actually interacts with. + // + // Subscribing to `workspace::Event` (rather than `observe`) keeps the + // rebind check off the cursor-move hot path; `observe` would fire on + // every workspace `cx.notify`. + if let Some(workspace) = &workspace.upgrade() { + cx.subscribe_in(workspace, window, Self::on_workspace_event).detach(); + } } } @@ -333,6 +376,54 @@ impl MarkdownPreviewView { self.update_markdown_from_active_editor(false, true, window, cx); } + fn on_workspace_event( + &mut self, + workspace: &Entity, + event: &workspace::Event, + window: &mut Window, + cx: &mut Context, + ) { + if !matches!( + event, + workspace::Event::ItemAdded { .. } | workspace::Event::ItemRemoved { .. } + ) { + return; + } + let candidate = self.find_canonical_editor(workspace.read(cx), cx); + if let Some(editor) = candidate + && self + .active_editor + .as_ref() + .is_none_or(|s| s.editor != editor) + { + self.set_editor(editor, window, cx); + } + } + + fn find_canonical_editor( + &self, + workspace: &Workspace, + cx: &App, + ) -> Option> { + let our_buffer = self + .active_editor + .as_ref()? + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton()?; + workspace.items_of_type::(cx).find(|editor| { + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .as_ref() + == Some(&our_buffer) + }) + } + fn update_markdown_from_active_editor( &mut self, wait_for_debounce: bool, @@ -897,6 +988,25 @@ impl Item for MarkdownPreviewView { Some("Markdown Preview Opened") } + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode != MarkdownPreviewMode::Default { + return; + } + if let Some(editor) = self.find_canonical_editor(workspace, cx) + && self + .active_editor + .as_ref() + .is_none_or(|s| s.editor != editor) + { + self.set_editor(editor, window, cx); + } + } + fn can_save(&self, cx: &App) -> bool { self.active_editor .as_ref() @@ -1164,6 +1274,147 @@ impl SearchableItem for MarkdownPreviewView { } } +impl SerializableItem for MarkdownPreviewView { + fn serialized_item_kind() -> &'static str { + "MarkdownPreviewView" + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + workspace_id: WorkspaceId, + item_id: ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let db = persistence::MarkdownPreviewDb::global(cx); + window.spawn(cx, async move |cx| { + let (abs_path, mode_value) = db + .get_preview(item_id, workspace_id)? + .context("No markdown preview entry found")?; + let mode = MarkdownPreviewMode::from_db(mode_value); + + let (worktree, relative_path) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(abs_path.clone(), false, cx) + }) + .await + .context("Path not found")?; + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + + let project_path = ProjectPath { + worktree_id, + path: relative_path, + }; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await?; + + cx.update(|window, cx| { + let language_registry = project.read(cx).languages().clone(); + let editor = + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)); + MarkdownPreviewView::new(mode, editor, workspace, language_registry, window, cx) + }) + }) + } + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + let db = persistence::MarkdownPreviewDb::global(cx); + delete_unloaded_items(alive_items, workspace_id, "markdown_previews", &db, cx) + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + let editor = self.active_editor.as_ref()?.editor.clone(); + let buffer = editor.read(cx).buffer().read(cx).as_singleton()?; + let file = buffer.read(cx).file()?; + let worktree_id = file.worktree_id(cx); + let abs_path = workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx)? + .read(cx) + .absolutize(file.path()); + let mode = self.mode.to_db(); + let db = persistence::MarkdownPreviewDb::global(cx); + Some(cx.background_spawn(async move { + db.save_preview(item_id, workspace_id, abs_path, mode).await + })) + } + + fn should_serialize(&self, _event: &Self::Event) -> bool { + false + } +} + +mod persistence { + use std::path::PathBuf; + + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + use workspace::{ItemId, WorkspaceDb, WorkspaceId}; + + pub struct MarkdownPreviewDb(ThreadSafeConnection); + + impl Domain for MarkdownPreviewDb { + const NAME: &str = stringify!(MarkdownPreviewDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE markdown_previews ( + workspace_id INTEGER, + item_id INTEGER, + abs_path BLOB, + mode INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; + } + + db::static_connection!(MarkdownPreviewDb, [WorkspaceDb]); + + impl MarkdownPreviewDb { + query! { + pub async fn save_preview( + item_id: ItemId, + workspace_id: WorkspaceId, + abs_path: PathBuf, + mode: i64 + ) -> Result<()> { + INSERT OR REPLACE INTO markdown_previews(item_id, workspace_id, abs_path, mode) + VALUES (?, ?, ?, ?) + } + } + + query! { + pub fn get_preview(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + SELECT abs_path, mode + FROM markdown_previews + WHERE item_id = ? AND workspace_id = ? + } + } + } +} + #[cfg(test)] mod tests { use crate::markdown_preview_view::ImageSource; From 1741d0c13e39ec63892bc0c726a0a602c9a7b513 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 16 Jun 2026 16:04:43 +0530 Subject: [PATCH 2/4] fix canonical editor --- .../src/markdown_preview_view.rs | 150 ++++++++++++++---- 1 file changed, 116 insertions(+), 34 deletions(-) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index e886bc7561e5e2..657caf0f302af2 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -168,16 +168,19 @@ impl MarkdownPreviewView { // the editor the user is currently invoking the action on even though both // wrap the same source buffer. view_read.mode == MarkdownPreviewMode::Default - && view_read.active_editor.as_ref().is_some_and(|active_editor| { - active_editor - .editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .as_ref() - == Some(&target_buffer) - }) + && view_read + .active_editor + .as_ref() + .is_some_and(|active_editor| { + active_editor + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .as_ref() + == Some(&target_buffer) + }) }) .and_then(|view| pane.index_for_item(&view)) } @@ -300,7 +303,8 @@ impl MarkdownPreviewView { // rebind check off the cursor-move hot path; `observe` would fire on // every workspace `cx.notify`. if let Some(workspace) = &workspace.upgrade() { - cx.subscribe_in(workspace, window, Self::on_workspace_event).detach(); + cx.subscribe_in(workspace, window, Self::on_workspace_event) + .detach(); } } } @@ -400,28 +404,20 @@ impl MarkdownPreviewView { } } - fn find_canonical_editor( - &self, - workspace: &Workspace, - cx: &App, - ) -> Option> { - let our_buffer = self - .active_editor - .as_ref()? - .editor - .read(cx) - .buffer() - .read(cx) - .as_singleton()?; - workspace.items_of_type::(cx).find(|editor| { - editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .as_ref() - == Some(&our_buffer) - }) + fn find_canonical_editor(&self, workspace: &Workspace, cx: &App) -> Option> { + let current = self.active_editor.as_ref()?.editor.clone(); + let our_buffer = current.read(cx).buffer().read(cx).as_singleton()?; + let mut fallback = None; + for editor in workspace.items_of_type::(cx) { + if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&our_buffer) { + continue; + } + if editor == current { + return Some(current); + } + fallback.get_or_insert(editor); + } + fallback } fn update_markdown_from_active_editor( @@ -1421,7 +1417,7 @@ mod tests { use crate::markdown_preview_view::Resource; use crate::markdown_preview_view::resolve_preview_image; use editor::Editor; - use gpui::{Entity, TestAppContext}; + use gpui::{AppContext as _, Entity, TestAppContext}; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; @@ -1625,6 +1621,92 @@ mod tests { ); } + #[gpui::test] + async fn default_preview_stays_bound_to_invoking_editor_across_splits(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "todo.md": "- [ ] Finish work\n" + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir/todo.md"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let (preview, second_editor) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let first_editor: Entity = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .unwrap(); + let buffer = first_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + let project = workspace.project().clone(); + + let second_editor = + cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + let new_pane = workspace.split_pane( + workspace.active_pane().clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + new_pane.update(cx, |pane, cx| { + pane.add_item( + Box::new(second_editor.clone()), + true, + true, + None, + window, + cx, + ) + }); + + let preview = MarkdownPreviewView::create_markdown_view( + workspace, + second_editor.clone(), + window, + cx, + ); + new_pane.update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) + }); + (preview, second_editor) + }) + }) + .unwrap(); + cx.run_until_parked(); + + let bound_editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + assert_eq!( + bound_editor, second_editor, + "a Default preview must stay bound to the editor it was opened from, not another \ + editor that happens to share the same buffer in a different split" + ); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); From 636cf8f5a322c65b5afff63d9aeae3f8ebd614be Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 16 Jun 2026 18:47:38 +0530 Subject: [PATCH 3/4] handle file handle change --- .../src/markdown_preview_view.rs | 185 +++++++++++++++++- 1 file changed, 179 insertions(+), 6 deletions(-) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 657caf0f302af2..c83da849f8dc17 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -82,6 +82,11 @@ struct EditorState { _subscription: Subscription, } +#[derive(Clone, Copy, Debug)] +pub enum MarkdownPreviewEvent { + SourceFileHandleChanged, +} + impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { @@ -356,6 +361,12 @@ impl MarkdownPreviewView { | EditorEvent::BuffersEdited { .. } => { this.update_markdown_from_active_editor(true, false, window, cx); } + EditorEvent::FileHandleChanged => { + this.base_directory = + Self::get_folder_for_active_editor(editor.read(cx), cx); + this.update_markdown_from_active_editor(false, false, window, cx); + cx.emit(MarkdownPreviewEvent::SourceFileHandleChanged); + } EditorEvent::SelectionsChanged { .. } => { let (selection_start, editor_is_focused) = editor.update(cx, |editor, cx| { @@ -942,11 +953,11 @@ impl Focusable for MarkdownPreviewView { } } -impl EventEmitter<()> for MarkdownPreviewView {} +impl EventEmitter for MarkdownPreviewView {} impl EventEmitter for MarkdownPreviewView {} impl Item for MarkdownPreviewView { - type Event = (); + type Event = MarkdownPreviewEvent; fn act_as_type<'a>( &'a self, @@ -1059,7 +1070,14 @@ impl Item for MarkdownPreviewView { Task::ready(Ok(())) } - fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {} + fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) { + match event { + MarkdownPreviewEvent::SourceFileHandleChanged => { + f(workspace::item::ItemEvent::UpdateTab); + f(workspace::item::ItemEvent::UpdateBreadcrumbs); + } + } + } fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { ItemBufferKind::Singleton @@ -1352,8 +1370,8 @@ impl SerializableItem for MarkdownPreviewView { })) } - fn should_serialize(&self, _event: &Self::Event) -> bool { - false + fn should_serialize(&self, event: &Self::Event) -> bool { + matches!(event, MarkdownPreviewEvent::SourceFileHandleChanged) } } @@ -1421,9 +1439,14 @@ mod tests { use serde_json::json; use std::path::PathBuf; use std::sync::Arc; + use std::time::Duration; use util::path; + use util::rel_path::rel_path; use util::test::TempTree; - use workspace::{AppState, MultiWorkspace, SaveIntent, Workspace, open_paths}; + use workspace::item::SerializableItem; + use workspace::{ + AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, + }; use super::MarkdownPreviewView; @@ -1621,6 +1644,136 @@ mod tests { ); } + #[gpui::test] + async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "todo.md": "![image](image.png)\n", + "subdir": {}, + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let worktree_id = workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id(); + workspace.open_path((worktree_id, rel_path("todo.md")), None, true, window, cx) + }) + }) + .unwrap(); + open_task.await.unwrap(); + cx.run_until_parked(); + + let (preview, project, workspace_id) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.set_random_database_id(); + let workspace_id = workspace.database_id().unwrap(); + let project = workspace.project().clone(); + let editor: Entity = workspace + .active_item(cx) + .and_then(|item| item.act_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, project, workspace_id) + }) + }) + .unwrap(); + let workspace_serialization_tasks = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.flush_all_serialization(window, cx) + }) + .unwrap(); + for task in workspace_serialization_tasks { + task.await; + } + + let serialize_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + preview + .update(cx, |preview, cx| { + preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx) + }) + .unwrap() + }) + }) + .unwrap(); + serialize_task.await.unwrap(); + + assert_eq!( + saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), + PathBuf::from(path!("/dir/todo.md")) + ); + + let (entry_id, worktree_id, destination_path) = preview.read_with(cx, |preview, cx| { + let editor = &preview.active_editor.as_ref().unwrap().editor; + let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); + let buffer = buffer.read(cx); + let file = buffer.file().unwrap(); + let worktree_id = file.worktree_id(cx); + let source_path = file.path(); + let mut destination_path = source_path.to_rel_path_buf(); + destination_path.pop(); + destination_path.push(rel_path("subdir/renamed.md")); + let worktree = project.read(cx).worktree_for_id(worktree_id, cx).unwrap(); + let entry_id = worktree.read(cx).entry_for_path(source_path).unwrap().id; + ( + entry_id, + worktree_id, + destination_path.as_rel_path().into_arc(), + ) + }); + project + .update(cx, |project, cx| { + project.rename_entry(entry_id, (worktree_id, destination_path).into(), cx) + }) + .await + .unwrap(); + wait_for_preview_serialization(cx).await; + + assert_eq!( + preview.read_with(cx, |preview, _| preview.base_directory.clone()), + Some(PathBuf::from(path!("/dir/subdir"))) + ); + assert_eq!( + saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), + PathBuf::from(path!("/dir/subdir/renamed.md")) + ); + } + #[gpui::test] async fn default_preview_stays_bound_to_invoking_editor_across_splits(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1716,6 +1869,26 @@ mod tests { }) } + async fn wait_for_preview_serialization(cx: &mut TestAppContext) { + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + } + + fn saved_preview_path( + cx: &mut TestAppContext, + item_id: ItemId, + workspace_id: WorkspaceId, + ) -> PathBuf { + cx.update(|cx| { + super::persistence::MarkdownPreviewDb::global(cx) + .get_preview(item_id, workspace_id) + .unwrap() + .unwrap() + .0 + }) + } + fn markdown_fixture_directory(tree: &TempTree) -> PathBuf { tree.path().join("docs") } From c0c229c292d29a69cccdeeb96d0d75fc55adfa43 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 16 Jun 2026 20:14:19 +0530 Subject: [PATCH 4/4] fix source editor change --- .../src/markdown_preview_view.rs | 165 +++++++++++++++++- 1 file changed, 161 insertions(+), 4 deletions(-) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index c83da849f8dc17..10ed73f61724d4 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -84,6 +84,7 @@ struct EditorState { #[derive(Clone, Copy, Debug)] pub enum MarkdownPreviewEvent { + SourceEditorChanged, SourceFileHandleChanged, } @@ -350,6 +351,7 @@ impl MarkdownPreviewView { return; } + let had_active_editor = self.active_editor.is_some(); let subscription = cx.subscribe_in( &editor, window, @@ -387,8 +389,10 @@ impl MarkdownPreviewView { editor, _subscription: subscription, }); - self.update_markdown_from_active_editor(false, true, window, cx); + if had_active_editor { + cx.emit(MarkdownPreviewEvent::SourceEditorChanged); + } } fn on_workspace_event( @@ -1072,7 +1076,8 @@ impl Item for MarkdownPreviewView { fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) { match event { - MarkdownPreviewEvent::SourceFileHandleChanged => { + MarkdownPreviewEvent::SourceEditorChanged + | MarkdownPreviewEvent::SourceFileHandleChanged => { f(workspace::item::ItemEvent::UpdateTab); f(workspace::item::ItemEvent::UpdateBreadcrumbs); } @@ -1371,7 +1376,11 @@ impl SerializableItem for MarkdownPreviewView { } fn should_serialize(&self, event: &Self::Event) -> bool { - matches!(event, MarkdownPreviewEvent::SourceFileHandleChanged) + matches!( + event, + MarkdownPreviewEvent::SourceEditorChanged + | MarkdownPreviewEvent::SourceFileHandleChanged + ) } } @@ -1441,7 +1450,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; use util::path; - use util::rel_path::rel_path; + use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; use workspace::item::SerializableItem; use workspace::{ @@ -1774,6 +1783,137 @@ mod tests { ); } + #[gpui::test] + async fn follow_preview_serialized_path_updates_when_followed_editor_changes( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "a.md": "# A\n", + "b.md": "# B\n", + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let worktree_id = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .read(cx) + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id() + }) + .unwrap(); + + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path((worktree_id, rel_path("a.md")), None, true, window, cx) + }) + }) + .unwrap(); + let opened_item = open_task.await.unwrap(); + cx.run_until_parked(); + let editor_a = cx.update(|cx| opened_item.act_as::(cx).unwrap()); + + let open_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path((worktree_id, rel_path("b.md")), None, true, window, cx) + }) + }) + .unwrap(); + let opened_item = open_task.await.unwrap(); + cx.run_until_parked(); + let editor_b = cx.update(|cx| opened_item.act_as::(cx).unwrap()); + let editor_b_path = editor_source_path(cx, &editor_b); + assert_eq!(editor_b_path.as_ref(), rel_path("b.md")); + + let (preview, workspace_id) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.set_random_database_id(); + let workspace_id = workspace.database_id().unwrap(); + let preview = MarkdownPreviewView::create_following_markdown_view( + workspace, editor_a, window, cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) + }); + (preview, workspace_id) + }) + }) + .unwrap(); + let workspace_serialization_tasks = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.flush_all_serialization(window, cx) + }) + .unwrap(); + for task in workspace_serialization_tasks { + task.await; + } + wait_for_preview_serialization(cx).await; + + let serialize_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + preview + .update(cx, |preview, cx| { + preview.serialize(workspace, cx.entity_id().as_u64(), false, window, cx) + }) + .unwrap() + }) + }) + .unwrap(); + serialize_task.await.unwrap(); + + assert_eq!( + saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), + PathBuf::from(path!("/dir/a.md")) + ); + + multi_workspace + .update(cx, |_, window, cx| { + preview.update(cx, |preview, cx| { + preview.set_editor(editor_b, window, cx); + }); + }) + .unwrap(); + wait_for_preview_serialization(cx).await; + + let followed_path = preview_source_path(cx, &preview); + assert_eq!(followed_path.as_ref(), rel_path("b.md")); + + assert_eq!( + saved_preview_path(cx, preview.entity_id().as_u64(), workspace_id), + PathBuf::from(path!("/dir/b.md")), + "a Follow preview should persist the source editor it most recently followed" + ); + } + #[gpui::test] async fn default_preview_stays_bound_to_invoking_editor_across_splits(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1889,6 +2029,23 @@ mod tests { }) } + fn preview_source_path( + cx: &mut TestAppContext, + preview: &Entity, + ) -> Arc { + let editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + editor_source_path(cx, &editor) + } + + fn editor_source_path(cx: &mut TestAppContext, editor: &Entity) -> Arc { + editor.read_with(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + buffer.read(cx).file().unwrap().path().clone() + }) + } + fn markdown_fixture_directory(tree: &TempTree) -> PathBuf { tree.path().join("docs") }