From 234c7057c4cfbbb1988d8b7fb60cc289a7488d49 Mon Sep 17 00:00:00 2001 From: iam-liam <117163129+iam-liam@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:43:41 +0000 Subject: [PATCH] Fix settings file restored to both panes after restart (#50842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #35947 ## Summary When a settings file was moved to a second pane and Zed restarted, the file appeared in both panes. Closing it in one pane would close it in the other. ## Root cause `Editor::deserialize` restored standalone files (like settings.json) by calling `workspace.open_abs_path()`, which routes through `open_path_preview` → `pane.open_item()`, adding the editor to the workspace's default pane. The caller (`SerializedPane::deserialize_to`) then also adds the item to the target pane, so it ends up in two panes. This also caused the SQL constraint violations @MrSubidubi noted: the `items` table has `PRIMARY KEY(item_id, workspace_id)`, so the duplicate triggers errors on the next serialisation cycle. ## Fix Replace `workspace.open_abs_path()` with `project.open_local_buffer()`, which opens the buffer without touching any pane. Pane placement is left to `deserialize_to`. https://github.com/user-attachments/assets/68d3c5b4-d002-429f-b907-ec21cb0019ec ## Test plan - [x] Reproduced the original bug (settings file duplicated across panes after restart) - [x] Verified the fix: file restores only to the correct pane - [x] Added regression test (`test_deserialize_non_worktree_file_does_not_add_to_pane`) - [x] Existing `items::tests::test_deserialize` passes (all 6 cases) - [x] `cargo clippy -p editor` clean Release Notes: - Fixed settings file being restored to multiple panes after restart ([#35947](https://github.com/zed-industries/zed/issues/35947)). --- crates/editor/src/items.rs | 129 ++++++++++++++++++++++++++----------- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index b741cfa6c80ca098d590c46de87948a76a4990aa..ac07545a455a7fe8de90470b19573cec0c1743f5 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -41,6 +41,7 @@ use std::{ use text::{BufferId, BufferSnapshot, Selection}; use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt}; +use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, invalid_item_view::InvalidItemView, @@ -51,11 +52,7 @@ use workspace::{ }, }; use workspace::{ - OpenOptions, - item::{Dedup, ItemSettings, SerializableItem, TabContentParams}, -}; -use workspace::{ - OpenVisible, Pane, WorkspaceSettings, + Pane, WorkspaceSettings, item::{FollowEvent, ProjectItemKind}, searchable::SearchOptions, }; @@ -1143,7 +1140,7 @@ impl SerializableItem for Editor { fn deserialize( project: Entity, - workspace: WeakEntity, + _workspace: WeakEntity, workspace_id: workspace::WorkspaceId, item_id: ItemId, window: &mut Window, @@ -1267,42 +1264,33 @@ impl SerializableItem for Editor { }) }), None => { - // File is not in any worktree (e.g., opened as a standalone file) - // We need to open it via workspace and then restore dirty contents + // File is not in any worktree (e.g., opened as a standalone file). + // Open the buffer directly via the project rather than through + // workspace.open_abs_path(), which has the side effect of adding + // the item to a pane. The caller (deserialize_to) will add the + // returned item to the correct pane. window.spawn(cx, async move |cx| { - let open_by_abs_path = - workspace.update_in(cx, |workspace, window, cx| { - workspace.open_abs_path( - abs_path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) + let buffer = project + .update(cx, |project, cx| project.open_local_buffer(&abs_path, cx)) + .await + .with_context(|| { + format!("Failed to open buffer for {abs_path:?}") })?; - let editor = - open_by_abs_path.await?.downcast::().with_context( - || format!("path {abs_path:?} cannot be opened as an Editor"), - )?; if let Some(contents) = contents { - editor.update_in(cx, |editor, _window, cx| { - if let Some(buffer) = editor.buffer().read(cx).as_singleton() { - buffer.update(cx, |buffer, cx| { - restore_serialized_buffer_contents( - buffer, contents, mtime, cx, - ); - }); - } - })?; + buffer.update(cx, |buffer, cx| { + restore_serialized_buffer_contents(buffer, contents, mtime, cx); + }); } - editor.update_in(cx, |editor, window, cx| { - editor.read_metadata_from_db(item_id, workspace_id, window, cx); - })?; - Ok(editor) + cx.update(|window, cx| { + cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer, Some(project), window, cx); + editor.read_metadata_from_db(item_id, workspace_id, window, cx); + editor + }) + }) }) } } @@ -2069,6 +2057,7 @@ mod tests { use gpui::{App, VisualTestContext}; use language::TestFile; use project::FakeFs; + use serde_json::json; use std::path::{Path, PathBuf}; use util::{path, rel_path::RelPath}; @@ -2349,4 +2338,72 @@ mod tests { }); } } + + // Regression test for https://github.com/zed-industries/zed/issues/35947 + // Verifies that deserializing a non-worktree editor does not add the item + // to any pane as a side effect. + #[gpui::test] + async fn test_deserialize_non_worktree_file_does_not_add_to_pane( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/outside"), json!({ "settings.json": "{}" })) + .await; + + // Project with a different root — settings.json is NOT in any worktree + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); + let item_id = 99999 as ItemId; + + let serialized_editor = SerializedEditor { + abs_path: Some(PathBuf::from(path!("/outside/settings.json"))), + contents: None, + language: None, + mtime: None, + }; + + DB.save_serialized_editor(item_id, workspace_id, serialized_editor) + .await + .unwrap(); + + // Count items in all panes before deserialization + let pane_items_before = workspace.read_with(cx, |workspace, cx| { + workspace + .panes() + .iter() + .map(|pane| pane.read(cx).items_len()) + .sum::() + }); + + let deserialized = + deserialize_editor(item_id, workspace_id, workspace.clone(), project, cx).await; + + cx.run_until_parked(); + + // The editor should exist and have the file + deserialized.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).as_singleton().unwrap().read(cx); + assert!(buffer.file().is_some()); + }); + + // No items should have been added to any pane as a side effect + let pane_items_after = workspace.read_with(cx, |workspace, cx| { + workspace + .panes() + .iter() + .map(|pane| pane.read(cx).items_len()) + .sum::() + }); + + assert_eq!( + pane_items_before, pane_items_after, + "Editor::deserialize should not add items to panes as a side effect" + ); + } }