editor: Store folds by file path for persistence across tab close (#47698)

Brandt Weary and Hector created

## Summary

Extends #46011 to make folds survive tab close and workspace cleanup.

- Adds `file_folds` table keyed by `(workspace_id, path)` instead of
`editor_id`
- Follows the `breakpoints` table pattern in
`workspace/src/persistence.rs`
- Includes backwards-compatible migration from `editor_folds` on first
read

cc @Veykril - you reviewed the original fold persistence PR, this
extends it to handle the tab-close case.

## Problem

Folds stored by `editor_id` get deleted when:
1. User closes a tab
2. Tab is removed from workspace serialization
3. On next Zed start, `cleanup()` deletes editor rows not in
`loaded_items`
4. `ON DELETE CASCADE` wipes associated folds
5. User manually reopens file → folds gone

This is especially painful for PKM/notes workflows where folds represent
permanent document structure (collapsed sections, reference blocks,
etc).

## Solution

Store folds by file path, not editor ID. The `breakpoints` table already
proves this pattern works in Zed - breakpoints persist across tab close
because they're keyed by path.

## Migration

The PR includes backwards-compatible migration: reads from `file_folds`
first, falls back to `get_editor_folds()`, and migrates old data on
first read. Happy to remove this if you'd prefer a clean break - I'd
delete `get_editor_folds()`, remove the fallback logic in
`read_metadata_from_db()`, and add a `DROP TABLE IF EXISTS editor_folds`
migration. Users would lose any folds saved before the update.

## Test Plan

- [x] Unit test for `file_folds` queries in `persistence.rs`
- [x] Manual test: Open file → fold → close tab → quit Zed → reopen →
manually open file → folds restored

Release Notes:

- N/A

Co-authored-by: Hector <hector@cyberneticwilderness.com>

Change summary

crates/editor/src/editor.rs      | 189 +++++++++++++++++++++++++++---
crates/editor/src/items.rs       |  54 ++++++--
crates/editor/src/persistence.rs | 205 +++++++++++++++++----------------
3 files changed, 311 insertions(+), 137 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -132,7 +132,7 @@ use language::{
     AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
     BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
     DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
-    IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt,
+    IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
     OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
     TreeSitterOptions, WordsQuery,
     language_settings::{
@@ -3734,8 +3734,16 @@ impl Editor {
         let Some(workspace_id) = self.workspace_serialization_id(cx) else {
             return;
         };
+
+        // Get file path for path-based fold storage (survives tab close)
+        let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| {
+            project::File::from_dyn(buffer.read(cx).file())
+                .map(|file| Arc::<Path>::from(file.abs_path(cx)))
+        }) else {
+            return;
+        };
+
         let background_executor = cx.background_executor().clone();
-        let editor_id = cx.entity().entity_id().as_u64() as ItemId;
         const FINGERPRINT_LEN: usize = 32;
         let db_folds = display_snapshot
             .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len())
@@ -3762,14 +3770,20 @@ impl Editor {
             .collect::<Vec<_>>();
         self.serialize_folds = cx.background_spawn(async move {
             background_executor.timer(SERIALIZATION_THROTTLE_TIME).await;
-            DB.save_editor_folds(editor_id, workspace_id, db_folds)
-                .await
-                .with_context(|| {
-                    format!(
-                        "persisting editor folds for editor {editor_id}, workspace {workspace_id:?}"
-                    )
-                })
-                .log_err();
+            if db_folds.is_empty() {
+                // No folds - delete any persisted folds for this file
+                DB.delete_file_folds(workspace_id, file_path)
+                    .await
+                    .with_context(|| format!("deleting file folds for workspace {workspace_id:?}"))
+                    .log_err();
+            } else {
+                DB.save_file_folds(workspace_id, file_path, db_folds)
+                    .await
+                    .with_context(|| {
+                        format!("persisting file folds for workspace {workspace_id:?}")
+                    })
+                    .log_err();
+            }
         });
     }
 
@@ -25235,9 +25249,34 @@ impl Editor {
         {
             let buffer_snapshot = OnceCell::new();
 
-            if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err()
-                && !folds.is_empty()
-            {
+            // Get file path for path-based fold lookup
+            let file_path: Option<Arc<Path>> =
+                self.buffer().read(cx).as_singleton().and_then(|buffer| {
+                    project::File::from_dyn(buffer.read(cx).file())
+                        .map(|file| Arc::from(file.abs_path(cx)))
+                });
+
+            // Try file_folds (path-based) first, fallback to editor_folds (migration)
+            let (folds, needs_migration) = if let Some(ref path) = file_path {
+                if let Some(folds) = DB.get_file_folds(workspace_id, path).log_err()
+                    && !folds.is_empty()
+                {
+                    (Some(folds), false)
+                } else if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err()
+                    && !folds.is_empty()
+                {
+                    // Found old editor_folds data, will migrate to file_folds
+                    (Some(folds), true)
+                } else {
+                    (None, false)
+                }
+            } else {
+                // No file path, try editor_folds as fallback
+                let folds = DB.get_editor_folds(item_id, workspace_id).log_err();
+                (folds.filter(|f| !f.is_empty()), false)
+            };
+
+            if let Some(folds) = folds {
                 let snapshot = buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx));
                 let snapshot_len = snapshot.len().0;
 
@@ -25266,6 +25305,9 @@ impl Editor {
                 // Folds are stored in document order, so we advance after each match.
                 let mut search_start = 0usize;
 
+                // Collect db_folds for migration (only folds with valid fingerprints)
+                let mut db_folds_for_migration: Vec<(usize, usize, String, String)> = Vec::new();
+
                 let valid_folds: Vec<_> = folds
                     .into_iter()
                     .filter_map(|(stored_start, stored_end, start_fp, end_fp)| {
@@ -25310,6 +25352,11 @@ impl Editor {
                             return None;
                         }
 
+                        // Collect for migration if needed
+                        if needs_migration {
+                            db_folds_for_migration.push((new_start, new_end, sfp, efp));
+                        }
+
                         Some(
                             snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left)
                                 ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right),
@@ -25320,17 +25367,17 @@ impl Editor {
                 if !valid_folds.is_empty() {
                     self.fold_ranges(valid_folds, false, window, cx);
 
-                    // Migrate folds to current entity_id before workspace cleanup runs.
-                    // Entity IDs change between sessions, but workspace cleanup deletes
-                    // old editor rows (cascading to folds) based on current entity IDs.
-                    let new_editor_id = cx.entity().entity_id().as_u64() as ItemId;
-                    if new_editor_id != item_id {
-                        cx.spawn(async move |_, _| {
-                            DB.migrate_editor_folds(item_id, new_editor_id, workspace_id)
-                                .await
-                                .log_err();
-                        })
-                        .detach();
+                    // Migrate from editor_folds to file_folds if we loaded from old table
+                    if needs_migration {
+                        if let Some(ref path) = file_path {
+                            let path = path.clone();
+                            cx.spawn(async move |_, _| {
+                                DB.save_file_folds(workspace_id, path, db_folds_for_migration)
+                                    .await
+                                    .log_err();
+                            })
+                            .detach();
+                        }
                     }
                 }
             }
@@ -25354,6 +25401,100 @@ impl Editor {
         self.read_scroll_position_from_db(item_id, workspace_id, window, cx);
     }
 
+    /// Load folds from the file_folds database table by file path.
+    /// Used when manually opening a file that was previously closed.
+    fn load_folds_from_db(
+        &mut self,
+        workspace_id: WorkspaceId,
+        file_path: PathBuf,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        if self.mode.is_minimap()
+            || WorkspaceSettings::get(None, cx).restore_on_startup
+                == RestoreOnStartupBehavior::EmptyTab
+        {
+            return;
+        }
+
+        let Some(folds) = DB.get_file_folds(workspace_id, &file_path).log_err() else {
+            return;
+        };
+        if folds.is_empty() {
+            return;
+        }
+
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let snapshot_len = snapshot.len().0;
+
+        // Helper: search for fingerprint in buffer, return offset if found
+        let find_fingerprint = |fingerprint: &str, search_start: usize| -> Option<usize> {
+            let search_start = snapshot
+                .clip_offset(MultiBufferOffset(search_start), Bias::Left)
+                .0;
+            let search_end = snapshot_len.saturating_sub(fingerprint.len());
+
+            let mut byte_offset = search_start;
+            for ch in snapshot.chars_at(MultiBufferOffset(search_start)) {
+                if byte_offset > search_end {
+                    break;
+                }
+                if snapshot.contains_str_at(MultiBufferOffset(byte_offset), fingerprint) {
+                    return Some(byte_offset);
+                }
+                byte_offset += ch.len_utf8();
+            }
+            None
+        };
+
+        let mut search_start = 0usize;
+
+        let valid_folds: Vec<_> = folds
+            .into_iter()
+            .filter_map(|(stored_start, stored_end, start_fp, end_fp)| {
+                let sfp = start_fp?;
+                let efp = end_fp?;
+                let efp_len = efp.len();
+
+                let start_matches = stored_start < snapshot_len
+                    && snapshot.contains_str_at(MultiBufferOffset(stored_start), &sfp);
+                let efp_check_pos = stored_end.saturating_sub(efp_len);
+                let end_matches = efp_check_pos >= stored_start
+                    && stored_end <= snapshot_len
+                    && snapshot.contains_str_at(MultiBufferOffset(efp_check_pos), &efp);
+
+                let (new_start, new_end) = if start_matches && end_matches {
+                    (stored_start, stored_end)
+                } else if sfp == efp {
+                    let new_start = find_fingerprint(&sfp, search_start)?;
+                    let fold_len = stored_end - stored_start;
+                    let new_end = new_start + fold_len;
+                    (new_start, new_end)
+                } else {
+                    let new_start = find_fingerprint(&sfp, search_start)?;
+                    let efp_pos = find_fingerprint(&efp, new_start + sfp.len())?;
+                    let new_end = efp_pos + efp_len;
+                    (new_start, new_end)
+                };
+
+                search_start = new_end;
+
+                if new_end <= new_start {
+                    return None;
+                }
+
+                Some(
+                    snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left)
+                        ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right),
+                )
+            })
+            .collect();
+
+        if !valid_folds.is_empty() {
+            self.fold_ranges(valid_folds, false, window, cx);
+        }
+    }
+
     fn update_lsp_data(
         &mut self,
         for_buffer: Option<BufferId>,

crates/editor/src/items.rs 🔗

@@ -989,19 +989,42 @@ impl Item for Editor {
     fn added_to_workspace(
         &mut self,
         workspace: &mut Workspace,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         self.workspace = Some((workspace.weak_handle(), workspace.database_id()));
-        if let Some(workspace) = &workspace.weak_handle().upgrade() {
-            cx.subscribe(workspace, |editor, _, event: &workspace::Event, _cx| {
-                if let workspace::Event::ModalOpened = event {
-                    editor.mouse_context_menu.take();
-                    editor.inline_blame_popover.take();
-                }
-            })
+        if let Some(workspace_entity) = &workspace.weak_handle().upgrade() {
+            cx.subscribe(
+                workspace_entity,
+                |editor, _, event: &workspace::Event, _cx| {
+                    if let workspace::Event::ModalOpened = event {
+                        editor.mouse_context_menu.take();
+                        editor.inline_blame_popover.take();
+                    }
+                },
+            )
             .detach();
         }
+
+        // Load persisted folds if this editor doesn't already have folds.
+        // This handles manually-opened files (not workspace restoration).
+        let display_snapshot = self
+            .display_map
+            .update(cx, |display_map, cx| display_map.snapshot(cx));
+        let has_folds = display_snapshot
+            .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len())
+            .next()
+            .is_some();
+
+        if !has_folds {
+            if let Some(workspace_id) = workspace.database_id()
+                && let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| {
+                    project::File::from_dyn(buffer.read(cx).file()).map(|file| file.abs_path(cx))
+                })
+            {
+                self.load_folds_from_db(workspace_id, file_path, window, cx);
+            }
+        }
     }
 
     fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
@@ -1397,6 +1420,7 @@ impl ProjectItem for Editor {
         cx: &mut Context<Self>,
     ) -> Self {
         let mut editor = Self::for_buffer(buffer.clone(), Some(project), window, cx);
+
         if let Some((excerpt_id, _, snapshot)) =
             editor.buffer().read(cx).snapshot(cx).as_singleton()
             && WorkspaceSettings::get(None, cx).restore_on_file_reopen
@@ -1408,12 +1432,14 @@ impl ProjectItem for Editor {
                     data.entries.get(&file.abs_path(cx))
                 })
         {
-            editor.fold_ranges(
-                clip_ranges(&restoration_data.folds, snapshot),
-                false,
-                window,
-                cx,
-            );
+            if !restoration_data.folds.is_empty() {
+                editor.fold_ranges(
+                    clip_ranges(&restoration_data.folds, snapshot),
+                    false,
+                    window,
+                    cx,
+                );
+            }
             if !restoration_data.selections.is_empty() {
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.select_ranges(clip_ranges(&restoration_data.selections, snapshot));

crates/editor/src/persistence.rs 🔗

@@ -10,7 +10,10 @@ use db::{
 };
 use fs::MTime;
 use itertools::Itertools as _;
-use std::path::PathBuf;
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
 
@@ -203,6 +206,23 @@ impl Domain for EditorDb {
             ALTER TABLE editor_folds ADD COLUMN start_fingerprint TEXT;
             ALTER TABLE editor_folds ADD COLUMN end_fingerprint TEXT;
         ),
+        // File-level fold persistence: store folds by file path instead of editor_id.
+        // This allows folds to survive tab close and workspace cleanup.
+        // Follows the breakpoints pattern in workspace/src/persistence.rs.
+        sql! (
+            CREATE TABLE file_folds (
+                workspace_id INTEGER NOT NULL,
+                path TEXT NOT NULL,
+                start INTEGER NOT NULL,
+                end INTEGER NOT NULL,
+                start_fingerprint TEXT,
+                end_fingerprint TEXT,
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                    ON DELETE CASCADE
+                    ON UPDATE CASCADE,
+                PRIMARY KEY(workspace_id, path, start)
+            );
+        ),
     ];
 }
 
@@ -287,33 +307,16 @@ impl EditorDb {
         }
     }
 
-    // Migrate folds from an old editor_id to a new one.
-    // This is needed because entity IDs change between sessions, but workspace
-    // cleanup deletes old editor rows (cascading to folds) before the new
-    // editor has a chance to re-save its folds.
-    //
-    // We temporarily disable FK checks because the new editor row doesn't exist
-    // yet (it gets created during workspace serialization, which runs later).
-    pub async fn migrate_editor_folds(
-        &self,
-        old_editor_id: ItemId,
-        new_editor_id: ItemId,
-        workspace_id: WorkspaceId,
-    ) -> Result<()> {
-        self.write(move |conn| {
-            let _ = conn.exec("PRAGMA foreign_keys = OFF");
-            let mut statement = Statement::prepare(
-                conn,
-                "UPDATE editor_folds SET editor_id = ?2 WHERE editor_id = ?1 AND workspace_id = ?3",
-            )?;
-            statement.bind(&old_editor_id, 1)?;
-            statement.bind(&new_editor_id, 2)?;
-            statement.bind(&workspace_id, 3)?;
-            let result = statement.exec();
-            let _ = conn.exec("PRAGMA foreign_keys = ON");
-            result
-        })
-        .await
+    query! {
+        pub fn get_file_folds(
+            workspace_id: WorkspaceId,
+            path: &Path
+        ) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
+            SELECT start, end, start_fingerprint, end_fingerprint
+            FROM file_folds
+            WHERE workspace_id = ?1 AND path = ?2
+            ORDER BY start
+        }
     }
 
     pub async fn save_editor_selections(
@@ -368,58 +371,42 @@ VALUES {placeholders};
         Ok(())
     }
 
-    pub async fn save_editor_folds(
+    pub async fn save_file_folds(
         &self,
-        editor_id: ItemId,
         workspace_id: WorkspaceId,
+        path: Arc<Path>,
         folds: Vec<(usize, usize, String, String)>,
     ) -> Result<()> {
-        log::debug!("Saving folds for editor {editor_id} in workspace {workspace_id:?}");
-        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 / 6)
-            .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, start_fingerprint, end_fingerprint)
-VALUES {placeholders};
-"#
-            );
+        log::debug!("Saving folds for file {path:?} in workspace {workspace_id:?}");
+        self.write(move |conn| {
+            // Clear existing folds for this file
+            conn.exec_bound(sql!(
+                DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
+            ))?((workspace_id, path.as_ref()))?;
+
+            // Insert each fold (matches breakpoints pattern)
+            for (start, end, start_fp, end_fp) in folds {
+                conn.exec_bound(sql!(
+                    INSERT INTO file_folds (workspace_id, path, start, end, start_fingerprint, end_fingerprint)
+                    VALUES (?1, ?2, ?3, ?4, ?5, ?6);
+                ))?((workspace_id, path.as_ref(), start, end, start_fp, end_fp))?;
+            }
+            Ok(())
+        })
+        .await
+    }
 
-            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, start_fp, end_fp) in folds {
-                    next_index = statement.bind(&start, next_index)?;
-                    next_index = statement.bind(&end, next_index)?;
-                    next_index = statement.bind(&start_fp, next_index)?;
-                    next_index = statement.bind(&end_fp, next_index)?;
-                }
-                statement.exec()
-            })
-            .await?;
-        }
-        Ok(())
+    pub async fn delete_file_folds(
+        &self,
+        workspace_id: WorkspaceId,
+        path: Arc<Path>,
+    ) -> Result<()> {
+        self.write(move |conn| {
+            conn.exec_bound(sql!(
+                DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
+            ))?((workspace_id, path.as_ref()))
+        })
+        .await
     }
 }
 
@@ -503,22 +490,22 @@ mod tests {
         assert_eq!(have, serialized_editor);
     }
 
+    // NOTE: The fingerprint search logic (finding content at new offsets when file
+    // is modified externally) is in editor.rs:restore_from_db and requires a full
+    // Editor context to test. Manual testing procedure:
+    // 1. Open a file, fold some sections, close Zed
+    // 2. Add text at the START of the file externally (shifts all offsets)
+    // 3. Reopen Zed - folds should be restored at their NEW correct positions
+    // The search uses contains_str_at() to find fingerprints in the buffer.
+
     #[gpui::test]
-    async fn test_save_and_get_editor_folds_with_fingerprints() {
+    async fn test_save_and_get_file_folds() {
         let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
 
-        // First create an editor entry (folds have FK to editors)
-        let serialized_editor = SerializedEditor {
-            abs_path: Some(PathBuf::from("test_folds.txt")),
-            contents: None,
-            language: None,
-            mtime: None,
-        };
-        DB.save_serialized_editor(5678, workspace_id, serialized_editor)
-            .await
-            .unwrap();
+        // file_folds table uses path as key (no FK to editors table)
+        let file_path: Arc<Path> = Arc::from(Path::new("/tmp/test_file_folds.rs"));
 
-        // Save folds with fingerprints (32-byte content samples at fold boundaries)
+        // Save folds with fingerprints
         let folds = vec![
             (
                 100,
@@ -533,12 +520,12 @@ mod tests {
                 "} // end Foo".to_string(),
             ),
         ];
-        DB.save_editor_folds(5678, workspace_id, folds.clone())
+        DB.save_file_folds(workspace_id, file_path.clone(), folds.clone())
             .await
             .unwrap();
 
         // Retrieve and verify fingerprints are preserved
-        let retrieved = DB.get_editor_folds(5678, workspace_id).unwrap();
+        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
         assert_eq!(retrieved.len(), 2);
         assert_eq!(
             retrieved[0],
@@ -566,11 +553,11 @@ mod tests {
             "impl Bar {".to_string(),
             "} // end impl".to_string(),
         )];
-        DB.save_editor_folds(5678, workspace_id, new_folds)
+        DB.save_file_folds(workspace_id, file_path.clone(), new_folds)
             .await
             .unwrap();
 
-        let retrieved = DB.get_editor_folds(5678, workspace_id).unwrap();
+        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
         assert_eq!(retrieved.len(), 1);
         assert_eq!(
             retrieved[0],
@@ -581,13 +568,33 @@ mod tests {
                 Some("} // end impl".to_string())
             )
         );
-    }
 
-    // NOTE: The fingerprint search logic (finding content at new offsets when file
-    // is modified externally) is in editor.rs:restore_from_db and requires a full
-    // Editor context to test. Manual testing procedure:
-    // 1. Open a file, fold some sections, close Zed
-    // 2. Add text at the START of the file externally (shifts all offsets)
-    // 3. Reopen Zed - folds should be restored at their NEW correct positions
-    // The search uses contains_str_at() to find fingerprints in the buffer.
+        // Test delete
+        DB.delete_file_folds(workspace_id, file_path.clone())
+            .await
+            .unwrap();
+        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
+        assert!(retrieved.is_empty());
+
+        // Test multiple files don't interfere
+        let file_path_a: Arc<Path> = Arc::from(Path::new("/tmp/file_a.rs"));
+        let file_path_b: Arc<Path> = Arc::from(Path::new("/tmp/file_b.rs"));
+        let folds_a = vec![(10, 20, "a_start".to_string(), "a_end".to_string())];
+        let folds_b = vec![(30, 40, "b_start".to_string(), "b_end".to_string())];
+
+        DB.save_file_folds(workspace_id, file_path_a.clone(), folds_a)
+            .await
+            .unwrap();
+        DB.save_file_folds(workspace_id, file_path_b.clone(), folds_b)
+            .await
+            .unwrap();
+
+        let retrieved_a = DB.get_file_folds(workspace_id, &file_path_a).unwrap();
+        let retrieved_b = DB.get_file_folds(workspace_id, &file_path_b).unwrap();
+
+        assert_eq!(retrieved_a.len(), 1);
+        assert_eq!(retrieved_b.len(), 1);
+        assert_eq!(retrieved_a[0].0, 10); // file_a's fold
+        assert_eq!(retrieved_b[0].0, 30); // file_b's fold
+    }
 }