Flush all persistence threads for the rest for various shutdown cases (#63213)

Follow-up to https://github.com/zed-industries/zed/pull/60477
Closes #25022

We have more places that need a similar synchronization before quitting
the app: restarts on update, reloading the workspace.

On top of it, two more findings:

* `on_app_will_quit` was redundant: the global `on_app_quit` hook
flushes a strict superset of the same tasks and runs before windows are
cleared, while the entity hook held only a weak handle and could never
flush the `Window`-dependent workspace state anyway.

* previously, shutdown was run as `cx.spawn`, yet main threads got
parked on app shutdown: now using `cx.background_spawn` instead

Release Notes:

- Improved data persistence during various app shutdown cases

---------

Co-authored-by: Kostia R <k-bx@k-bx.com>
This commit is contained in:
Kirill BulatovandKostia R authored and GitHub committed 2026-08-26 14:33:10 +00:00
1 parent 6c6673e4a5
commit 677e8174b5
19 files changed
+669 -240

No files matched your search

@@ -863,7 +863,6 @@ impl SerializableItem for ComponentPreview {
_workspace: &mut Workspace,
item_id: ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<anyhow::Result<()>>> {
let active_page = self.active_page_id(cx);
+18 -25
View File
@@ -1454,7 +1454,6 @@ impl SerializableItem for Editor {
workspace: &mut Workspace,
item_id: ItemId,
closing: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let buffer_serialization = self.buffer_serialization?;
@@ -1494,31 +1493,25 @@ impl SerializableItem for Editor {
let snapshot = buffer.read(cx).snapshot();
let db = EditorDb::global(cx);
Some(cx.spawn_in(window, async move |_this, cx| {
cx.background_spawn(async move {
let (contents, language) = if serialize_dirty_buffers && is_dirty {
let contents = snapshot.text();
let language = snapshot.language().map(|lang| lang.name().to_string());
(Some(contents), language)
} else {
(None, None)
};
Some(cx.background_spawn(async move {
let (contents, language) = if serialize_dirty_buffers && is_dirty {
let contents = snapshot.text();
let language = snapshot.language().map(|lang| lang.name().to_string());
(Some(contents), language)
} else {
(None, None)
};
let editor = SerializedEditor {
abs_path,
contents,
language,
mtime,
};
log::debug!("Serializing editor {item_id:?} in workspace {workspace_id:?}");
db.save_serialized_editor(item_id, workspace_id, editor)
.await
.context("failed to save serialized editor")
})
.await
.context("failed to save contents of buffer")?;
Ok(())
let editor = SerializedEditor {
abs_path,
contents,
language,
mtime,
};
log::debug!("Serializing editor {item_id:?} in workspace {workspace_id:?}");
db.save_serialized_editor(item_id, workspace_id, editor)
.await
.context("failed to save serialized editor")
}))
}
-1
View File
@@ -682,7 +682,6 @@ impl SerializableItem for BranchDiff {
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let workspace_id = workspace.database_id()?;
-1
View File
@@ -4307,7 +4307,6 @@ impl workspace::SerializableItem for GitGraph {
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
-1
View File
@@ -622,7 +622,6 @@ impl SerializableItem for ProjectDiff {
_: &mut Workspace,
_: workspace::ItemId,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
Some(Task::ready(Ok(())))
-1
View File
@@ -514,7 +514,6 @@ impl SerializableItem for StagedDiff {
_: &mut Workspace,
_: workspace::ItemId,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
Some(Task::ready(Ok(())))
-1
View File
@@ -588,7 +588,6 @@ impl SerializableItem for UnstagedDiff {
_: &mut Workspace,
_: workspace::ItemId,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
Some(Task::ready(Ok(())))
-1
View File
@@ -737,7 +737,6 @@ impl SerializableItem for ImageView {
workspace: &mut Workspace,
item_id: ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<anyhow::Result<()>>> {
let workspace_id = workspace.database_id()?;
@@ -3961,7 +3961,6 @@ impl SerializableItem for KeymapEditor {
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut ui::Context<Self>,
) -> Option<gpui::Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
@@ -2000,7 +2000,6 @@ impl SerializableItem for MarkdownPreviewView {
workspace: &mut Workspace,
item_id: ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let workspace_id = workspace.database_id()?;
@@ -3121,12 +3120,12 @@ mod tests {
}
let serialize_task = multi_workspace
.update(cx, |multi_workspace, window, cx| {
.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)
preview.serialize(workspace, cx.entity_id().as_u64(), false, cx)
})
.unwrap()
})
@@ -3269,12 +3268,12 @@ mod tests {
wait_for_preview_serialization(cx).await;
let serialize_task = multi_workspace
.update(cx, |multi_workspace, window, cx| {
.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)
preview.serialize(workspace, cx.entity_id().as_u64(), false, cx)
})
.unwrap()
})
-1
View File
@@ -630,7 +630,6 @@ impl workspace::SerializableItem for Onboarding {
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut ui::Context<Self>,
) -> Option<gpui::Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
@@ -1866,7 +1866,6 @@ impl SerializableItem for TerminalView {
_workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<anyhow::Result<()>>> {
let terminal = self.terminal().read(cx);
+1 -5
View File
@@ -430,7 +430,6 @@ pub trait SerializableItem: Item {
workspace: &mut Workspace,
item_id: ItemId,
closing: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>>;
@@ -443,7 +442,6 @@ pub trait SerializableItemHandle: ItemHandle {
&self,
workspace: &mut Workspace,
closing: bool,
window: &mut Window,
cx: &mut App,
) -> Option<Task<Result<()>>>;
fn should_serialize(&self, event: &dyn Any, cx: &App) -> bool;
@@ -461,11 +459,10 @@ where
&self,
workspace: &mut Workspace,
closing: bool,
window: &mut Window,
cx: &mut App,
) -> Option<Task<Result<()>>> {
self.update(cx, |this, cx| {
this.serialize(workspace, cx.entity_id().as_u64(), closing, window, cx)
this.serialize(workspace, cx.entity_id().as_u64(), closing, cx)
})
}
@@ -1876,7 +1873,6 @@ pub mod test {
_workspace: &mut Workspace,
_item_id: ItemId,
_closing: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Task<anyhow::Result<()>>> {
if let Some(serialize) = self.serialize.take() {
+48 -83
View File
@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context as _, Result};
use fs::Fs;
use gpui::{
@@ -12,7 +12,6 @@ use remote::RemoteConnectionOptions;
use settings::Settings;
pub use settings::SidebarSide;
use std::cell::Cell;
use std::future::Future;
use std::path::PathBuf;
use std::rc::Rc;
use ui::prelude::*;
@@ -348,7 +347,6 @@ impl MultiWorkspace {
task.detach();
}
});
let quit_subscription = cx.on_app_quit(Self::app_will_quit);
let settings_subscription = cx.observe_global_in::<settings::SettingsStore>(window, {
let mut previous_multi_workspace_enabled = !DisableAiSettings::get_global(cx)
.disable_ai
@@ -381,11 +379,7 @@ impl MultiWorkspace {
sidebar_overlay: None,
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_subscriptions: vec![
release_subscription,
quit_subscription,
settings_subscription,
],
_subscriptions: vec![release_subscription, settings_subscription],
previous_focus_handle: None,
}
}
@@ -559,44 +553,25 @@ impl MultiWorkspace {
}
pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let workspaces = this.update(cx, |multi_workspace, _cx| {
multi_workspace.workspaces().cloned().collect::<Vec<_>>()
})?;
let mut prepared = anyhow::Ok(true);
for workspace in workspaces {
prepared = match workspace.update_in(cx, |workspace, window, cx| {
workspace.prepare_to_close(CloseIntent::CloseWindow, window, cx)
}) {
Ok(task) => task.await,
Err(error) => Err(error),
};
if !matches!(prepared, Ok(true)) {
break;
}
}
if !matches!(prepared, Ok(true)) {
this.update(cx, |multi_workspace, cx| {
for workspace in multi_workspace.workspaces() {
workspace.update(cx, |workspace, _| {
workspace.removing = false;
});
}
})?;
prepared?;
let Some(window_handle) = window.window_handle().downcast::<Self>() else {
log::error!("cannot close a window whose root is not a MultiWorkspace");
return;
};
cx.spawn(async move |_, cx| {
if !crate::prepare_window_to_close(window_handle, CloseIntent::CloseWindow, cx)
.await
.context("preparing the window to close")?
{
return anyhow::Ok(());
}
let flush_tasks = this.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.flush_pending_serialization(window, cx)
})?;
futures::future::join_all(flush_tasks).await;
crate::flush_windows_serialization(&[window_handle], cx).await;
cx.update(|window, _cx| {
window.remove_window();
})?;
window_handle
.update(cx, |_, window, _cx| {
window.remove_window();
})
.context("removing the closed window")?;
anyhow::Ok(())
})
@@ -1467,39 +1442,41 @@ impl MultiWorkspace {
pub fn serialize(&mut self, cx: &mut Context<Self>) {
self._serialize_task = Some(cx.spawn(async move |this, cx| {
let Some((window_id, state)) = this
.read_with(cx, |this, cx| {
let state = MultiWorkspaceState {
active_workspace_id: this.workspace().read(cx).database_id(),
project_groups: this
.project_groups
.iter()
.map(|group| {
crate::persistence::model::SerializedProjectGroup::from_group(
&group.key,
group.expanded,
)
})
.collect::<Vec<_>>(),
sidebar_open: this.sidebar_open,
sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
};
(this.window_id, state)
})
.ok()
else {
let Ok(task) = this.update(cx, |this, cx| this.serialize_now(cx)) else {
return;
};
let kvp = cx.update(|cx| db::kvp::KeyValueStore::global(cx));
crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
task.await;
}));
}
/// Returns the in-flight serialization task (if any) so the caller can
/// await it. Used by the quit handler to ensure pending DB writes
fn serialize_now(&mut self, cx: &mut Context<Self>) -> Task<()> {
let state = MultiWorkspaceState {
active_workspace_id: self.workspace().read(cx).database_id(),
project_groups: self
.project_groups
.iter()
.map(|group| {
crate::persistence::model::SerializedProjectGroup::from_group(
&group.key,
group.expanded,
)
})
.collect::<Vec<_>>(),
sidebar_open: self.sidebar_open,
sidebar_state: self.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
};
let window_id = self.window_id;
let kvp = db::kvp::KeyValueStore::global(cx);
cx.background_spawn(async move {
crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
})
}
/// Used by the quit handler to ensure pending DB writes
/// complete before the process exits.
pub fn flush_serialization(&mut self) -> Task<()> {
self._serialize_task.take().unwrap_or(Task::ready(()))
pub fn flush_serialization(&mut self, cx: &mut Context<Self>) -> Task<()> {
self._serialize_task.take();
self.serialize_now(cx)
}
pub fn flush_pending_serialization(
@@ -1514,22 +1491,10 @@ impl MultiWorkspace {
}));
}
tasks.append(&mut self.take_pending_removal_tasks());
tasks.push(self.flush_serialization());
tasks.push(self.flush_serialization(cx));
tasks
}
fn app_will_quit(&mut self, _cx: &mut Context<Self>) -> impl Future<Output = ()> + use<> {
let mut tasks: Vec<Task<()>> = Vec::new();
if let Some(task) = self._serialize_task.take() {
tasks.push(task);
}
tasks.extend(std::mem::take(&mut self.pending_removal_tasks));
async move {
futures::future::join_all(tasks).await;
}
}
pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
let focus_handle = self.workspace().read(cx).fallback_focus_handle(window, cx);
window.focus(&focus_handle, cx);
@@ -1757,7 +1722,7 @@ impl MultiWorkspace {
}));
}
}
self.serialize(cx);
tasks.push(self.flush_serialization(cx));
tasks
}
+52 -1
View File
@@ -4765,6 +4765,57 @@ mod tests {
);
}
#[gpui::test]
async fn test_pending_serialization_flushed_on_shutdown(cx: &mut gpui::TestAppContext) {
crate::tests::init_test(cx);
let app_state = cx.update(crate::AppState::test);
cx.update(|cx| crate::init(app_state.clone(), cx));
let fs = fs::FakeFs::new(cx.executor());
let dir = unique_test_dir(&fs, "shutdown-flush").await;
let project = Project::test(fs.clone(), [dir.as_path()], cx).await;
let (multi_workspace, vcx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(vcx, |mw, _| mw.workspace().clone());
let db = vcx.update(|_, cx| WorkspaceDb::global(cx));
let workspace_id = db.next_id().await.unwrap();
workspace.update(vcx, |ws, _cx| {
ws.set_database_id(workspace_id);
});
multi_workspace.update_in(vcx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.serialize_workspace(window, cx)
})
});
let serialized_paths = db
.workspace_for_id(workspace_id)
.expect("next_id should have reserved a row")
.paths;
assert_eq!(
serialized_paths.paths().len(),
0,
"the debounced serialization must not have fired yet"
);
cx.update(|cx| cx.shutdown());
let serialized_paths = db
.workspace_for_id(workspace_id)
.expect("the workspace row should still exist after shutdown")
.paths;
assert_eq!(
serialized_paths.paths(),
std::slice::from_ref(&dir),
"shutdown should flush the pending workspace serialization"
);
}
#[gpui::test]
async fn test_create_workspace_serialization(cx: &mut gpui::TestAppContext) {
crate::tests::init_test(cx);
@@ -5092,7 +5143,7 @@ mod tests {
// Note: removal_tasks may be empty if the background task already
// completed (take_pending_removal_tasks filters out ready tasks).
tasks.append(&mut removal_tasks);
tasks.push(mw.flush_serialization());
tasks.push(mw.flush_serialization(cx));
tasks
});
futures::future::join_all(all_tasks).await;
-1
View File
@@ -585,7 +585,6 @@ impl crate::SerializableItem for WelcomePage {
workspace: &mut Workspace,
item_id: crate::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
+188 -31
View File
@@ -60,12 +60,13 @@ use futures::{
future::{Shared, try_join_all},
};
use gpui::{
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Axis, Bounds,
Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle,
Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton,
PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription,
SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, WindowBounds, WindowHandle,
WindowId, WindowOptions, actions, canvas, point, relative, size, transparent_black,
Action, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp, AsyncWindowContext, Axis,
Bounds, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter,
FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView,
MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful,
Subscription, SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, WindowBounds,
WindowHandle, WindowId, WindowOptions, actions, canvas, point, relative, size,
transparent_black,
};
pub use history_manager::*;
pub use item::{
@@ -578,7 +579,7 @@ actions!(
]
);
#[derive(PartialEq, Eq, Debug)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CloseIntent {
/// Quit the program entirely.
Quit,
@@ -788,6 +789,8 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
toast_layer::init(cx);
history_manager::init(app_state.fs.clone(), cx);
cx.on_app_quit(flush_windows_serialization_on_quit).detach();
cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx))
.on_action(|_: &Reload, cx| reload(cx))
.on_action(|action: &Open, cx: &mut App| {
@@ -3637,11 +3640,11 @@ impl Workspace {
let mut serialize_tasks = Vec::new();
let mut remaining_dirty_items = Vec::new();
if allow_hot_exit_serialization {
workspace.update_in(cx, |workspace, window, cx| {
workspace.update(cx, |workspace, 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))
.and_then(|handle| handle.serialize(workspace, true, cx))
{
serialize_tasks.push((pane, item, task));
} else {
@@ -7114,20 +7117,42 @@ impl Workspace {
})
}
/// Bypass the 200ms serialization throttle and write workspace state to
/// the DB immediately. Returns a task the caller can await to ensure the
/// write completes. Used by the quit handler so the most recent state
/// isn't lost to a pending throttle timer when the process exits.
/// Bypass the serialization throttles and write workspace and item state
/// to the DB immediately. Returns a task the caller can await to ensure the
/// writes complete before the process exits.
pub fn flush_serialization(&mut self, window: &mut Window, cx: &mut App) -> Task<()> {
self._schedule_serialize_workspace.take();
self._serialize_workspace_task.take();
self.bounds_save_task_queued.take();
let serializable_items = self
.panes
.iter()
.flat_map(|pane| pane.read(cx).items())
.filter_map(|item| item.to_serializable_item_handle(cx))
.fold(HashMap::default(), |mut items, item| {
items.entry(item.item_id()).or_insert(item);
items
});
let item_tasks = serializable_items
.into_values()
.filter_map(|item| {
let item_id = item.item_id();
let task = item.serialize(self, false, cx)?;
Some(async move {
task.await
.with_context(|| format!("flushing serialization of item {item_id:?}"))
})
})
.collect::<Vec<_>>();
let bounds_task = self.save_window_bounds(window, cx);
let serialize_task = self.serialize_workspace_internal(window, cx);
cx.spawn(async move |_| {
cx.background_spawn(async move {
bounds_task.await;
serialize_task.await;
for result in futures::future::join_all(item_tasks).await {
result.log_err();
}
})
}
@@ -7285,7 +7310,7 @@ impl Workspace {
};
let db = WorkspaceDb::global(cx);
window.spawn(cx, async move |_| {
cx.background_spawn(async move {
db.save_workspace(serialized_workspace).await;
})
}
@@ -7296,7 +7321,7 @@ impl Workspace {
let docks = build_serialized_docks(self, window, cx);
let db = WorkspaceDb::global(cx);
let kvp = db::kvp::KeyValueStore::global(cx);
window.spawn(cx, async move |_| {
cx.background_spawn(async move {
let open_status_write = db.set_window_open_status(
database_id,
window_bounds,
@@ -7316,7 +7341,7 @@ impl Workspace {
// Save dock state for empty non-local workspaces
let docks = build_serialized_docks(self, window, cx);
let kvp = db::kvp::KeyValueStore::global(cx);
window.spawn(cx, async move |_| {
cx.background_spawn(async move {
persistence::write_default_dock_state(&kvp, docks)
.await
.log_err();
@@ -7380,9 +7405,9 @@ impl Workspace {
// We use into_iter() here so that the references to the items are moved into
// the tasks and not kept alive while we're sleeping.
for (_, item) in unique_items.into_iter() {
if let Ok(Some(task)) = this.update_in(cx, |workspace, window, cx| {
item.serialize(workspace, false, window, cx)
}) {
if let Ok(Some(task)) =
this.update(cx, |workspace, cx| item.serialize(workspace, false, cx))
{
cx.background_spawn(async move { task.await.log_err() })
.detach();
}
@@ -11213,17 +11238,8 @@ pub fn reload(cx: &mut App) {
}
}
// If the user cancels any save prompt, then keep the app open.
for window in workspace_windows {
if let Ok(should_close) = window.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
workspace.update(cx, |workspace, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
})
}) && !should_close.await?
{
return anyhow::Ok(());
}
if !prepare_windows_to_quit(&workspace_windows, cx).await {
return anyhow::Ok(());
}
cx.update(|cx| cx.restart());
anyhow::Ok(())
@@ -11231,6 +11247,147 @@ pub fn reload(cx: &mut App) {
.detach_and_log_err(cx);
}
pub async fn prepare_windows_to_quit(
workspace_windows: &[WindowHandle<MultiWorkspace>],
cx: &mut AsyncApp,
) -> bool {
// If the user cancels any save prompt, then keep the app open.
let mut prepared_windows = Vec::new();
let mut cancelled = false;
for window in workspace_windows {
match prepare_window_to_close(*window, CloseIntent::Quit, cx).await {
Ok(true) => prepared_windows.push(*window),
Ok(false) => {
cancelled = true;
break;
}
Err(error) => {
log::error!(
"failed to prepare window {:?} to close before quitting: {error:#}",
window.window_id()
);
cancelled = true;
break;
}
}
}
if cancelled {
flush_windows_serialization(&prepared_windows, cx).await;
for window in prepared_windows {
window
.update(cx, |_, window, _cx| {
window.remove_window();
})
.log_err();
}
return false;
}
// Flush all pending workspace serialization before quitting so that
// session_id/window_id are up-to-date in the database.
flush_windows_serialization(workspace_windows, cx).await;
true
}
pub(crate) async fn prepare_window_to_close(
window: WindowHandle<MultiWorkspace>,
close_intent: CloseIntent,
cx: &mut AsyncApp,
) -> Result<bool> {
let active_and_workspaces = window
.update(cx, |multi_workspace, window, _cx| {
if close_intent == CloseIntent::Quit {
window.activate_window();
}
(
multi_workspace.workspace().clone(),
multi_workspace.workspaces().cloned().collect::<Vec<_>>(),
)
})
.log_err();
let Some((originally_active, workspaces)) = active_and_workspaces else {
return Ok(true);
};
let mut prepared = anyhow::Ok(true);
for workspace in workspaces {
prepared = match window.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.prepare_to_close(close_intent, window, cx)
})
}) {
Ok(task) => task.await,
Err(error) => Err(error),
}
.with_context(|| format!("preparing workspace {:?} to close", workspace.entity_id()));
if !matches!(prepared, Ok(true)) {
break;
}
}
// Re-activate the workspace the user actually had focused so it is the
// one serialized (and restored on next launch) as active, rather than
// whichever happened to be last.
window
.update(cx, |multi_workspace, window, cx| {
if !matches!(prepared, Ok(true)) {
for workspace in multi_workspace.workspaces() {
workspace.update(cx, |workspace, _| {
workspace.removing = false;
});
}
}
multi_workspace.activate(originally_active, None, window, cx);
})
.log_err();
prepared
}
pub async fn flush_windows_serialization(
workspace_windows: &[WindowHandle<MultiWorkspace>],
cx: &mut AsyncApp,
) {
let flush_tasks = collect_flush_tasks(workspace_windows, cx);
futures::future::join_all(flush_tasks).await;
}
fn flush_windows_serialization_on_quit(cx: &mut App) -> impl Future<Output = ()> + use<> {
let workspace_windows = cx
.windows()
.into_iter()
.filter_map(|window| window.downcast::<MultiWorkspace>())
.collect::<Vec<_>>();
let flush_tasks = collect_flush_tasks(&workspace_windows, cx);
async move {
futures::future::join_all(flush_tasks).await;
}
}
fn collect_flush_tasks(
workspace_windows: &[WindowHandle<MultiWorkspace>],
cx: &mut impl AppContext,
) -> Vec<Task<()>> {
let mut flush_tasks = Vec::new();
for window in workspace_windows {
window
.update(cx, |multi_workspace, window, cx| {
flush_tasks.extend(multi_workspace.flush_pending_serialization(window, cx));
})
.with_context(|| {
format!(
"flushing pending serialization for window {:?}",
window.window_id()
)
})
.log_err();
}
flush_tasks
}
fn parse_pixel_position_env_var(value: &str) -> Option<Point<Pixels>> {
let mut parts = value.split(',');
let x: usize = parts.next()?.parse().ok()?;
+345 -77
View File
@@ -102,9 +102,7 @@ use workspace::{
WorkspaceSettings, create_and_open_local_file,
notifications::simple_message_notification::MessageNotification, open_new,
};
use workspace::{
CloseIntent, CloseProject, CloseWindow, RestoreBanner, with_active_or_new_workspace,
};
use workspace::{CloseProject, CloseWindow, RestoreBanner, with_active_or_new_workspace};
use workspace::{Pane, notifications::DetachAndPromptErr};
use zed_actions::{
About, GetMerch, OpenAccountSettings, OpenBrowser, OpenDocs, OpenProjectTasks,
@@ -1807,74 +1805,9 @@ fn quit(_: &Quit, cx: &mut App) {
}
}
// If the user cancels any save prompt, then keep the app open.
for window in &workspace_windows {
let window = *window;
let active_and_workspaces = window
.update(cx, |multi_workspace, _, _cx| {
(
multi_workspace.workspace().clone(),
multi_workspace.workspaces().cloned().collect::<Vec<_>>(),
)
})
.log_err();
let Some((originally_active, workspaces)) = active_and_workspaces else {
continue;
};
for workspace in workspaces {
if let Some(should_close) = window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace.clone(), None, window, cx);
window.activate_window();
workspace.update(cx, |workspace, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
})
})
.log_err()
{
if !should_close.await? {
// Activating each workspace above to surface its save
// prompts changed which workspace is active. Restore the
// user's focused workspace before bailing so the window
// is left as they had it.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(
originally_active.clone(),
None,
window,
cx,
);
})
.log_err();
return Ok(());
}
}
}
// The loop above activated each workspace in turn, overwriting the
// persisted active workspace. Re-activate the workspace the user
// actually had focused so it is the one serialized (and restored on
// next launch) as active, rather than whichever happened to be last.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(originally_active, None, window, cx);
})
.log_err();
if !workspace::prepare_windows_to_quit(&workspace_windows, cx).await {
return Ok(());
}
// Flush all pending workspace serialization before quitting so that
// session_id/window_id are up-to-date in the database.
let mut flush_tasks = Vec::new();
for window in &workspace_windows {
window
.update(cx, |multi_workspace, window, cx| {
flush_tasks.extend(multi_workspace.flush_pending_serialization(window, cx));
})
.log_err();
}
futures::future::join_all(flush_tasks).await;
cx.update(|cx| cx.quit());
anyhow::Ok(())
@@ -2971,7 +2904,7 @@ mod tests {
})
})
.collect::<Vec<_>>();
tasks.push(multi_workspace.flush_serialization());
tasks.push(multi_workspace.flush_serialization(cx));
tasks
})
.unwrap();
@@ -6995,6 +6928,11 @@ mod tests {
})
.unwrap();
window1
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.run_until_parked();
cx.dispatch_action(*window1, Quit);
cx.run_until_parked();
@@ -7061,6 +6999,11 @@ mod tests {
})
.unwrap();
window1
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.run_until_parked();
cx.dispatch_action(*window1, Quit);
cx.run_until_parked();
@@ -7169,9 +7112,154 @@ mod tests {
assert_eq!(
cx.windows().len(),
2,
"Case 3: Windows should still exist after cancelling quit"
1,
"Case 3: The clean window should close when quit is cancelled in another window"
);
assert_eq!(
window1.update(cx, |_, _, _| ()).is_ok(),
false,
"Case 3: The clean window should have been closed"
);
assert_eq!(
window2.update(cx, |_, _, _| ()).is_ok(),
true,
"Case 3: The window with the cancelled prompt should stay open"
);
}
#[gpui::test]
async fn test_reload_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
app_state
.fs
.as_fake()
.insert_tree(
path!("/"),
json!({
"dir1": {
"a.txt": "content a"
},
"dir2": {
"b.txt": "content b"
}
}),
)
.await;
let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
let window = cx.add_window({
let project = project1.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
let workspace1 = window
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let workspace2 = window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project2.clone(), window, cx)
})
.unwrap();
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace2.clone(), None, window, cx);
multi_workspace.activate(workspace1.clone(), None, window, cx);
})
.unwrap();
cx.run_until_parked();
let worktree2_id = project2.update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let editor2 = window
.update(cx, |_, window, cx| {
workspace2.update(cx, |workspace, cx| {
workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
window
.update(cx, |_, window, cx| {
editor2.update(cx, |editor, cx| {
editor.insert("dirty in non-active workspace", window, cx);
});
})
.unwrap();
cx.run_until_parked();
window
.read_with(cx, |multi_workspace, _| {
assert_eq!(multi_workspace.workspace(), &workspace1);
})
.unwrap();
let mut will_restart = cx.expect_restart();
cx.dispatch_action(*window, workspace::Reload);
cx.run_until_parked();
window
.read_with(cx, |multi_workspace, _| {
assert_eq!(
multi_workspace.workspace(),
&workspace2,
"Non-active workspace should be activated when it has a dirty item"
);
})
.unwrap();
assert!(
cx.has_pending_prompt(),
"Reload should prompt to save the dirty item in the non-active workspace"
);
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(
will_restart.try_recv(),
Ok(None),
"Cancelling the save prompt should abort the restart"
);
window
.read_with(cx, |multi_workspace, _| {
assert_eq!(
multi_workspace.workspace(),
&workspace1,
"Cancelling reload should restore the originally focused workspace"
);
})
.unwrap();
cx.dispatch_action(*window, workspace::Reload);
cx.run_until_parked();
assert!(
cx.has_pending_prompt(),
"Reload should prompt again for the dirty item"
);
cx.simulate_prompt_answer("Don't Save");
cx.run_until_parked();
will_restart
.await
.expect("reload should restart the app after the dirty item is resolved");
}
#[gpui::test]
@@ -7466,14 +7554,11 @@ mod tests {
.unwrap();
// Quit. With no dirty items there are no save prompts, so the quit flow
// runs the prepare_to_close loop (which activates every workspace in
// turn to surface prompts) and then flushes serialization. cx.quit() is
// runs the prepare_to_close loop and then flushes serialization. cx.quit() is
// a no-op in tests, so the window stays around for inspection.
cx.dispatch_action(*window, Quit);
cx.run_until_parked();
// The fix re-activates the originally-focused workspace after the loop,
// so the window must still be focused on dir1, not dir2.
window
.read_with(cx, |mw, cx| {
let active = mw.workspace().read(cx).root_paths(cx);
@@ -7531,6 +7616,139 @@ mod tests {
.unwrap();
}
#[gpui::test]
async fn test_reload_restores_project_windows_and_tabs(cx: &mut TestAppContext) {
use session::Session;
let app_state = init_test(cx);
cx.update(init);
let first_dir = format!("reload-restore-{}", uuid::Uuid::new_v4());
let second_dir = format!("reload-restore-{}", uuid::Uuid::new_v4());
app_state
.fs
.as_fake()
.insert_tree(
path!("/"),
json!({
first_dir.clone(): {
"a.txt": "a",
"b.txt": "b"
},
second_dir.clone(): {
"c.txt": "c",
"d.txt": "d"
}
}),
)
.await;
let mut first_dir = PathBuf::from(path!("/")).join(first_dir);
let mut second_dir = PathBuf::from(path!("/")).join(second_dir);
if second_dir < first_dir {
std::mem::swap(&mut first_dir, &mut second_dir);
}
let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
let first_window = open_test_project_window_with_tabs(
&app_state,
&first_dir,
&[rel_path("a.txt"), rel_path("b.txt")],
cx,
)
.await;
let second_window = open_test_project_window_with_tabs(
&app_state,
&second_dir,
&[rel_path("c.txt"), rel_path("d.txt")],
cx,
)
.await;
let restart = cx.expect_restart();
cx.update(workspace::reload);
let (restart_path, restart_arguments) = restart.await.expect("restart was not requested");
assert_eq!(restart_path, None);
assert!(restart_arguments.is_empty());
let database = cx.update(|cx| workspace::WorkspaceDb::global(cx));
let locations = workspace::last_session_workspace_locations(
&database,
&session_id,
None,
app_state.fs.as_ref(),
)
.await
.expect("failed to read session workspaces");
assert_eq!(locations.len(), 2);
for window in [first_window, second_window] {
window
.update(cx, |_, window, _| window.remove_window())
.expect("workspace window was closed");
}
cx.run_until_parked();
cx.update(|cx| {
app_state.session.update(cx, |app_session, _cx| {
app_session
.replace_session_for_test(Session::test_with_old_session(session_id.clone()));
});
});
let mut async_cx = cx.to_async();
crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
.await
.expect("failed to restore workspaces");
cx.run_until_parked();
let mut restored_tabs = cx.read(|cx| {
cx.windows()
.into_iter()
.filter_map(|window| window.downcast::<MultiWorkspace>())
.map(|window| {
window
.read_with(cx, |multi_workspace, cx| {
let workspace = multi_workspace.workspace().read(cx);
let root_path = workspace
.root_paths(cx)
.into_iter()
.next()
.expect("restored project should have a root path")
.as_ref()
.to_path_buf();
let tab_paths = workspace
.active_pane()
.read(cx)
.items()
.map(|item| {
item.project_path(cx)
.expect("restored tab should have a project path")
.path
})
.collect::<Vec<_>>();
(root_path, tab_paths)
})
.expect("restored workspace window was closed")
})
.collect::<Vec<_>>()
});
restored_tabs.sort_by(|left, right| left.0.cmp(&right.0));
assert_eq!(
restored_tabs,
vec![
(
first_dir,
vec![rel_path("a.txt").into(), rel_path("b.txt").into()]
),
(
second_dir,
vec![rel_path("c.txt").into(), rel_path("d.txt").into()]
),
]
);
}
#[gpui::test]
async fn test_restored_project_groups_survive_workspace_key_change(cx: &mut TestAppContext) {
use session::Session;
@@ -7826,4 +8044,54 @@ mod tests {
"active workspace should contain the remaining project, not be empty: {active_paths:?}"
);
}
async fn open_test_project_window_with_tabs(
app_state: &Arc<AppState>,
root_path: &Path,
tab_paths: &[&RelPath],
cx: &mut TestAppContext,
) -> WindowHandle<MultiWorkspace> {
let workspace::OpenResult { window, .. } = cx
.update(|cx| {
Workspace::new_local(
vec![root_path.into()],
app_state.clone(),
None,
None,
None,
workspace::OpenMode::Activate,
cx,
)
})
.await
.expect("failed to open project workspace");
let workspace = window
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.expect("workspace window was closed");
let worktree_id = workspace.read_with(cx, |workspace, cx| {
workspace
.project()
.read(cx)
.worktrees(cx)
.next()
.expect("project should have a worktree")
.read(cx)
.id()
});
for tab_path in tab_paths {
window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.open_path((worktree_id, *tab_path), None, true, window, cx)
})
})
.expect("workspace window was closed")
.await
.expect("failed to open project tab");
}
window
}
}
+13 -2
View File
@@ -214,7 +214,7 @@ fn should_offer_to_move(app_path: &Path) -> bool {
async fn move_to_applications(app_path: &Path, cx: &mut AsyncWindowContext) -> Result<()> {
let destination_path = install_destination(app_path).await?;
restart_into(destination_path, cx)
restart_into(destination_path, cx).await
}
async fn install_destination(app_path: &Path) -> Result<PathBuf> {
@@ -305,7 +305,18 @@ async fn copy_app_bundle(source: &Path, destination: &Path) -> Result<()> {
Ok(())
}
fn restart_into(app_path: PathBuf, cx: &mut AsyncWindowContext) -> Result<()> {
async fn restart_into(app_path: PathBuf, cx: &mut AsyncWindowContext) -> Result<()> {
let workspace_windows = cx
.update(|_window, cx| {
cx.windows()
.into_iter()
.filter_map(|window| window.downcast::<MultiWorkspace>())
.collect::<Vec<_>>()
})
.context("collecting workspace windows before restarting")?;
if !workspace::prepare_windows_to_quit(&workspace_windows, cx).await {
return Ok(());
}
cx.update(|_window, cx| {
cx.set_restart_path(app_path);
cx.restart();