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,6 +1,7 @@
use anyhow::Result;
use std::path::PathBuf;
use db::{define_connection, query, sqlez_macros::sql};
use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
define_connection! {
@ -68,4 +69,30 @@ impl TerminalDb {
WHERE item_id = ? AND workspace_id = ?
}
}
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 terminals 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
}
}

View file

@ -26,10 +26,10 @@ use ui::{
use util::{ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
item::Item,
item::SerializableItem,
pane,
ui::IconName,
DraggedTab, NewTerminal, Pane, ToggleZoom, Workspace,
DraggedTab, ItemId, NewTerminal, Pane, ToggleZoom, Workspace,
};
use anyhow::Result;
@ -278,6 +278,7 @@ impl TerminalPanel {
let pane = pane.downgrade();
let items = futures::future::join_all(items).await;
let mut alive_item_ids = Vec::new();
pane.update(&mut cx, |pane, cx| {
let active_item_id = serialized_panel
.as_ref()
@ -287,6 +288,7 @@ impl TerminalPanel {
if let Some(item) = item.log_err() {
let item_id = item.entity_id().as_u64();
pane.add_item(Box::new(item), false, false, None, cx);
alive_item_ids.push(item_id as ItemId);
if Some(item_id) == active_item_id {
active_ix = Some(pane.items_len() - 1);
}
@ -298,6 +300,18 @@ impl TerminalPanel {
}
})?;
// Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace.
if let Some(workspace) = workspace.upgrade() {
let cleanup_task = workspace.update(&mut cx, |workspace, cx| {
workspace
.database_id()
.map(|workspace_id| TerminalView::cleanup(workspace_id, alive_item_ids, cx))
})?;
if let Some(task) = cleanup_task {
task.await.log_err();
}
}
Ok(panel)
}

View file

@ -29,9 +29,9 @@ use terminal_element::{is_blank, TerminalElement};
use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip};
use util::{paths::PathLikeWithPosition, ResultExt};
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, TabContentParams},
item::{BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams},
notifications::NotifyResultExt,
register_deserializable_item,
register_serializable_item,
searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
CloseActiveItem, NewCenterTerminal, OpenVisible, Pane, ToolbarItemLocation, Workspace,
WorkspaceId,
@ -73,7 +73,7 @@ pub fn init(cx: &mut AppContext) {
terminal_panel::init(cx);
terminal::init(cx);
register_deserializable_item::<TerminalView>(cx);
register_serializable_item::<TerminalView>(cx);
cx.observe_new_views(|workspace: &mut Workspace, _| {
workspace.register_action(TerminalView::deploy);
@ -612,22 +612,6 @@ fn subscribe_for_terminal_events(
Event::TitleChanged => {
cx.emit(ItemEvent::UpdateTab);
let terminal = this.terminal().read(cx);
if terminal.task().is_none() {
if let Some(cwd) = terminal.get_cwd() {
let item_id = cx.entity_id();
if let Some(workspace_id) = this.workspace_id {
cx.background_executor()
.spawn(async move {
TERMINAL_DB
.save_working_directory(item_id.as_u64(), workspace_id, cwd)
.await
.log_err();
})
.detach();
}
}
}
}
Event::NewNavigationTarget(maybe_navigation_target) => {
@ -1072,8 +1056,60 @@ impl Item for TerminalView {
}])
}
fn serialized_item_kind() -> Option<&'static str> {
Some("Terminal")
fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
if self.terminal().read(cx).task().is_none() {
if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
cx.background_executor()
.spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64()))
.detach();
}
self.workspace_id = workspace.database_id();
}
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
f(*event)
}
}
impl SerializableItem for TerminalView {
fn serialized_item_kind() -> &'static str {
"Terminal"
}
fn cleanup(
workspace_id: WorkspaceId,
alive_items: Vec<workspace::ItemId>,
cx: &mut WindowContext,
) -> Task<gpui::Result<()>> {
cx.spawn(|_| TERMINAL_DB.delete_unloaded_items(workspace_id, alive_items))
}
fn serialize(
&mut self,
_workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
cx: &mut ViewContext<Self>,
) -> Option<Task<gpui::Result<()>>> {
let terminal = self.terminal().read(cx);
if terminal.task().is_some() {
return None;
}
if let Some((cwd, workspace_id)) = terminal.get_cwd().zip(self.workspace_id) {
Some(cx.background_executor().spawn(async move {
TERMINAL_DB
.save_working_directory(item_id, workspace_id, cwd)
.await
}))
} else {
None
}
}
fn should_serialize(&self, event: &Self::Event) -> bool {
matches!(event, ItemEvent::UpdateTab)
}
fn deserialize(
@ -1116,21 +1152,6 @@ impl Item for TerminalView {
})
})
}
fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
if self.terminal().read(cx).task().is_none() {
if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
cx.background_executor()
.spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64()))
.detach();
}
self.workspace_id = workspace.database_id();
}
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
f(*event)
}
}
impl SearchableItem for TerminalView {