Persist editor folds between restarts (#27252)

Kirill Bulatov created

Part of https://github.com/zed-industries/zed/issues/11626


https://github.com/user-attachments/assets/276cca5f-dd87-4496-b1b8-40b211f65aa7

Folds restoration between editor reopens will follow later

Release Notes:

- Started to persist editor folds between restarts

Change summary

crates/editor/src/editor.rs      | 99 +++++++++++++++++++++++++++------
crates/editor/src/items.rs       | 19 +-----
crates/editor/src/persistence.rs | 90 ++++++++++++++++++++++++++++++
3 files changed, 173 insertions(+), 35 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -123,7 +123,7 @@ pub use proposed_changes_editor::{
     ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
 };
 use smallvec::smallvec;
-use std::iter::Peekable;
+use std::{cell::OnceCell, iter::Peekable};
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
 
 pub use lsp::CompletionContext;
@@ -188,7 +188,7 @@ use ui::{
 use util::{maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::{
     item::{ItemHandle, PreviewTabsSettings},
-    ItemId, RestoreOnStartupBehavior,
+    ItemId, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME,
 };
 use workspace::{
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
@@ -777,6 +777,7 @@ pub struct Editor {
     toggle_fold_multiple_buffers: Task<()>,
     _scroll_cursor_center_top_bottom_task: Task<()>,
     serialize_selections: Task<()>,
+    serialize_folds: Task<()>,
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
@@ -1183,6 +1184,7 @@ impl Editor {
                 display_map.set_state(&snapshot, cx);
             });
         });
+        clone.folds_did_change(cx);
         clone.selections.clone_state(&self.selections);
         clone.scroll_manager.clone_state(&self.scroll_manager);
         clone.searchable = self.searchable;
@@ -1514,6 +1516,7 @@ impl Editor {
             selection_mark_mode: false,
             toggle_fold_multiple_buffers: Task::ready(()),
             serialize_selections: Task::ready(()),
+            serialize_folds: Task::ready(()),
             text_style_refinement: None,
             load_diff_task: load_uncommitted_diff,
         };
@@ -2254,7 +2257,7 @@ impl Editor {
                 let snapshot = self.buffer().read(cx).snapshot(cx);
                 let selections = selections.clone();
                 self.serialize_selections = cx.background_spawn(async move {
-                    background_executor.timer(Duration::from_millis(100)).await;
+                    background_executor.timer(SERIALIZATION_THROTTLE_TIME).await;
                     let selections = selections
                         .iter()
                         .map(|selection| {
@@ -2275,6 +2278,40 @@ impl Editor {
         cx.notify();
     }
 
+    fn folds_did_change(&mut self, cx: &mut Context<Self>) {
+        if !self.is_singleton(cx)
+            || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None
+        {
+            return;
+        }
+
+        let Some(workspace_id) = self.workspace.as_ref().and_then(|workspace| workspace.1) else {
+            return;
+        };
+        let background_executor = cx.background_executor().clone();
+        let editor_id = cx.entity().entity_id().as_u64() as ItemId;
+        let snapshot = self.buffer().read(cx).snapshot(cx);
+        let folds = self.display_map.update(cx, |display_map, cx| {
+            display_map
+                .snapshot(cx)
+                .folds_in_range(0..snapshot.len())
+                .map(|fold| {
+                    (
+                        fold.range.start.to_offset(&snapshot),
+                        fold.range.end.to_offset(&snapshot),
+                    )
+                })
+                .collect()
+        });
+        self.serialize_folds = cx.background_spawn(async move {
+            background_executor.timer(SERIALIZATION_THROTTLE_TIME).await;
+            DB.save_editor_folds(editor_id, workspace_id, folds)
+                .await
+                .with_context(|| format!("persisting editor folds for editor {editor_id}, workspace {workspace_id:?}"))
+                .log_err();
+        });
+    }
+
     pub fn sync_selections(
         &mut self,
         other: Entity<Editor>,
@@ -14358,6 +14395,7 @@ impl Editor {
         }
 
         self.scrollbar_marker_state.dirty = true;
+        self.folds_did_change(cx);
     }
 
     /// Removes any folds whose ranges intersect any of the given ranges.
@@ -14371,6 +14409,7 @@ impl Editor {
         self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| {
             map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx)
         });
+        self.folds_did_change(cx);
     }
 
     pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
@@ -14422,6 +14461,7 @@ impl Editor {
         self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| {
             map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx)
         });
+        self.folds_did_change(cx);
     }
 
     fn remove_folds_with<T: ToOffset + Clone>(
@@ -17144,31 +17184,52 @@ impl Editor {
         self.load_diff_task.clone()
     }
 
-    fn read_selections_from_db(
+    fn read_metadata_from_db(
         &mut self,
         item_id: u64,
         workspace_id: WorkspaceId,
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
-        if !self.is_singleton(cx)
-            || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None
+        if self.is_singleton(cx)
+            && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
         {
-            return;
-        }
-        let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() else {
-            return;
-        };
-        if selections.is_empty() {
-            return;
+            let buffer_snapshot = OnceCell::new();
+
+            if let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() {
+                if !selections.is_empty() {
+                    let snapshot =
+                        buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx));
+                    self.change_selections(None, window, cx, |s| {
+                        s.select_ranges(selections.into_iter().map(|(start, end)| {
+                            snapshot.clip_offset(start, Bias::Left)
+                                ..snapshot.clip_offset(end, Bias::Right)
+                        }));
+                    });
+                }
+            };
+
+            if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err() {
+                if !folds.is_empty() {
+                    let snapshot =
+                        buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx));
+                    self.fold_ranges(
+                        folds
+                            .into_iter()
+                            .map(|(start, end)| {
+                                snapshot.clip_offset(start, Bias::Left)
+                                    ..snapshot.clip_offset(end, Bias::Right)
+                            })
+                            .collect(),
+                        false,
+                        window,
+                        cx,
+                    );
+                }
+            }
         }
 
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        self.change_selections(None, window, cx, |s| {
-            s.select_ranges(selections.into_iter().map(|(start, end)| {
-                snapshot.clip_offset(start, Bias::Left)..snapshot.clip_offset(end, Bias::Right)
-            }));
-        });
+        self.read_scroll_position_from_db(item_id, workspace_id, window, cx);
     }
 }
 

crates/editor/src/items.rs 🔗

@@ -1078,8 +1078,7 @@ impl SerializableItem for Editor {
                         cx.new(|cx| {
                             let mut editor = Editor::for_buffer(buffer, Some(project), window, cx);
 
-                            editor.read_selections_from_db(item_id, workspace_id, window, cx);
-                            editor.read_scroll_position_from_db(item_id, workspace_id, window, cx);
+                            editor.read_metadata_from_db(item_id, workspace_id, window, cx);
                             editor
                         })
                     })
@@ -1137,18 +1136,7 @@ impl SerializableItem for Editor {
                                     let mut editor =
                                         Editor::for_buffer(buffer, Some(project), window, cx);
 
-                                    editor.read_selections_from_db(
-                                        item_id,
-                                        workspace_id,
-                                        window,
-                                        cx,
-                                    );
-                                    editor.read_scroll_position_from_db(
-                                        item_id,
-                                        workspace_id,
-                                        window,
-                                        cx,
-                                    );
+                                    editor.read_metadata_from_db(item_id, workspace_id, window, cx);
                                     editor
                                 })
                             })
@@ -1169,8 +1157,7 @@ impl SerializableItem for Editor {
                         window.spawn(cx, async move |cx| {
                             let editor = open_by_abs_path?.await?.downcast::<Editor>().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?;
                             editor.update_in(cx, |editor, window, cx| {
-                                editor.read_selections_from_db(item_id, workspace_id, window, cx);
-                                editor.read_scroll_position_from_db(item_id, workspace_id, window, cx);
+                                editor.read_metadata_from_db(item_id, workspace_id, window, cx);
                             })?;
                             Ok(editor)
                         })

crates/editor/src/persistence.rs 🔗

@@ -97,6 +97,22 @@ define_connection!(
     //   mtime_seconds: Option<i64>,
     //   mtime_nanos: Option<i32>,
     // )
+    //
+    // editor_selections(
+    //   item_id: usize,
+    //   editor_id: usize,
+    //   workspace_id: usize,
+    //   start: usize,
+    //   end: usize,
+    // )
+    //
+    // editor_folds(
+    //   item_id: usize,
+    //   editor_id: usize,
+    //   workspace_id: usize,
+    //   start: usize,
+    //   end: usize,
+    // )
     pub static ref DB: EditorDb<WorkspaceDb> = &[
         sql! (
             CREATE TABLE editors(
@@ -160,6 +176,18 @@ define_connection!(
             ALTER TABLE editors ADD COLUMN buffer_path TEXT;
             UPDATE editors SET buffer_path = CAST(path AS TEXT);
         ),
+        sql! (
+            CREATE TABLE editor_folds (
+                item_id INTEGER NOT NULL,
+                editor_id INTEGER NOT NULL,
+                workspace_id INTEGER NOT NULL,
+                start INTEGER NOT NULL,
+                end INTEGER NOT NULL,
+                PRIMARY KEY(item_id),
+                FOREIGN KEY(editor_id, workspace_id) REFERENCES editors(item_id, workspace_id)
+                ON DELETE CASCADE
+            ) STRICT;
+        ),
     ];
 );
 
@@ -231,6 +259,17 @@ impl EditorDb {
         }
     }
 
+    query! {
+        pub fn get_editor_folds(
+            editor_id: ItemId,
+            workspace_id: WorkspaceId
+        ) -> Result<Vec<(usize, usize)>> {
+            SELECT start, end
+            FROM editor_folds
+            WHERE editor_id = ?1 AND workspace_id = ?2
+        }
+    }
+
     pub async fn save_editor_selections(
         &self,
         editor_id: ItemId,
@@ -282,6 +321,57 @@ VALUES {placeholders};
         Ok(())
     }
 
+    pub async fn save_editor_folds(
+        &self,
+        editor_id: ItemId,
+        workspace_id: WorkspaceId,
+        folds: Vec<(usize, usize)>,
+    ) -> Result<()> {
+        let mut first_fold;
+        let mut last_fold = 0_usize;
+        for (count, placeholders) in std::iter::once("(?1, ?2, ?, ?)")
+            .cycle()
+            .take(folds.len())
+            .chunks(MAX_QUERY_PLACEHOLDERS / 4)
+            .into_iter()
+            .map(|chunk| {
+                let mut count = 0;
+                let placeholders = chunk
+                    .inspect(|_| {
+                        count += 1;
+                    })
+                    .join(", ");
+                (count, placeholders)
+            })
+            .collect::<Vec<_>>()
+        {
+            first_fold = last_fold;
+            last_fold = last_fold + count;
+            let query = format!(
+                r#"
+DELETE FROM editor_folds WHERE editor_id = ?1 AND workspace_id = ?2;
+
+INSERT OR IGNORE INTO editor_folds (editor_id, workspace_id, start, end)
+VALUES {placeholders};
+"#
+            );
+
+            let folds = folds[first_fold..last_fold].to_vec();
+            self.write(move |conn| {
+                let mut statement = Statement::prepare(conn, query)?;
+                statement.bind(&editor_id, 1)?;
+                let mut next_index = statement.bind(&workspace_id, 2)?;
+                for (start, end) in folds {
+                    next_index = statement.bind(&start, next_index)?;
+                    next_index = statement.bind(&end, next_index)?;
+                }
+                statement.exec()
+            })
+            .await?;
+        }
+        Ok(())
+    }
+
     pub async fn delete_unloaded_items(
         &self,
         workspace: WorkspaceId,