workspace: Fix recent-projects cleanup wiping active workspaces (#54224)

The recent-projects picker, sidebar, welcome screen, and agent thread
store all called `recent_workspaces_on_disk`, which combined listing
with deleting stale rows. Its retention predicate rejected workspaces
with no on-disk directory, including empty workspaces holding unsaved
scratch buffers, and the resulting `delete_workspace_by_id` call
cascaded into `items`, `pane_groups`, and the per-editor tables. For
clarity, the method has been renamed to `recent_workspaces_for_ui`.

Meanwhile, `last_session_workspace_locations` used a slightly different
form of the same predicate. The two disagreeing on what counts as a
valid workspace caused #48799, `Workspace WorkspaceId(N) not found`
errors on repeated launches (#50409), the
`test_window_edit_state_restoring_enabled` flake (#50871), and the
foreign key constraint fail on `projects: open recent` with a dirty
scratch buffer (#51456).

Note that for the last issue mentioned (#51456) there is no save prompt
for scratch buffers. This seems out of scope for this PR so I'll fix
that after this is addressed.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #48799 
Closes #50409 
Closes #50871
Closes #51456 

Release Notes:

- Fixed unsaved scratch buffers being lost across restarts and an
occasional error when opening a recent project.
This commit is contained in:
allison 2026-04-21 11:44:18 -05:00 committed by GitHub
parent 19429026c1
commit 1ea6eb4d42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 367 additions and 66 deletions

View file

@ -191,7 +191,7 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::
return Ok(());
}
let recent_workspaces = workspace_db.recent_workspaces_on_disk(fs.as_ref()).await?;
let recent_workspaces = workspace_db.recent_project_workspaces(fs.as_ref()).await?;
let mut local_path_lists = HashSet::<PathList>::default();
let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();

View file

@ -1079,7 +1079,7 @@ impl ProjectPickerModal {
let db = WorkspaceDb::global(cx);
cx.spawn_in(window, async move |this, cx| {
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();

View file

@ -96,7 +96,7 @@ pub async fn get_recent_projects(
db: &WorkspaceDb,
) -> Vec<RecentProjectEntry> {
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default();
@ -610,7 +610,7 @@ impl RecentProjects {
cx.spawn_in(window, async move |this, cx| {
let Some(fs) = fs else { return };
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@ -2039,7 +2039,7 @@ impl RecentProjectsDelegate {
db.delete_workspace_by_id(workspace_id).await.log_err();
let Some(fs) = fs else { return };
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default();
let workspaces =

View file

@ -70,7 +70,7 @@ impl SidebarRecentProjects {
cx.spawn_in(window, async move |this, cx| {
let Some(fs) = fs else { return };
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();

View file

@ -44,7 +44,7 @@ impl HistoryManager {
let db = WorkspaceDb::global(cx);
cx.spawn(async move |cx| {
let recent_folders = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default()
.into_iter()

View file

@ -66,6 +66,14 @@ fn parse_timestamp(text: &str) -> DateTime<Utc> {
.unwrap_or_else(|_| Utc::now())
}
fn contains_wsl_path(paths: &PathList) -> bool {
cfg!(windows)
&& paths
.paths()
.iter()
.any(|path| util::paths::WslPath::from_path(path).is_some())
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
@ -1740,26 +1748,30 @@ impl WorkspaceDb {
WorkspaceId,
PathList,
Option<RemoteConnectionId>,
Option<String>,
DateTime<Utc>,
)>,
> {
Ok(self
.recent_workspaces_query()?
.into_iter()
.map(|(id, paths, order, remote_connection_id, timestamp)| {
(
id,
PathList::deserialize(&SerializedPathList { paths, order }),
remote_connection_id.map(RemoteConnectionId),
parse_timestamp(&timestamp),
)
})
.map(
|(id, paths, order, remote_connection_id, session_id, timestamp)| {
(
id,
PathList::deserialize(&SerializedPathList { paths, order }),
remote_connection_id.map(RemoteConnectionId),
session_id,
parse_timestamp(&timestamp),
)
},
)
.collect())
}
query! {
fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, String)>> {
SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp
fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, Option<String>, String)>> {
SELECT workspace_id, paths, paths_order, remote_connection_id, session_id, timestamp
FROM workspaces
WHERE
paths IS NOT NULL OR
@ -1921,9 +1933,7 @@ impl WorkspaceDb {
let mut any_dir = false;
for path in paths {
match fs.metadata(path).await.ok().flatten() {
None => {
return false;
}
None => return false,
Some(meta) => {
if meta.is_dir {
any_dir = true;
@ -1934,9 +1944,10 @@ impl WorkspaceDb {
any_dir
}
// Returns the recent locations which are still valid on disk and deletes ones which no longer
// exist.
pub async fn recent_workspaces_on_disk(
// Returns the recent project workspaces suitable for showing in the recent-projects UI.
// Scratch workspaces (no paths) are filtered out - they aren't really "projects" and
// are restored separately by `last_session_workspace_locations`.
pub async fn recent_project_workspaces(
&self,
fs: &dyn Fs,
) -> Result<
@ -1947,11 +1958,9 @@ impl WorkspaceDb {
DateTime<Utc>,
)>,
> {
let mut result = Vec::new();
let mut workspaces_to_delete = Vec::new();
let remote_connections = self.remote_connections()?;
let now = Utc::now();
for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? {
let mut result = Vec::new();
for (id, paths, remote_connection_id, _session_id, timestamp) in self.recent_workspaces()? {
if let Some(remote_connection_id) = remote_connection_id {
if let Some(connection_options) = remote_connections.get(&remote_connection_id) {
result.push((
@ -1960,7 +1969,44 @@ impl WorkspaceDb {
paths,
timestamp,
));
} else {
}
continue;
}
if paths.paths().is_empty() || contains_wsl_path(&paths) {
continue;
}
if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
}
}
Ok(result)
}
// Deletes workspace rows that can no longer be restored from. Remote workspaces whose
// connection was removed, and (on Windows) workspaces pointing at WSL paths, are cleaned
// up immediately. Local workspaces with no valid paths on disk are kept for seven days
// after going stale. Workspaces belonging to the current session or the last session are
// always preserved so that an in-progress restore can rehydrate them.
pub async fn garbage_collect_workspaces(
&self,
fs: &dyn Fs,
current_session_id: &str,
last_session_id: Option<&str>,
) -> Result<()> {
let remote_connections = self.remote_connections()?;
let now = Utc::now();
let mut workspaces_to_delete = Vec::new();
for (id, paths, remote_connection_id, session_id, timestamp) in self.recent_workspaces()? {
if let Some(session_id) = session_id.as_deref() {
if session_id == current_session_id || Some(session_id) == last_session_id {
continue;
}
}
if let Some(remote_connection_id) = remote_connection_id {
if !remote_connections.contains_key(&remote_connection_id) {
workspaces_to_delete.push(id);
}
continue;
@ -1971,20 +2017,14 @@ impl WorkspaceDb {
// will wait for the WSL VM and file server to boot up. This can
// block for many seconds. Supported scenarios use remote
// workspaces.
if cfg!(windows) {
let has_wsl_path = paths
.paths()
.iter()
.any(|path| util::paths::WslPath::from_path(path).is_some());
if has_wsl_path {
workspaces_to_delete.push(id);
continue;
}
if contains_wsl_path(&paths) {
workspaces_to_delete.push(id);
continue;
}
if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
} else if now - timestamp >= chrono::Duration::days(7) {
if !Self::all_paths_exist_with_a_directory(paths.paths(), fs).await
&& now - timestamp >= chrono::Duration::days(7)
{
workspaces_to_delete.push(id);
}
}
@ -1995,7 +2035,7 @@ impl WorkspaceDb {
.map(|id| self.delete_workspace_by_id(id)),
)
.await;
Ok(result)
Ok(())
}
pub async fn last_workspace(
@ -2009,7 +2049,7 @@ impl WorkspaceDb {
DateTime<Utc>,
)>,
> {
Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
Ok(self.recent_project_workspaces(fs).await?.into_iter().next())
}
// Returns the locations of the workspaces that were still opened when the last
@ -2038,23 +2078,16 @@ impl WorkspaceDb {
paths,
window_id,
});
} else if paths.is_empty() {
// Empty workspace with items (drafts, files) - include for restoration
continue;
}
if paths.is_empty() || Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
workspaces.push(SessionWorkspace {
workspace_id,
location: SerializedWorkspaceLocation::Local,
paths,
window_id,
});
} else {
if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
workspaces.push(SessionWorkspace {
workspace_id,
location: SerializedWorkspaceLocation::Local,
paths,
window_id,
});
}
}
}
@ -2268,6 +2301,15 @@ impl WorkspaceDb {
}
}
#[cfg(test)]
query! {
pub(crate) async fn set_timestamp_for_tests(workspace_id: WorkspaceId, timestamp: String) -> Result<()> {
UPDATE workspaces
SET timestamp = ?2
WHERE workspace_id = ?1
}
}
query! {
pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> {
UPDATE workspaces
@ -3580,6 +3622,228 @@ mod tests {
);
}
fn pane_with_items(item_ids: &[ItemId]) -> SerializedPaneGroup {
SerializedPaneGroup::Pane(SerializedPane::new(
item_ids
.iter()
.map(|id| SerializedItem::new("Terminal", *id, true, false))
.collect(),
true,
0,
))
}
fn empty_pane_group() -> SerializedPaneGroup {
SerializedPaneGroup::Pane(SerializedPane::default())
}
fn workspace_with(
id: u64,
paths: &[&Path],
center_group: SerializedPaneGroup,
session_id: Option<&str>,
) -> SerializedWorkspace {
SerializedWorkspace {
id: WorkspaceId(id as i64),
paths: PathList::new(paths),
location: SerializedWorkspaceLocation::Local,
center_group,
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
bookmarks: Default::default(),
breakpoints: Default::default(),
centered_layout: false,
session_id: session_id.map(|s| s.to_owned()),
window_id: Some(id),
user_toolchains: Default::default(),
}
}
#[gpui::test]
async fn test_scratch_only_workspace_restores_from_last_session(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let db =
WorkspaceDb::open_test_db("test_scratch_only_workspace_restores_from_last_session")
.await;
db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), Some("s1")))
.await;
let sessions = db
.last_session_workspace_locations("s1", None, fs.as_ref())
.await
.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].workspace_id, WorkspaceId(1));
assert!(sessions[0].paths.is_empty());
let recents = db.recent_project_workspaces(fs.as_ref()).await.unwrap();
assert!(
recents.iter().all(|(id, ..)| *id != WorkspaceId(1)),
"scratch-only workspace must not appear in the recent-projects UI"
);
}
#[gpui::test]
async fn test_gc_preserves_scratch_inside_window(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let db = WorkspaceDb::open_test_db("test_gc_preserves_scratch_inside_window").await;
db.save_workspace(workspace_with(1, &[], empty_pane_group(), None))
.await;
db.garbage_collect_workspaces(fs.as_ref(), "current", None)
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_some(),
"fresh stale workspace must not be deleted before the 7-day window"
);
}
#[gpui::test]
async fn test_gc_deletes_stale_outside_window(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let db = WorkspaceDb::open_test_db("test_gc_deletes_stale_outside_window").await;
db.save_workspace(workspace_with(1, &[], empty_pane_group(), None))
.await;
db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
.await
.unwrap();
db.garbage_collect_workspaces(fs.as_ref(), "current", None)
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_none(),
"stale empty workspace older than the retention window must be deleted"
);
}
#[gpui::test]
async fn test_gc_preserves_directory_workspace_with_missing_path(
cx: &mut gpui::TestAppContext,
) {
let fs = fs::FakeFs::new(cx.executor());
let db =
WorkspaceDb::open_test_db("test_gc_preserves_directory_workspace_with_missing_path")
.await;
let missing_dir = PathBuf::from("/missing-project-dir");
db.save_workspace(workspace_with(
1,
&[missing_dir.as_path()],
empty_pane_group(),
None,
))
.await;
db.garbage_collect_workspaces(fs.as_ref(), "current", None)
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_some(),
"a stale workspace within the retention window must be kept"
);
db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
.await
.unwrap();
db.garbage_collect_workspaces(fs.as_ref(), "current", None)
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_none(),
"a stale workspace past the retention window must be deleted"
);
}
#[gpui::test]
async fn test_gc_preserves_current_and_last_sessions(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let db = WorkspaceDb::open_test_db("test_gc_preserves_current_and_last_sessions").await;
db.save_workspace(workspace_with(1, &[], empty_pane_group(), Some("current")))
.await;
db.save_workspace(workspace_with(2, &[], empty_pane_group(), Some("last")))
.await;
db.save_workspace(workspace_with(3, &[], empty_pane_group(), Some("stale")))
.await;
for id in [1, 2, 3] {
db.set_timestamp_for_tests(WorkspaceId(id), "2000-01-01 00:00:00".to_owned())
.await
.unwrap();
}
db.garbage_collect_workspaces(fs.as_ref(), "current", Some("last"))
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_some(),
"GC must not delete workspaces belonging to the current session"
);
assert!(
db.workspace_for_id(WorkspaceId(2)).is_some(),
"GC must not delete workspaces belonging to the last session"
);
assert!(
db.workspace_for_id(WorkspaceId(3)).is_none(),
"GC should still delete stale workspaces from other sessions"
);
}
#[gpui::test]
async fn test_gc_deletes_empty_workspace_with_items(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let db = WorkspaceDb::open_test_db("test_gc_deletes_empty_workspace_with_items").await;
db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), None))
.await;
db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
.await
.unwrap();
db.garbage_collect_workspaces(fs.as_ref(), "current", None)
.await
.unwrap();
assert!(
db.workspace_for_id(WorkspaceId(1)).is_none(),
"a stale empty-path workspace must be deleted regardless of its items"
);
}
#[gpui::test]
async fn test_last_session_restores_workspace_with_missing_paths(
cx: &mut gpui::TestAppContext,
) {
let fs = fs::FakeFs::new(cx.executor());
let db =
WorkspaceDb::open_test_db("test_last_session_restores_workspace_with_missing_paths")
.await;
let missing = PathBuf::from("/gone/file.rs");
db.save_workspace(workspace_with(
1,
&[missing.as_path()],
empty_pane_group(),
Some("s"),
))
.await;
let sessions = db
.last_session_workspace_locations("s", None, fs.as_ref())
.await
.unwrap();
assert!(
sessions.is_empty(),
"workspaces whose paths no longer exist on disk must not restore"
);
}
#[gpui::test]
async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());

View file

@ -271,7 +271,7 @@ impl WelcomePage {
cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
let Some(fs) = fs else { return };
let workspaces = db
.recent_workspaces_on_disk(fs.as_ref())
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();

View file

@ -21,7 +21,9 @@ use fs::{Fs, RealFs};
use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry;
use git_ui::clone::clone_and_open;
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
use gpui::{
App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, Task, UpdateGlobal as _,
};
use gpui_platform;
use gpui_tokio::Tokio;
@ -850,26 +852,47 @@ fn main() {
})
}
match open_rx
let (current_session_id, last_session_id) = {
let session = app_state.session.read(cx);
(
session.id().to_owned(),
session.last_session_id().map(|id| id.to_owned()),
)
};
let restore_task = match open_rx
.try_recv()
.ok()
.and_then(|request| OpenRequest::parse(request, cx).log_err())
{
Some(request) => {
handle_open_request(request, app_state.clone(), cx);
Task::ready(())
}
None => {
cx.spawn({
let app_state = app_state.clone();
async move |cx| {
if let Err(e) = restore_or_create_workspace(app_state, cx).await {
fail_to_open_window_async(e, cx)
}
None => cx.spawn({
let app_state = app_state.clone();
async move |cx| {
if let Err(e) = restore_or_create_workspace(app_state, cx).await {
fail_to_open_window_async(e, cx)
}
})
.detach();
}
}),
};
cx.spawn({
let db = workspace::WorkspaceDb::global(cx);
let fs = app_state.fs.clone();
async move |_cx| {
restore_task.await;
db.garbage_collect_workspaces(
fs.as_ref(),
&current_session_id,
last_session_id.as_deref(),
)
.await
}
}
})
.detach_and_log_err(cx);
let app_state = app_state.clone();

View file

@ -3028,6 +3028,10 @@ mod tests {
let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
let workspace_database_id = |window: WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).database_id())
};
let editor = window
.read_with(cx, |multi_workspace, cx| {
@ -3042,6 +3046,11 @@ mod tests {
.unwrap();
assert!(!window_is_edited(window, cx));
let initial_database_id = workspace_database_id(window, cx);
assert!(
initial_database_id.is_some(),
"a restored workspace must have a stable database id"
);
// Editing a buffer marks the window as edited.
window
@ -3087,6 +3096,11 @@ mod tests {
.unwrap()
});
assert!(window_is_edited(window, cx));
assert_eq!(
workspace_database_id(window, cx),
initial_database_id,
"the workspace must keep the same database id across a close/reopen cycle"
);
window
.update(cx, |multi_workspace, _, cx| {