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

@ -1,3 +1,5 @@
use anyhow::Result;
use db::sqlez::statement::Statement;
use std::path::PathBuf;
use db::sqlez_macros::sql;
@ -10,10 +12,12 @@ define_connection!(
// editors(
// item_id: usize,
// workspace_id: usize,
// path: PathBuf,
// path: Option<PathBuf>,
// scroll_top_row: usize,
// scroll_vertical_offset: f32,
// scroll_horizontal_offset: f32,
// content: Option<String>,
// language: Option<String>,
// )
pub static ref DB: EditorDb<WorkspaceDb> =
&[sql! (
@ -31,13 +35,39 @@ define_connection!(
ALTER TABLE editors ADD COLUMN scroll_top_row INTEGER NOT NULL DEFAULT 0;
ALTER TABLE editors ADD COLUMN scroll_horizontal_offset REAL NOT NULL DEFAULT 0;
ALTER TABLE editors ADD COLUMN scroll_vertical_offset REAL NOT NULL DEFAULT 0;
),
sql! (
// Since sqlite3 doesn't support ALTER COLUMN, we create a new
// table, move the data over, drop the old table, rename new table.
CREATE TABLE new_editors_tmp (
item_id INTEGER NOT NULL,
workspace_id INTEGER NOT NULL,
path BLOB, // <-- No longer "NOT NULL"
scroll_top_row INTEGER NOT NULL DEFAULT 0,
scroll_horizontal_offset REAL NOT NULL DEFAULT 0,
scroll_vertical_offset REAL NOT NULL DEFAULT 0,
contents TEXT, // New
language TEXT, // New
PRIMARY KEY(item_id, workspace_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE
) STRICT;
INSERT INTO new_editors_tmp(item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset)
SELECT item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
FROM editors;
DROP TABLE editors;
ALTER TABLE new_editors_tmp RENAME TO editors;
)];
);
impl EditorDb {
query! {
pub fn get_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
SELECT path FROM editors
pub fn get_path_and_contents(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(Option<PathBuf>, Option<String>, Option<String>)>> {
SELECT path, contents, language FROM editors
WHERE item_id = ? AND workspace_id = ?
}
}
@ -55,6 +85,20 @@ impl EditorDb {
}
}
query! {
pub async fn save_contents(item_id: ItemId, workspace: WorkspaceId, contents: Option<String>, language: Option<String>) -> Result<()> {
INSERT INTO editors
(item_id, workspace_id, contents, language)
VALUES
(?1, ?2, ?3, ?4)
ON CONFLICT DO UPDATE SET
item_id = ?1,
workspace_id = ?2,
contents = ?3,
language = ?4
}
}
// Returns the scroll top row, and offset
query! {
pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(u32, f32, f32)>> {
@ -80,4 +124,75 @@ impl EditorDb {
WHERE item_id = ?1 AND workspace_id = ?2
}
}
pub async fn delete_unloaded_items(
&self,
workspace: WorkspaceId,
alive_items: Vec<ItemId>,
) -> Result<()> {
let placeholders = alive_items
.iter()
.map(|_| "?")
.collect::<Vec<&str>>()
.join(", ");
let query = format!(
"DELETE FROM editors WHERE workspace_id = ? AND item_id NOT IN ({placeholders})"
);
self.write(move |conn| {
let mut statement = Statement::prepare(conn, query)?;
let mut next_index = statement.bind(&workspace, 1)?;
for id in alive_items {
next_index = statement.bind(&id, next_index)?;
}
statement.exec()
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui;
#[gpui::test]
async fn test_saving_content() {
env_logger::try_init().ok();
let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
// Sanity check: make sure there is no row in the `editors` table
assert_eq!(DB.get_path_and_contents(1234, workspace_id).unwrap(), None);
// Save content/language
DB.save_contents(
1234,
workspace_id,
Some("testing".into()),
Some("Go".into()),
)
.await
.unwrap();
// Check that it can be read from DB
let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap();
let (path, contents, language) = path_and_contents.unwrap();
assert!(path.is_none());
assert_eq!(contents, Some("testing".to_owned()));
assert_eq!(language, Some("Go".to_owned()));
// Update it with NULL
DB.save_contents(1234, workspace_id, None, None)
.await
.unwrap();
// Check that it worked
let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap();
let (path, contents, language) = path_and_contents.unwrap();
assert!(path.is_none());
assert!(contents.is_none());
assert!(language.is_none());
}
}