## Summary Fixes #33633 - folds corrupting after external file modifications. The existing fold persistence stored raw byte offsets, which become stale when files are modified externally (git operations, other editors, sync tools). This caused folds to capture wrong content on restore - users reported "wrong lines getting folded" with systematic offsets. **Solution: Content fingerprinting** - Store 32-byte content samples at fold boundaries - On restore, validate fingerprints match; if not, search buffer for new positions - Handles edge cases: short folds (< 32 bytes), duplicate content, boundary positioning **Bonus fix: Ungraceful exit survival** - Entity IDs change between sessions, but workspace cleanup's CASCADE DELETE was wiping folds before new editors could save - Now migrates folds to new entity_id immediately after restore ## Test plan - [x] Unit test for fingerprint storage/retrieval (`test_save_and_get_editor_folds_with_fingerprints`) - [x] Manual test: fold sections, add content at file start externally, reopen → folds restore at correct positions - [x] Manual test: fold sections, Ctrl+C, reopen → folds survive - [x] Existing fold tests pass (`cargo test -p editor`) ## Release Notes - N/A --------- Co-authored-by: Hector <hector@cyberneticwilderness.com>
594 lines
20 KiB
Rust
594 lines
20 KiB
Rust
use anyhow::Result;
|
|
use db::{
|
|
query,
|
|
sqlez::{
|
|
bindable::{Bind, Column, StaticColumnCount},
|
|
domain::Domain,
|
|
statement::Statement,
|
|
},
|
|
sqlez_macros::sql,
|
|
};
|
|
use fs::MTime;
|
|
use itertools::Itertools as _;
|
|
use std::path::PathBuf;
|
|
|
|
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Default)]
|
|
pub(crate) struct SerializedEditor {
|
|
pub(crate) abs_path: Option<PathBuf>,
|
|
pub(crate) contents: Option<String>,
|
|
pub(crate) language: Option<String>,
|
|
pub(crate) mtime: Option<MTime>,
|
|
}
|
|
|
|
impl StaticColumnCount for SerializedEditor {
|
|
fn column_count() -> usize {
|
|
6
|
|
}
|
|
}
|
|
|
|
impl Bind for SerializedEditor {
|
|
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
|
|
let start_index = statement.bind(&self.abs_path, start_index)?;
|
|
let start_index = statement.bind(
|
|
&self
|
|
.abs_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().into_owned()),
|
|
start_index,
|
|
)?;
|
|
let start_index = statement.bind(&self.contents, start_index)?;
|
|
let start_index = statement.bind(&self.language, start_index)?;
|
|
|
|
let start_index = match self
|
|
.mtime
|
|
.and_then(|mtime| mtime.to_seconds_and_nanos_for_persistence())
|
|
{
|
|
Some((seconds, nanos)) => {
|
|
let start_index = statement.bind(&(seconds as i64), start_index)?;
|
|
statement.bind(&(nanos as i32), start_index)?
|
|
}
|
|
None => {
|
|
let start_index = statement.bind::<Option<i64>>(&None, start_index)?;
|
|
statement.bind::<Option<i32>>(&None, start_index)?
|
|
}
|
|
};
|
|
Ok(start_index)
|
|
}
|
|
}
|
|
|
|
impl Column for SerializedEditor {
|
|
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
|
|
let (abs_path, start_index): (Option<PathBuf>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
let (_abs_path, start_index): (Option<PathBuf>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
let (contents, start_index): (Option<String>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
let (language, start_index): (Option<String>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
let (mtime_seconds, start_index): (Option<i64>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
let (mtime_nanos, start_index): (Option<i32>, i32) =
|
|
Column::column(statement, start_index)?;
|
|
|
|
let mtime = mtime_seconds
|
|
.zip(mtime_nanos)
|
|
.map(|(seconds, nanos)| MTime::from_seconds_and_nanos(seconds as u64, nanos as u32));
|
|
|
|
let editor = Self {
|
|
abs_path,
|
|
contents,
|
|
language,
|
|
mtime,
|
|
};
|
|
Ok((editor, start_index))
|
|
}
|
|
}
|
|
|
|
pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
|
|
|
|
impl Domain for EditorDb {
|
|
const NAME: &str = stringify!(EditorDb);
|
|
|
|
// Current schema shape using pseudo-rust syntax:
|
|
// editors(
|
|
// item_id: usize,
|
|
// workspace_id: usize,
|
|
// path: Option<PathBuf>,
|
|
// scroll_top_row: usize,
|
|
// scroll_vertical_offset: f32,
|
|
// scroll_horizontal_offset: f32,
|
|
// contents: Option<String>,
|
|
// language: Option<String>,
|
|
// 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,
|
|
// start_fingerprint: Option<String>,
|
|
// end_fingerprint: Option<String>,
|
|
// )
|
|
|
|
const MIGRATIONS: &[&str] = &[
|
|
sql! (
|
|
CREATE TABLE editors(
|
|
item_id INTEGER NOT NULL,
|
|
workspace_id INTEGER NOT NULL,
|
|
path BLOB NOT NULL,
|
|
PRIMARY KEY(item_id, workspace_id),
|
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
|
|
ON DELETE CASCADE
|
|
ON UPDATE CASCADE
|
|
) STRICT;
|
|
),
|
|
sql! (
|
|
ALTER TABLE editors ADD COLUMN scroll_top_row INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE editors ADD COLUMN scroll_horizontal_offset REAL NOT NULL DEFAULT 0;
|
|
ALTER TABLE editors ADD COLUMN scroll_vertical_offset REAL NOT NULL DEFAULT 0;
|
|
),
|
|
sql! (
|
|
// Since sqlite3 doesn't support ALTER COLUMN, we create a new
|
|
// table, move the data over, drop the old table, rename new table.
|
|
CREATE TABLE new_editors_tmp (
|
|
item_id INTEGER NOT NULL,
|
|
workspace_id INTEGER NOT NULL,
|
|
path BLOB, // <-- No longer "NOT NULL"
|
|
scroll_top_row INTEGER NOT NULL DEFAULT 0,
|
|
scroll_horizontal_offset REAL NOT NULL DEFAULT 0,
|
|
scroll_vertical_offset REAL NOT NULL DEFAULT 0,
|
|
contents TEXT, // New
|
|
language TEXT, // New
|
|
PRIMARY KEY(item_id, workspace_id),
|
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
|
|
ON DELETE CASCADE
|
|
ON UPDATE CASCADE
|
|
) STRICT;
|
|
|
|
INSERT INTO new_editors_tmp(item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset)
|
|
SELECT item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
|
|
FROM editors;
|
|
|
|
DROP TABLE editors;
|
|
|
|
ALTER TABLE new_editors_tmp RENAME TO editors;
|
|
),
|
|
sql! (
|
|
ALTER TABLE editors ADD COLUMN mtime_seconds INTEGER DEFAULT NULL;
|
|
ALTER TABLE editors ADD COLUMN mtime_nanos INTEGER DEFAULT NULL;
|
|
),
|
|
sql! (
|
|
CREATE TABLE editor_selections (
|
|
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;
|
|
),
|
|
sql! (
|
|
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;
|
|
),
|
|
sql! (
|
|
ALTER TABLE editor_folds ADD COLUMN start_fingerprint TEXT;
|
|
ALTER TABLE editor_folds ADD COLUMN end_fingerprint TEXT;
|
|
),
|
|
];
|
|
}
|
|
|
|
db::static_connection!(DB, EditorDb, [WorkspaceDb]);
|
|
|
|
// https://www.sqlite.org/limits.html
|
|
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
|
|
// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
|
|
const MAX_QUERY_PLACEHOLDERS: usize = 32000;
|
|
|
|
impl EditorDb {
|
|
query! {
|
|
pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<SerializedEditor>> {
|
|
SELECT path, buffer_path, contents, language, mtime_seconds, mtime_nanos FROM editors
|
|
WHERE item_id = ? AND workspace_id = ?
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub async fn save_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId, serialized_editor: SerializedEditor) -> Result<()> {
|
|
INSERT INTO editors
|
|
(item_id, workspace_id, path, buffer_path, contents, language, mtime_seconds, mtime_nanos)
|
|
VALUES
|
|
(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
ON CONFLICT DO UPDATE SET
|
|
item_id = ?1,
|
|
workspace_id = ?2,
|
|
path = ?3,
|
|
buffer_path = ?4,
|
|
contents = ?5,
|
|
language = ?6,
|
|
mtime_seconds = ?7,
|
|
mtime_nanos = ?8
|
|
}
|
|
}
|
|
|
|
// Returns the scroll top row, and offset
|
|
query! {
|
|
pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(u32, f64, f64)>> {
|
|
SELECT scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
|
|
FROM editors
|
|
WHERE item_id = ? AND workspace_id = ?
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub async fn save_scroll_position(
|
|
item_id: ItemId,
|
|
workspace_id: WorkspaceId,
|
|
top_row: u32,
|
|
vertical_offset: f64,
|
|
horizontal_offset: f64
|
|
) -> Result<()> {
|
|
UPDATE OR IGNORE editors
|
|
SET
|
|
scroll_top_row = ?3,
|
|
scroll_horizontal_offset = ?4,
|
|
scroll_vertical_offset = ?5
|
|
WHERE item_id = ?1 AND workspace_id = ?2
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub fn get_editor_selections(
|
|
editor_id: ItemId,
|
|
workspace_id: WorkspaceId
|
|
) -> Result<Vec<(usize, usize)>> {
|
|
SELECT start, end
|
|
FROM editor_selections
|
|
WHERE editor_id = ?1 AND workspace_id = ?2
|
|
}
|
|
}
|
|
|
|
query! {
|
|
pub fn get_editor_folds(
|
|
editor_id: ItemId,
|
|
workspace_id: WorkspaceId
|
|
) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
|
|
SELECT start, end, start_fingerprint, end_fingerprint
|
|
FROM editor_folds
|
|
WHERE editor_id = ?1 AND workspace_id = ?2
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
pub async fn save_editor_selections(
|
|
&self,
|
|
editor_id: ItemId,
|
|
workspace_id: WorkspaceId,
|
|
selections: Vec<(usize, usize)>,
|
|
) -> Result<()> {
|
|
log::debug!("Saving selections for editor {editor_id} in workspace {workspace_id:?}");
|
|
let mut first_selection;
|
|
let mut last_selection = 0_usize;
|
|
for (count, placeholders) in std::iter::once("(?1, ?2, ?, ?)")
|
|
.cycle()
|
|
.take(selections.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_selection = last_selection;
|
|
last_selection = last_selection + count;
|
|
let query = format!(
|
|
r#"
|
|
DELETE FROM editor_selections WHERE editor_id = ?1 AND workspace_id = ?2;
|
|
|
|
INSERT OR IGNORE INTO editor_selections (editor_id, workspace_id, start, end)
|
|
VALUES {placeholders};
|
|
"#
|
|
);
|
|
|
|
let selections = selections[first_selection..last_selection].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 selections {
|
|
next_index = statement.bind(&start, next_index)?;
|
|
next_index = statement.bind(&end, next_index)?;
|
|
}
|
|
statement.exec()
|
|
})
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn save_editor_folds(
|
|
&self,
|
|
editor_id: ItemId,
|
|
workspace_id: WorkspaceId,
|
|
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};
|
|
"#
|
|
);
|
|
|
|
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(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[gpui::test]
|
|
async fn test_save_and_get_serialized_editor() {
|
|
let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
|
|
|
|
let serialized_editor = SerializedEditor {
|
|
abs_path: Some(PathBuf::from("testing.txt")),
|
|
contents: None,
|
|
language: None,
|
|
mtime: None,
|
|
};
|
|
|
|
DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
let have = DB
|
|
.get_serialized_editor(1234, workspace_id)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(have, serialized_editor);
|
|
|
|
// Now update contents and language
|
|
let serialized_editor = SerializedEditor {
|
|
abs_path: Some(PathBuf::from("testing.txt")),
|
|
contents: Some("Test".to_owned()),
|
|
language: Some("Go".to_owned()),
|
|
mtime: None,
|
|
};
|
|
|
|
DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
let have = DB
|
|
.get_serialized_editor(1234, workspace_id)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(have, serialized_editor);
|
|
|
|
// Now set all the fields to NULL
|
|
let serialized_editor = SerializedEditor {
|
|
abs_path: None,
|
|
contents: None,
|
|
language: None,
|
|
mtime: None,
|
|
};
|
|
|
|
DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
let have = DB
|
|
.get_serialized_editor(1234, workspace_id)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(have, serialized_editor);
|
|
|
|
// Storing and retrieving mtime
|
|
let serialized_editor = SerializedEditor {
|
|
abs_path: None,
|
|
contents: None,
|
|
language: None,
|
|
mtime: Some(MTime::from_seconds_and_nanos(100, 42)),
|
|
};
|
|
|
|
DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
let have = DB
|
|
.get_serialized_editor(1234, workspace_id)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(have, serialized_editor);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_and_get_editor_folds_with_fingerprints() {
|
|
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();
|
|
|
|
// Save folds with fingerprints (32-byte content samples at fold boundaries)
|
|
let folds = vec![
|
|
(
|
|
100,
|
|
200,
|
|
"fn main() {".to_string(),
|
|
"} // end main".to_string(),
|
|
),
|
|
(
|
|
300,
|
|
400,
|
|
"struct Foo {".to_string(),
|
|
"} // end Foo".to_string(),
|
|
),
|
|
];
|
|
DB.save_editor_folds(5678, workspace_id, folds.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
// Retrieve and verify fingerprints are preserved
|
|
let retrieved = DB.get_editor_folds(5678, workspace_id).unwrap();
|
|
assert_eq!(retrieved.len(), 2);
|
|
assert_eq!(
|
|
retrieved[0],
|
|
(
|
|
100,
|
|
200,
|
|
Some("fn main() {".to_string()),
|
|
Some("} // end main".to_string())
|
|
)
|
|
);
|
|
assert_eq!(
|
|
retrieved[1],
|
|
(
|
|
300,
|
|
400,
|
|
Some("struct Foo {".to_string()),
|
|
Some("} // end Foo".to_string())
|
|
)
|
|
);
|
|
|
|
// Test overwrite: saving new folds replaces old ones
|
|
let new_folds = vec![(
|
|
500,
|
|
600,
|
|
"impl Bar {".to_string(),
|
|
"} // end impl".to_string(),
|
|
)];
|
|
DB.save_editor_folds(5678, workspace_id, new_folds)
|
|
.await
|
|
.unwrap();
|
|
|
|
let retrieved = DB.get_editor_folds(5678, workspace_id).unwrap();
|
|
assert_eq!(retrieved.len(), 1);
|
|
assert_eq!(
|
|
retrieved[0],
|
|
(
|
|
500,
|
|
600,
|
|
Some("impl Bar {".to_string()),
|
|
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.
|
|
}
|