From 7ab425665d42f56aaaefcabf49b0d102c55ec767 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 21 Apr 2026 12:10:24 -0700 Subject: [PATCH] workspace: Handle unsaved scratch buffers when opening recent projects (#51611) Fixes #51456 ## Problem When opening a recent project with an unsaved scratch buffer (untitled file), the project switch silently fails. The log shows: ``` failed to save contents of buffer Caused by: FOREIGN KEY constraint failed ``` ## Root Cause When a user creates a new file from the welcome screen and edits it without saving, the buffer is dirty but has no file path. When opening a recent project, `prepare_to_close` calls `save_all_internal(SaveIntent::Close)`, which tries to serialize dirty items to the database for hot-exit functionality. The serialization fails with a FOREIGN KEY constraint error because workspace serialization is throttled - the workspace row may not exist in the database yet when the editor tries to INSERT into the editors table referencing that workspace_id. The previous code used `try_join_all` on all serialize tasks, so a single serialization failure would abort the entire close flow, preventing the project switch. ## Fix Replace `try_join_all` with individual task awaiting. If a serialize task fails, the item is moved back to the `remaining_dirty_items` list so the user gets a proper save/discard prompt instead of the action silently failing. This is a minimal change (7 insertions, 2 deletions) that: - Handles the FOREIGN KEY error gracefully - Preserves the save/discard prompt UX for items that fail serialization - Logs the serialization error for debugging via `.log_err()` - Does not change behavior for items that serialize successfully ## Test Plan - [ ] Open Zed with no active workspace - [ ] Create a new file from the welcome screen - [ ] Edit the buffer to make it dirty (do not save) - [ ] Open a recent project via `projects: open recent` - [ ] Verify the project opens (previously it silently failed) - [ ] Verify a save/discard prompt appears for the dirty scratch buffer Release Notes: - Fixed opening a recent project silently failing when an unsaved scratch buffer is present (#51456). --- This PR was written with the assistance of AI (Claude). --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Max Brunsfeld --- crates/workspace/src/workspace.rs | 77 ++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b4afd209216101dcefebb2103436872c0a4084d3..e6312e695476cf081f65fe238ec10386fb93e443 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3444,24 +3444,26 @@ impl Workspace { let project = self.project.clone(); cx.spawn_in(window, async move |workspace, cx| { let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() { - let (serialize_tasks, remaining_dirty_items) = - workspace.update_in(cx, |workspace, window, cx| { - let mut remaining_dirty_items = Vec::new(); - let mut serialize_tasks = Vec::new(); - for (pane, item) in dirty_items { - if let Some(task) = item - .to_serializable_item_handle(cx) - .and_then(|handle| handle.serialize(workspace, true, window, cx)) - { - serialize_tasks.push(task); - } else { - remaining_dirty_items.push((pane, item)); - } + let mut serialize_tasks = Vec::new(); + let mut remaining_dirty_items = Vec::new(); + workspace.update_in(cx, |workspace, window, cx| { + for (pane, item) in dirty_items { + if let Some(task) = item + .to_serializable_item_handle(cx) + .and_then(|handle| handle.serialize(workspace, true, window, cx)) + { + serialize_tasks.push((pane, item, task)); + } else { + remaining_dirty_items.push((pane, item)); } - (serialize_tasks, remaining_dirty_items) - })?; + } + })?; - futures::future::try_join_all(serialize_tasks).await?; + for (pane, item, task) in serialize_tasks { + if task.await.log_err().is_none() { + remaining_dirty_items.push((pane, item)); + } + } if !remaining_dirty_items.is_empty() { workspace.update(cx, |_, cx| cx.emit(Event::Activate))?; @@ -11300,6 +11302,49 @@ mod tests { assert!(task.await.unwrap()); } + #[gpui::test] + async fn test_close_window_with_failing_serialization(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_dirty(true).with_serialize(|| { + Some(Task::ready(Err(anyhow::anyhow!( + "FOREIGN KEY constraint failed" + )))) + }) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::CloseWindow, window, cx) + }); + cx.executor().run_until_parked(); + + // The failing serialization must not short-circuit the close; a + // save/discard prompt must be shown for the dirty scratch item. + assert!( + cx.has_pending_prompt(), + "a save/discard prompt should be shown for the dirty scratch item \ + when its serialization fails" + ); + cx.simulate_prompt_answer("Don't Save"); + cx.executor().run_until_parked(); + + // Preparing to close succeeds, even though serialization failed. + assert!(task.await.unwrap()); + } + #[gpui::test] async fn test_close_pane_items(cx: &mut TestAppContext) { init_test(cx);