persistence.rs

  1use anyhow::Result;
  2use db::{
  3    query,
  4    sqlez::{
  5        bindable::{Bind, Column, StaticColumnCount},
  6        domain::Domain,
  7        statement::Statement,
  8    },
  9    sqlez_macros::sql,
 10};
 11use fs::MTime;
 12use itertools::Itertools as _;
 13use std::{
 14    path::{Path, PathBuf},
 15    sync::Arc,
 16};
 17
 18use workspace::{ItemId, WorkspaceDb, WorkspaceId};
 19
 20#[derive(Clone, Debug, PartialEq, Default)]
 21pub(crate) struct SerializedEditor {
 22    pub(crate) abs_path: Option<PathBuf>,
 23    pub(crate) contents: Option<String>,
 24    pub(crate) language: Option<String>,
 25    pub(crate) mtime: Option<MTime>,
 26}
 27
 28impl StaticColumnCount for SerializedEditor {
 29    fn column_count() -> usize {
 30        6
 31    }
 32}
 33
 34impl Bind for SerializedEditor {
 35    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
 36        let start_index = statement.bind(&self.abs_path, start_index)?;
 37        let start_index = statement.bind(
 38            &self
 39                .abs_path
 40                .as_ref()
 41                .map(|p| p.to_string_lossy().into_owned()),
 42            start_index,
 43        )?;
 44        let start_index = statement.bind(&self.contents, start_index)?;
 45        let start_index = statement.bind(&self.language, start_index)?;
 46
 47        let start_index = match self
 48            .mtime
 49            .and_then(|mtime| mtime.to_seconds_and_nanos_for_persistence())
 50        {
 51            Some((seconds, nanos)) => {
 52                let start_index = statement.bind(&(seconds as i64), start_index)?;
 53                statement.bind(&(nanos as i32), start_index)?
 54            }
 55            None => {
 56                let start_index = statement.bind::<Option<i64>>(&None, start_index)?;
 57                statement.bind::<Option<i32>>(&None, start_index)?
 58            }
 59        };
 60        Ok(start_index)
 61    }
 62}
 63
 64impl Column for SerializedEditor {
 65    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
 66        let (abs_path, start_index): (Option<PathBuf>, i32) =
 67            Column::column(statement, start_index)?;
 68        let (_abs_path, start_index): (Option<PathBuf>, i32) =
 69            Column::column(statement, start_index)?;
 70        let (contents, start_index): (Option<String>, i32) =
 71            Column::column(statement, start_index)?;
 72        let (language, start_index): (Option<String>, i32) =
 73            Column::column(statement, start_index)?;
 74        let (mtime_seconds, start_index): (Option<i64>, i32) =
 75            Column::column(statement, start_index)?;
 76        let (mtime_nanos, start_index): (Option<i32>, i32) =
 77            Column::column(statement, start_index)?;
 78
 79        let mtime = mtime_seconds
 80            .zip(mtime_nanos)
 81            .map(|(seconds, nanos)| MTime::from_seconds_and_nanos(seconds as u64, nanos as u32));
 82
 83        let editor = Self {
 84            abs_path,
 85            contents,
 86            language,
 87            mtime,
 88        };
 89        Ok((editor, start_index))
 90    }
 91}
 92
 93pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
 94
 95impl Domain for EditorDb {
 96    const NAME: &str = stringify!(EditorDb);
 97
 98    // Current schema shape using pseudo-rust syntax:
 99    // editors(
100    //   item_id: usize,
101    //   workspace_id: usize,
102    //   path: Option<PathBuf>,
103    //   scroll_top_row: usize,
104    //   scroll_vertical_offset: f32,
105    //   scroll_horizontal_offset: f32,
106    //   contents: Option<String>,
107    //   language: Option<String>,
108    //   mtime_seconds: Option<i64>,
109    //   mtime_nanos: Option<i32>,
110    // )
111    //
112    // editor_selections(
113    //   item_id: usize,
114    //   editor_id: usize,
115    //   workspace_id: usize,
116    //   start: usize,
117    //   end: usize,
118    // )
119    //
120    // editor_folds(
121    //   item_id: usize,
122    //   editor_id: usize,
123    //   workspace_id: usize,
124    //   start: usize,
125    //   end: usize,
126    //   start_fingerprint: Option<String>,
127    //   end_fingerprint: Option<String>,
128    // )
129
130    const MIGRATIONS: &[&str] = &[
131        sql! (
132            CREATE TABLE editors(
133                item_id INTEGER NOT NULL,
134                workspace_id INTEGER NOT NULL,
135                path BLOB NOT NULL,
136                PRIMARY KEY(item_id, workspace_id),
137                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
138                ON DELETE CASCADE
139                ON UPDATE CASCADE
140            ) STRICT;
141        ),
142        sql! (
143            ALTER TABLE editors ADD COLUMN scroll_top_row INTEGER NOT NULL DEFAULT 0;
144            ALTER TABLE editors ADD COLUMN scroll_horizontal_offset REAL NOT NULL DEFAULT 0;
145            ALTER TABLE editors ADD COLUMN scroll_vertical_offset REAL NOT NULL DEFAULT 0;
146        ),
147        sql! (
148            // Since sqlite3 doesn't support ALTER COLUMN, we create a new
149            // table, move the data over, drop the old table, rename new table.
150            CREATE TABLE new_editors_tmp (
151                item_id INTEGER NOT NULL,
152                workspace_id INTEGER NOT NULL,
153                path BLOB, // <-- No longer "NOT NULL"
154                scroll_top_row INTEGER NOT NULL DEFAULT 0,
155                scroll_horizontal_offset REAL NOT NULL DEFAULT 0,
156                scroll_vertical_offset REAL NOT NULL DEFAULT 0,
157                contents TEXT, // New
158                language TEXT, // New
159                PRIMARY KEY(item_id, workspace_id),
160                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
161                ON DELETE CASCADE
162                ON UPDATE CASCADE
163            ) STRICT;
164
165            INSERT INTO new_editors_tmp(item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset)
166            SELECT item_id, workspace_id, path, scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
167            FROM editors;
168
169            DROP TABLE editors;
170
171            ALTER TABLE new_editors_tmp RENAME TO editors;
172        ),
173        sql! (
174            ALTER TABLE editors ADD COLUMN mtime_seconds INTEGER DEFAULT NULL;
175            ALTER TABLE editors ADD COLUMN mtime_nanos INTEGER DEFAULT NULL;
176        ),
177        sql! (
178            CREATE TABLE editor_selections (
179                item_id INTEGER NOT NULL,
180                editor_id INTEGER NOT NULL,
181                workspace_id INTEGER NOT NULL,
182                start INTEGER NOT NULL,
183                end INTEGER NOT NULL,
184                PRIMARY KEY(item_id),
185                FOREIGN KEY(editor_id, workspace_id) REFERENCES editors(item_id, workspace_id)
186                ON DELETE CASCADE
187            ) STRICT;
188        ),
189        sql! (
190            ALTER TABLE editors ADD COLUMN buffer_path TEXT;
191            UPDATE editors SET buffer_path = CAST(path AS TEXT);
192        ),
193        sql! (
194            CREATE TABLE editor_folds (
195                item_id INTEGER NOT NULL,
196                editor_id INTEGER NOT NULL,
197                workspace_id INTEGER NOT NULL,
198                start INTEGER NOT NULL,
199                end INTEGER NOT NULL,
200                PRIMARY KEY(item_id),
201                FOREIGN KEY(editor_id, workspace_id) REFERENCES editors(item_id, workspace_id)
202                ON DELETE CASCADE
203            ) STRICT;
204        ),
205        sql! (
206            ALTER TABLE editor_folds ADD COLUMN start_fingerprint TEXT;
207            ALTER TABLE editor_folds ADD COLUMN end_fingerprint TEXT;
208        ),
209        // File-level fold persistence: store folds by file path instead of editor_id.
210        // This allows folds to survive tab close and workspace cleanup.
211        // Follows the breakpoints pattern in workspace/src/persistence.rs.
212        sql! (
213            CREATE TABLE file_folds (
214                workspace_id INTEGER NOT NULL,
215                path TEXT NOT NULL,
216                start INTEGER NOT NULL,
217                end INTEGER NOT NULL,
218                start_fingerprint TEXT,
219                end_fingerprint TEXT,
220                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
221                    ON DELETE CASCADE
222                    ON UPDATE CASCADE,
223                PRIMARY KEY(workspace_id, path, start)
224            );
225        ),
226    ];
227}
228
229db::static_connection!(DB, EditorDb, [WorkspaceDb]);
230
231// https://www.sqlite.org/limits.html
232// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
233// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
234const MAX_QUERY_PLACEHOLDERS: usize = 32000;
235
236impl EditorDb {
237    query! {
238        pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<SerializedEditor>> {
239            SELECT path, buffer_path, contents, language, mtime_seconds, mtime_nanos FROM editors
240            WHERE item_id = ? AND workspace_id = ?
241        }
242    }
243
244    query! {
245        pub async fn save_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId, serialized_editor: SerializedEditor) -> Result<()> {
246            INSERT INTO editors
247                (item_id, workspace_id, path, buffer_path, contents, language, mtime_seconds, mtime_nanos)
248            VALUES
249                (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
250            ON CONFLICT DO UPDATE SET
251                item_id = ?1,
252                workspace_id = ?2,
253                path = ?3,
254                buffer_path = ?4,
255                contents = ?5,
256                language = ?6,
257                mtime_seconds = ?7,
258                mtime_nanos = ?8
259        }
260    }
261
262    // Returns the scroll top row, and offset
263    query! {
264        pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(u32, f64, f64)>> {
265            SELECT scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset
266            FROM editors
267            WHERE item_id = ? AND workspace_id = ?
268        }
269    }
270
271    query! {
272        pub async fn save_scroll_position(
273            item_id: ItemId,
274            workspace_id: WorkspaceId,
275            top_row: u32,
276            vertical_offset: f64,
277            horizontal_offset: f64
278        ) -> Result<()> {
279            UPDATE OR IGNORE editors
280            SET
281                scroll_top_row = ?3,
282                scroll_horizontal_offset = ?4,
283                scroll_vertical_offset = ?5
284            WHERE item_id = ?1 AND workspace_id = ?2
285        }
286    }
287
288    query! {
289        pub fn get_editor_selections(
290            editor_id: ItemId,
291            workspace_id: WorkspaceId
292        ) -> Result<Vec<(usize, usize)>> {
293            SELECT start, end
294            FROM editor_selections
295            WHERE editor_id = ?1 AND workspace_id = ?2
296        }
297    }
298
299    query! {
300        pub fn get_editor_folds(
301            editor_id: ItemId,
302            workspace_id: WorkspaceId
303        ) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
304            SELECT start, end, start_fingerprint, end_fingerprint
305            FROM editor_folds
306            WHERE editor_id = ?1 AND workspace_id = ?2
307        }
308    }
309
310    query! {
311        pub fn get_file_folds(
312            workspace_id: WorkspaceId,
313            path: &Path
314        ) -> Result<Vec<(usize, usize, Option<String>, Option<String>)>> {
315            SELECT start, end, start_fingerprint, end_fingerprint
316            FROM file_folds
317            WHERE workspace_id = ?1 AND path = ?2
318            ORDER BY start
319        }
320    }
321
322    pub async fn save_editor_selections(
323        &self,
324        editor_id: ItemId,
325        workspace_id: WorkspaceId,
326        selections: Vec<(usize, usize)>,
327    ) -> Result<()> {
328        log::debug!("Saving selections for editor {editor_id} in workspace {workspace_id:?}");
329        let mut first_selection;
330        let mut last_selection = 0_usize;
331        for (count, placeholders) in std::iter::once("(?1, ?2, ?, ?)")
332            .cycle()
333            .take(selections.len())
334            .chunks(MAX_QUERY_PLACEHOLDERS / 4)
335            .into_iter()
336            .map(|chunk| {
337                let mut count = 0;
338                let placeholders = chunk
339                    .inspect(|_| {
340                        count += 1;
341                    })
342                    .join(", ");
343                (count, placeholders)
344            })
345            .collect::<Vec<_>>()
346        {
347            first_selection = last_selection;
348            last_selection = last_selection + count;
349            let query = format!(
350                r#"
351DELETE FROM editor_selections WHERE editor_id = ?1 AND workspace_id = ?2;
352
353INSERT OR IGNORE INTO editor_selections (editor_id, workspace_id, start, end)
354VALUES {placeholders};
355"#
356            );
357
358            let selections = selections[first_selection..last_selection].to_vec();
359            self.write(move |conn| {
360                let mut statement = Statement::prepare(conn, query)?;
361                statement.bind(&editor_id, 1)?;
362                let mut next_index = statement.bind(&workspace_id, 2)?;
363                for (start, end) in selections {
364                    next_index = statement.bind(&start, next_index)?;
365                    next_index = statement.bind(&end, next_index)?;
366                }
367                statement.exec()
368            })
369            .await?;
370        }
371        Ok(())
372    }
373
374    pub async fn save_file_folds(
375        &self,
376        workspace_id: WorkspaceId,
377        path: Arc<Path>,
378        folds: Vec<(usize, usize, String, String)>,
379    ) -> Result<()> {
380        log::debug!("Saving folds for file {path:?} in workspace {workspace_id:?}");
381        self.write(move |conn| {
382            // Clear existing folds for this file
383            conn.exec_bound(sql!(
384                DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
385            ))?((workspace_id, path.as_ref()))?;
386
387            // Insert each fold (matches breakpoints pattern)
388            for (start, end, start_fp, end_fp) in folds {
389                conn.exec_bound(sql!(
390                    INSERT INTO file_folds (workspace_id, path, start, end, start_fingerprint, end_fingerprint)
391                    VALUES (?1, ?2, ?3, ?4, ?5, ?6);
392                ))?((workspace_id, path.as_ref(), start, end, start_fp, end_fp))?;
393            }
394            Ok(())
395        })
396        .await
397    }
398
399    pub async fn delete_file_folds(
400        &self,
401        workspace_id: WorkspaceId,
402        path: Arc<Path>,
403    ) -> Result<()> {
404        self.write(move |conn| {
405            conn.exec_bound(sql!(
406                DELETE FROM file_folds WHERE workspace_id = ?1 AND path = ?2;
407            ))?((workspace_id, path.as_ref()))
408        })
409        .await
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[gpui::test]
418    async fn test_save_and_get_serialized_editor() {
419        let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
420
421        let serialized_editor = SerializedEditor {
422            abs_path: Some(PathBuf::from("testing.txt")),
423            contents: None,
424            language: None,
425            mtime: None,
426        };
427
428        DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
429            .await
430            .unwrap();
431
432        let have = DB
433            .get_serialized_editor(1234, workspace_id)
434            .unwrap()
435            .unwrap();
436        assert_eq!(have, serialized_editor);
437
438        // Now update contents and language
439        let serialized_editor = SerializedEditor {
440            abs_path: Some(PathBuf::from("testing.txt")),
441            contents: Some("Test".to_owned()),
442            language: Some("Go".to_owned()),
443            mtime: None,
444        };
445
446        DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
447            .await
448            .unwrap();
449
450        let have = DB
451            .get_serialized_editor(1234, workspace_id)
452            .unwrap()
453            .unwrap();
454        assert_eq!(have, serialized_editor);
455
456        // Now set all the fields to NULL
457        let serialized_editor = SerializedEditor {
458            abs_path: None,
459            contents: None,
460            language: None,
461            mtime: None,
462        };
463
464        DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
465            .await
466            .unwrap();
467
468        let have = DB
469            .get_serialized_editor(1234, workspace_id)
470            .unwrap()
471            .unwrap();
472        assert_eq!(have, serialized_editor);
473
474        // Storing and retrieving mtime
475        let serialized_editor = SerializedEditor {
476            abs_path: None,
477            contents: None,
478            language: None,
479            mtime: Some(MTime::from_seconds_and_nanos(100, 42)),
480        };
481
482        DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
483            .await
484            .unwrap();
485
486        let have = DB
487            .get_serialized_editor(1234, workspace_id)
488            .unwrap()
489            .unwrap();
490        assert_eq!(have, serialized_editor);
491    }
492
493    // NOTE: The fingerprint search logic (finding content at new offsets when file
494    // is modified externally) is in editor.rs:restore_from_db and requires a full
495    // Editor context to test. Manual testing procedure:
496    // 1. Open a file, fold some sections, close Zed
497    // 2. Add text at the START of the file externally (shifts all offsets)
498    // 3. Reopen Zed - folds should be restored at their NEW correct positions
499    // The search uses contains_str_at() to find fingerprints in the buffer.
500
501    #[gpui::test]
502    async fn test_save_and_get_file_folds() {
503        let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
504
505        // file_folds table uses path as key (no FK to editors table)
506        let file_path: Arc<Path> = Arc::from(Path::new("/tmp/test_file_folds.rs"));
507
508        // Save folds with fingerprints
509        let folds = vec![
510            (
511                100,
512                200,
513                "fn main() {".to_string(),
514                "} // end main".to_string(),
515            ),
516            (
517                300,
518                400,
519                "struct Foo {".to_string(),
520                "} // end Foo".to_string(),
521            ),
522        ];
523        DB.save_file_folds(workspace_id, file_path.clone(), folds.clone())
524            .await
525            .unwrap();
526
527        // Retrieve and verify fingerprints are preserved
528        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
529        assert_eq!(retrieved.len(), 2);
530        assert_eq!(
531            retrieved[0],
532            (
533                100,
534                200,
535                Some("fn main() {".to_string()),
536                Some("} // end main".to_string())
537            )
538        );
539        assert_eq!(
540            retrieved[1],
541            (
542                300,
543                400,
544                Some("struct Foo {".to_string()),
545                Some("} // end Foo".to_string())
546            )
547        );
548
549        // Test overwrite: saving new folds replaces old ones
550        let new_folds = vec![(
551            500,
552            600,
553            "impl Bar {".to_string(),
554            "} // end impl".to_string(),
555        )];
556        DB.save_file_folds(workspace_id, file_path.clone(), new_folds)
557            .await
558            .unwrap();
559
560        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
561        assert_eq!(retrieved.len(), 1);
562        assert_eq!(
563            retrieved[0],
564            (
565                500,
566                600,
567                Some("impl Bar {".to_string()),
568                Some("} // end impl".to_string())
569            )
570        );
571
572        // Test delete
573        DB.delete_file_folds(workspace_id, file_path.clone())
574            .await
575            .unwrap();
576        let retrieved = DB.get_file_folds(workspace_id, &file_path).unwrap();
577        assert!(retrieved.is_empty());
578
579        // Test multiple files don't interfere
580        let file_path_a: Arc<Path> = Arc::from(Path::new("/tmp/file_a.rs"));
581        let file_path_b: Arc<Path> = Arc::from(Path::new("/tmp/file_b.rs"));
582        let folds_a = vec![(10, 20, "a_start".to_string(), "a_end".to_string())];
583        let folds_b = vec![(30, 40, "b_start".to_string(), "b_end".to_string())];
584
585        DB.save_file_folds(workspace_id, file_path_a.clone(), folds_a)
586            .await
587            .unwrap();
588        DB.save_file_folds(workspace_id, file_path_b.clone(), folds_b)
589            .await
590            .unwrap();
591
592        let retrieved_a = DB.get_file_folds(workspace_id, &file_path_a).unwrap();
593        let retrieved_b = DB.get_file_folds(workspace_id, &file_path_b).unwrap();
594
595        assert_eq!(retrieved_a.len(), 1);
596        assert_eq!(retrieved_b.len(), 1);
597        assert_eq!(retrieved_a[0].0, 10); // file_a's fold
598        assert_eq!(retrieved_b[0].0, 30); // file_b's fold
599    }
600}