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!(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(cx: &mut gpui::TestAppContext) {
419        let db = cx.update(|cx| workspace::WorkspaceDb::global(cx));
420        let workspace_id = db.next_id().await.unwrap();
421        let editor_db = cx.update(|cx| EditorDb::global(cx));
422
423        let serialized_editor = SerializedEditor {
424            abs_path: Some(PathBuf::from("testing.txt")),
425            contents: None,
426            language: None,
427            mtime: None,
428        };
429
430        editor_db
431            .save_serialized_editor(1234, workspace_id, serialized_editor.clone())
432            .await
433            .unwrap();
434
435        let have = editor_db
436            .get_serialized_editor(1234, workspace_id)
437            .unwrap()
438            .unwrap();
439        assert_eq!(have, serialized_editor);
440
441        // Now update contents and language
442        let serialized_editor = SerializedEditor {
443            abs_path: Some(PathBuf::from("testing.txt")),
444            contents: Some("Test".to_owned()),
445            language: Some("Go".to_owned()),
446            mtime: None,
447        };
448
449        editor_db
450            .save_serialized_editor(1234, workspace_id, serialized_editor.clone())
451            .await
452            .unwrap();
453
454        let have = editor_db
455            .get_serialized_editor(1234, workspace_id)
456            .unwrap()
457            .unwrap();
458        assert_eq!(have, serialized_editor);
459
460        // Now set all the fields to NULL
461        let serialized_editor = SerializedEditor {
462            abs_path: None,
463            contents: None,
464            language: None,
465            mtime: None,
466        };
467
468        editor_db
469            .save_serialized_editor(1234, workspace_id, serialized_editor.clone())
470            .await
471            .unwrap();
472
473        let have = editor_db
474            .get_serialized_editor(1234, workspace_id)
475            .unwrap()
476            .unwrap();
477        assert_eq!(have, serialized_editor);
478
479        // Storing and retrieving mtime
480        let serialized_editor = SerializedEditor {
481            abs_path: None,
482            contents: None,
483            language: None,
484            mtime: Some(MTime::from_seconds_and_nanos(100, 42)),
485        };
486
487        editor_db
488            .save_serialized_editor(1234, workspace_id, serialized_editor.clone())
489            .await
490            .unwrap();
491
492        let have = editor_db
493            .get_serialized_editor(1234, workspace_id)
494            .unwrap()
495            .unwrap();
496        assert_eq!(have, serialized_editor);
497    }
498
499    // NOTE: The fingerprint search logic (finding content at new offsets when file
500    // is modified externally) is in editor.rs:restore_from_db and requires a full
501    // Editor context to test. Manual testing procedure:
502    // 1. Open a file, fold some sections, close Zed
503    // 2. Add text at the START of the file externally (shifts all offsets)
504    // 3. Reopen Zed - folds should be restored at their NEW correct positions
505    // The search uses contains_str_at() to find fingerprints in the buffer.
506
507    #[gpui::test]
508    async fn test_save_and_get_file_folds(cx: &mut gpui::TestAppContext) {
509        let db = cx.update(|cx| workspace::WorkspaceDb::global(cx));
510        let workspace_id = db.next_id().await.unwrap();
511        let editor_db = cx.update(|cx| EditorDb::global(cx));
512
513        // file_folds table uses path as key (no FK to editors table)
514        let file_path: Arc<Path> = Arc::from(Path::new("/tmp/test_file_folds.rs"));
515
516        // Save folds with fingerprints
517        let folds = vec![
518            (
519                100,
520                200,
521                "fn main() {".to_string(),
522                "} // end main".to_string(),
523            ),
524            (
525                300,
526                400,
527                "struct Foo {".to_string(),
528                "} // end Foo".to_string(),
529            ),
530        ];
531        editor_db
532            .save_file_folds(workspace_id, file_path.clone(), folds.clone())
533            .await
534            .unwrap();
535
536        // Retrieve and verify fingerprints are preserved
537        let retrieved = editor_db.get_file_folds(workspace_id, &file_path).unwrap();
538        assert_eq!(retrieved.len(), 2);
539        assert_eq!(
540            retrieved[0],
541            (
542                100,
543                200,
544                Some("fn main() {".to_string()),
545                Some("} // end main".to_string())
546            )
547        );
548        assert_eq!(
549            retrieved[1],
550            (
551                300,
552                400,
553                Some("struct Foo {".to_string()),
554                Some("} // end Foo".to_string())
555            )
556        );
557
558        // Test overwrite: saving new folds replaces old ones
559        let new_folds = vec![(
560            500,
561            600,
562            "impl Bar {".to_string(),
563            "} // end impl".to_string(),
564        )];
565        editor_db
566            .save_file_folds(workspace_id, file_path.clone(), new_folds)
567            .await
568            .unwrap();
569
570        let retrieved = editor_db.get_file_folds(workspace_id, &file_path).unwrap();
571        assert_eq!(retrieved.len(), 1);
572        assert_eq!(
573            retrieved[0],
574            (
575                500,
576                600,
577                Some("impl Bar {".to_string()),
578                Some("} // end impl".to_string())
579            )
580        );
581
582        // Test delete
583        editor_db
584            .delete_file_folds(workspace_id, file_path.clone())
585            .await
586            .unwrap();
587        let retrieved = editor_db.get_file_folds(workspace_id, &file_path).unwrap();
588        assert!(retrieved.is_empty());
589
590        // Test multiple files don't interfere
591        let file_path_a: Arc<Path> = Arc::from(Path::new("/tmp/file_a.rs"));
592        let file_path_b: Arc<Path> = Arc::from(Path::new("/tmp/file_b.rs"));
593        let folds_a = vec![(10, 20, "a_start".to_string(), "a_end".to_string())];
594        let folds_b = vec![(30, 40, "b_start".to_string(), "b_end".to_string())];
595
596        editor_db
597            .save_file_folds(workspace_id, file_path_a.clone(), folds_a)
598            .await
599            .unwrap();
600        editor_db
601            .save_file_folds(workspace_id, file_path_b.clone(), folds_b)
602            .await
603            .unwrap();
604
605        let retrieved_a = editor_db
606            .get_file_folds(workspace_id, &file_path_a)
607            .unwrap();
608        let retrieved_b = editor_db
609            .get_file_folds(workspace_id, &file_path_b)
610            .unwrap();
611
612        assert_eq!(retrieved_a.len(), 1);
613        assert_eq!(retrieved_b.len(), 1);
614        assert_eq!(retrieved_a[0].0, 10); // file_a's fold
615        assert_eq!(retrieved_b[0].0, 30); // file_b's fold
616    }
617}