Restore unsaved buffers on restart (#13546)

This adds the ability for Zed to restore unsaved buffers on restart. The
user is no longer prompted to save/discard/cancel when trying to close a
Zed window with dirty buffers in it. Instead those dirty buffers are
stored and restored on restart.

It does this by saving the contents of dirty buffers to the internal
SQLite database in which Zed stores other data too. On restart, if there
are dirty buffers in the database, they are restored.

On certain events (buffer changed, file saved, ...) Zed will serialize
these buffers, throttled to a 100ms, so that we don't overload the
machine by saving on every keystroke. When Zed quits, it waits until all
the buffers are serialized.


### Current limitations
- It does not persist undo-history (right now we don't persist/restore
undo-history regardless of dirty buffers or not)
- It does not restore buffers in windows without projects/worktrees.
Example: if you open a new window with `cmd-shift-n` and type something
in a buffer, this will _not_ be stored and you will be asked whether to
save/discard on quit. In the future, we want to fix this by also
restoring windows without projects/worktrees.

### Demo



https://github.com/user-attachments/assets/45c63237-8848-471f-8575-ac05496bba19



### Related tickets

I'm unsure about closing them, without also fixing the 2nd limitation:
restoring of worktree-less windows. So let's wait until that.

- https://github.com/zed-industries/zed/issues/4985
- https://github.com/zed-industries/zed/issues/4683

### Note on performance

- Serializing editing buffer (asynchronously on background thread) with
500k lines takes ~200ms on M3 Max. That's an extreme case and that
performance seems acceptable.

Release Notes:

- Added automatic restoring of unsaved buffers. Zed can now be closed
even if there are unsaved changes in buffers. One current limitation is
that this only works when having projects open, not single files or
empty windows with unsaved buffers. The feature can be turned off by
setting `{"session": {"restore_unsaved_buffers": false}}`.

---------

Co-authored-by: Bennet <bennet@zed.dev>
Co-authored-by: Antonio <antonio@zed.dev>
This commit is contained in:
Thorsten Ball 2024-07-17 18:10:20 +02:00 committed by GitHub
parent 8e9e94de22
commit 9241b11e1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1111 additions and 340 deletions

View file

@ -964,13 +964,16 @@ mod tests {
use editor::{display_map::DisplayRow, scroll::Autoscroll, DisplayPoint, Editor};
use gpui::{
actions, Action, AnyWindowHandle, AppContext, AssetSource, BorrowAppContext, Entity,
SemanticVersion, TestAppContext, VisualTestContext, WindowHandle,
SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle,
};
use language::{LanguageMatcher, LanguageRegistry};
use project::{Project, ProjectPath, WorktreeSettings};
use project::{project_settings::ProjectSettings, Project, ProjectPath, WorktreeSettings};
use serde_json::json;
use settings::{handle_settings_file_changes, watch_config_file, SettingsStore};
use std::path::{Path, PathBuf};
use std::{
path::{Path, PathBuf},
time::Duration,
};
use task::{RevealStrategy, SpawnInTerminal};
use theme::{ThemeRegistry, ThemeSettings};
use workspace::{
@ -1253,9 +1256,18 @@ mod tests {
}
#[gpui::test]
async fn test_window_edit_state(cx: &mut TestAppContext) {
async fn test_window_edit_state_restoring_disabled(cx: &mut TestAppContext) {
let executor = cx.executor();
let app_state = init_test(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<ProjectSettings>(cx, |settings| {
settings.session.restore_unsaved_buffers = false
});
});
});
app_state
.fs
.as_fake()
@ -1335,6 +1347,9 @@ mod tests {
close.await.unwrap();
assert!(!window_is_edited(window, cx));
// Advance the clock to ensure that the item has been serialized and dropped from the queue
cx.executor().advance_clock(Duration::from_secs(1));
// Opening the buffer again doesn't impact the window's edited state.
cx.update(|cx| {
open_paths(
@ -1346,6 +1361,22 @@ mod tests {
})
.await
.unwrap();
executor.run_until_parked();
window
.update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "hey");
});
})
.unwrap();
let editor = window
.read_with(cx, |workspace, cx| {
workspace
@ -1363,6 +1394,7 @@ mod tests {
editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
})
.unwrap();
executor.run_until_parked();
assert!(window_is_edited(window, cx));
// Ensure closing the window via the mouse gets preempted due to the
@ -1377,6 +1409,102 @@ mod tests {
assert_eq!(cx.update(|cx| cx.windows().len()), 0);
}
#[gpui::test]
async fn test_window_edit_state_restoring_enabled(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree("/root", json!({"a": "hey"}))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from("/root/a")],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// When opening the workspace, the window is not in a edited state.
let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().is_edited())
};
let editor = window
.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
})
.unwrap();
assert!(!window_is_edited(window, cx));
// Editing a buffer marks the window as edited.
window
.update(cx, |_, cx| {
editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
})
.unwrap();
assert!(window_is_edited(window, cx));
cx.run_until_parked();
// Advance the clock to make sure the workspace is serialized
cx.executor().advance_clock(Duration::from_secs(1));
// When closing the window, no prompt shows up and the window is closed.
// buffer having unsaved changes.
assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close());
cx.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 0);
// When we now reopen the window, the edited state and the edited buffer are back
cx.update(|cx| {
open_paths(
&[PathBuf::from("/root/a")],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
assert!(cx.update(|cx| cx.active_window().is_some()));
// When opening the workspace, the window is not in a edited state.
let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
assert!(window_is_edited(window, cx));
window
.update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.unwrap()
.downcast::<editor::Editor>()
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "EDIThey");
assert!(editor.is_dirty(cx));
});
editor
})
.unwrap();
}
#[gpui::test]
async fn test_new_empty_workspace(cx: &mut TestAppContext) {
let app_state = init_test(cx);
@ -2256,6 +2384,8 @@ mod tests {
assert!(workspace.active_item(cx).is_none());
})
.unwrap();
cx.run_until_parked();
editor_1.assert_released();
editor_2.assert_released();
buffer.assert_released();