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:
parent
8e9e94de22
commit
9241b11e1f
20 changed files with 1111 additions and 340 deletions
|
@ -272,7 +272,8 @@ pub fn init(cx: &mut AppContext) {
|
|||
|
||||
workspace::register_project_item::<Editor>(cx);
|
||||
workspace::FollowableViewRegistry::register::<Editor>(cx);
|
||||
workspace::register_deserializable_item::<Editor>(cx);
|
||||
workspace::register_serializable_item::<Editor>(cx);
|
||||
|
||||
cx.observe_new_views(
|
||||
|workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
|
||||
workspace.register_action(Editor::new_file);
|
||||
|
@ -550,6 +551,7 @@ pub struct Editor {
|
|||
show_git_blame_inline: bool,
|
||||
show_git_blame_inline_delay_task: Option<Task<()>>,
|
||||
git_blame_inline_enabled: bool,
|
||||
serialize_dirty_buffers: bool,
|
||||
show_selection_menu: Option<bool>,
|
||||
blame: Option<Model<GitBlame>>,
|
||||
blame_subscription: Option<Subscription>,
|
||||
|
@ -1876,6 +1878,9 @@ impl Editor {
|
|||
show_selection_menu: None,
|
||||
show_git_blame_inline_delay_task: None,
|
||||
git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
|
||||
serialize_dirty_buffers: ProjectSettings::get_global(cx)
|
||||
.session
|
||||
.restore_unsaved_buffers,
|
||||
blame: None,
|
||||
blame_subscription: None,
|
||||
file_header_size,
|
||||
|
@ -11250,8 +11255,11 @@ impl Editor {
|
|||
self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
|
||||
self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
|
||||
|
||||
let project_settings = ProjectSettings::get_global(cx);
|
||||
self.serialize_dirty_buffers = project_settings.session.restore_unsaved_buffers;
|
||||
|
||||
if self.mode == EditorMode::Full {
|
||||
let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
|
||||
let inline_blame_enabled = project_settings.git.inline_blame_enabled();
|
||||
if self.git_blame_inline_enabled != inline_blame_enabled {
|
||||
self.toggle_git_blame_inline_internal(false, cx);
|
||||
}
|
||||
|
|
|
@ -16,10 +16,13 @@ use language::{
|
|||
proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, Point, SelectionGoal,
|
||||
};
|
||||
use multi_buffer::AnchorRangeExt;
|
||||
use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
|
||||
use project::{
|
||||
project_settings::ProjectSettings, search::SearchQuery, FormatTrigger, Item as _, Project,
|
||||
ProjectPath,
|
||||
};
|
||||
use rpc::proto::{self, update_view, PeerId};
|
||||
use settings::Settings;
|
||||
use workspace::item::{Dedup, ItemSettings, TabContentParams};
|
||||
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
|
||||
|
||||
use std::{
|
||||
any::TypeId,
|
||||
|
@ -36,7 +39,7 @@ use ui::{h_flex, prelude::*, Label};
|
|||
use util::{paths::PathExt, ResultExt, TryFutureExt};
|
||||
use workspace::item::{BreadcrumbText, FollowEvent};
|
||||
use workspace::{
|
||||
item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
|
||||
item::{FollowableItem, Item, ItemEvent, ProjectItem},
|
||||
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
|
||||
ItemId, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
|
||||
};
|
||||
|
@ -837,54 +840,8 @@ impl Item for Editor {
|
|||
Some(breadcrumbs)
|
||||
}
|
||||
|
||||
fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
|
||||
fn added_to_workspace(&mut self, workspace: &mut Workspace, _: &mut ViewContext<Self>) {
|
||||
self.workspace = Some((workspace.weak_handle(), workspace.database_id()));
|
||||
let Some(workspace_id) = workspace.database_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item_id = cx.view().item_id().as_u64() as ItemId;
|
||||
|
||||
fn serialize(
|
||||
buffer: Model<Buffer>,
|
||||
workspace_id: WorkspaceId,
|
||||
item_id: ItemId,
|
||||
cx: &mut AppContext,
|
||||
) {
|
||||
if let Some(file) = buffer.read(cx).file().and_then(|file| file.as_local()) {
|
||||
let path = file.abs_path(cx);
|
||||
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
DB.save_path(item_id, workspace_id, path.clone())
|
||||
.await
|
||||
.log_err()
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(buffer) = self.buffer().read(cx).as_singleton() {
|
||||
serialize(buffer.clone(), workspace_id, item_id, cx);
|
||||
|
||||
cx.subscribe(&buffer, |this, buffer, event, cx| {
|
||||
if let Some((_, Some(workspace_id))) = this.workspace.as_ref() {
|
||||
if let language::Event::FileHandleChanged = event {
|
||||
serialize(
|
||||
buffer,
|
||||
*workspace_id,
|
||||
cx.view().item_id().as_u64() as ItemId,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn serialized_item_kind() -> Option<&'static str> {
|
||||
Some("Editor")
|
||||
}
|
||||
|
||||
fn to_item_events(event: &EditorEvent, mut f: impl FnMut(ItemEvent)) {
|
||||
|
@ -920,6 +877,20 @@ impl Item for Editor {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SerializableItem for Editor {
|
||||
fn serialized_item_kind() -> &'static str {
|
||||
"Editor"
|
||||
}
|
||||
|
||||
fn cleanup(
|
||||
workspace_id: WorkspaceId,
|
||||
alive_items: Vec<ItemId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Task<Result<()>> {
|
||||
cx.spawn(|_| DB.delete_unloaded_items(workspace_id, alive_items))
|
||||
}
|
||||
|
||||
fn deserialize(
|
||||
project: Model<Project>,
|
||||
|
@ -928,41 +899,171 @@ impl Item for Editor {
|
|||
item_id: ItemId,
|
||||
cx: &mut ViewContext<Pane>,
|
||||
) -> Task<Result<View<Self>>> {
|
||||
let project_item: Result<_> = project.update(cx, |project, cx| {
|
||||
// Look up the path with this key associated, create a self with that path
|
||||
let path = DB
|
||||
.get_path(item_id, workspace_id)?
|
||||
.context("No path stored for this editor")?;
|
||||
let path_content_language = match DB
|
||||
.get_path_and_contents(item_id, workspace_id)
|
||||
.context("Failed to query editor state")
|
||||
{
|
||||
Ok(Some((path, content, language))) => {
|
||||
if ProjectSettings::get_global(cx)
|
||||
.session
|
||||
.restore_unsaved_buffers
|
||||
{
|
||||
(path, content, language)
|
||||
} else {
|
||||
(path, None, None)
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Task::ready(Err(anyhow!("No path or contents found for buffer")));
|
||||
}
|
||||
Err(error) => {
|
||||
return Task::ready(Err(error));
|
||||
}
|
||||
};
|
||||
|
||||
let (worktree, path) = project
|
||||
.find_worktree(&path, cx)
|
||||
.with_context(|| format!("No worktree for path: {path:?}"))?;
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: worktree.read(cx).id(),
|
||||
path: path.into(),
|
||||
};
|
||||
match path_content_language {
|
||||
(None, Some(content), language_name) => cx.spawn(|_, mut cx| async move {
|
||||
let language = if let Some(language_name) = language_name {
|
||||
let language_registry =
|
||||
project.update(&mut cx, |project, _| project.languages().clone())?;
|
||||
|
||||
Ok(project.open_path(project_path, cx))
|
||||
});
|
||||
Some(language_registry.language_for_name(&language_name).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
project_item
|
||||
.map(|project_item| {
|
||||
cx.spawn(|pane, mut cx| async move {
|
||||
let (_, project_item) = project_item.await?;
|
||||
let buffer = project_item
|
||||
.downcast::<Buffer>()
|
||||
.map_err(|_| anyhow!("Project item at stored path was not a buffer"))?;
|
||||
pane.update(&mut cx, |_, cx| {
|
||||
cx.new_view(|cx| {
|
||||
let mut editor = Editor::for_buffer(buffer, Some(project), cx);
|
||||
// First create the empty buffer
|
||||
let buffer = project.update(&mut cx, |project, cx| {
|
||||
project.create_local_buffer("", language, cx)
|
||||
})?;
|
||||
|
||||
editor.read_scroll_position_from_db(item_id, workspace_id, cx);
|
||||
editor
|
||||
// Then set the text so that the dirty bit is set correctly
|
||||
buffer.update(&mut cx, |buffer, cx| {
|
||||
buffer.set_text(content, cx);
|
||||
})?;
|
||||
|
||||
cx.new_view(|cx| {
|
||||
let mut editor = Editor::for_buffer(buffer, Some(project), cx);
|
||||
editor.read_scroll_position_from_db(item_id, workspace_id, cx);
|
||||
editor
|
||||
})
|
||||
}),
|
||||
(Some(path), contents, _) => {
|
||||
let project_item = project.update(cx, |project, cx| {
|
||||
let (worktree, path) = project
|
||||
.find_worktree(&path, cx)
|
||||
.with_context(|| format!("No worktree for path: {path:?}"))?;
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: worktree.read(cx).id(),
|
||||
path: path.into(),
|
||||
};
|
||||
|
||||
Ok(project.open_path(project_path, cx))
|
||||
});
|
||||
|
||||
project_item
|
||||
.map(|project_item| {
|
||||
cx.spawn(|pane, mut cx| async move {
|
||||
let (_, project_item) = project_item.await?;
|
||||
let buffer = project_item.downcast::<Buffer>().map_err(|_| {
|
||||
anyhow!("Project item at stored path was not a buffer")
|
||||
})?;
|
||||
|
||||
// This is a bit wasteful: we're loading the whole buffer from
|
||||
// disk and then overwrite the content.
|
||||
// But for now, it keeps the implementation of the content serialization
|
||||
// simple, because we don't have to persist all of the metadata that we get
|
||||
// by loading the file (git diff base, mtime, ...).
|
||||
if let Some(buffer_text) = contents {
|
||||
buffer.update(&mut cx, |buffer, cx| {
|
||||
buffer.set_text(buffer_text, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
pane.update(&mut cx, |_, cx| {
|
||||
cx.new_view(|cx| {
|
||||
let mut editor = Editor::for_buffer(buffer, Some(project), cx);
|
||||
|
||||
editor.read_scroll_position_from_db(item_id, workspace_id, cx);
|
||||
editor
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|error| Task::ready(Err(error)))
|
||||
}
|
||||
_ => Task::ready(Err(anyhow!("No path or contents found for buffer"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(
|
||||
&mut self,
|
||||
workspace: &mut Workspace,
|
||||
item_id: ItemId,
|
||||
closing: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<Task<Result<()>>> {
|
||||
let mut serialize_dirty_buffers = self.serialize_dirty_buffers;
|
||||
|
||||
let project = self.project.clone()?;
|
||||
if project.read(cx).visible_worktrees(cx).next().is_none() {
|
||||
// If we don't have a worktree, we don't serialize, because
|
||||
// projects without worktrees aren't deserialized.
|
||||
serialize_dirty_buffers = false;
|
||||
}
|
||||
|
||||
if closing && !serialize_dirty_buffers {
|
||||
return None;
|
||||
}
|
||||
|
||||
let workspace_id = workspace.database_id()?;
|
||||
|
||||
let buffer = self.buffer().read(cx).as_singleton()?;
|
||||
|
||||
let is_dirty = buffer.read(cx).is_dirty();
|
||||
let path = buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.and_then(|file| file.as_local())
|
||||
.map(|file| file.abs_path(cx));
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
|
||||
Some(cx.spawn(|_this, cx| async move {
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
if let Some(path) = path {
|
||||
DB.save_path(item_id, workspace_id, path.clone())
|
||||
.await
|
||||
.context("failed to save path of buffer")?
|
||||
}
|
||||
|
||||
if serialize_dirty_buffers {
|
||||
let (contents, language) = if is_dirty {
|
||||
let contents = snapshot.text();
|
||||
let language = snapshot.language().map(|lang| lang.name().to_string());
|
||||
(Some(contents), language)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
DB.save_contents(item_id, workspace_id, contents, language)
|
||||
.await?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|error| Task::ready(Err(error)))
|
||||
.await
|
||||
.context("failed to save contents of buffer")?;
|
||||
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
fn should_serialize(&self, event: &Self::Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
EditorEvent::Saved | EditorEvent::DirtyChanged | EditorEvent::BufferEdited
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue