Detailed changes
@@ -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>,
@@ -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));
@@ -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
+ }
}