diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 192412f2681ae007ed9bece0b32432a713ed0ee6..32bcf32b7bfc58f65961891842e57ff21734c760 100644 --- a/crates/editor/src/editor.rs +++ b/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::::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::>(); 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> = + 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, + ) { + 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 { + 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, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f1f9b32e720e2c1819785957b7178f6737b28f57..afb296cff59369804cd28ebd85ced3d2f7649b7a 100644 --- a/crates/editor/src/items.rs +++ b/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.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 { 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)); diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index 02f867002d5562607c16f3180d7554c042494319..fc24277cda1c834471559144304c0d14d5ab52df 100644 --- a/crates/editor/src/persistence.rs +++ b/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, Option)>> { + 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, 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::>() - { - 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, + ) -> 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 = 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 = Arc::from(Path::new("/tmp/file_a.rs")); + let file_path_b: Arc = 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 + } }