zed: Mark restored buffers as conflicted if file changed on disk between store & restore (#15207)

Thorsten Ball created

Previously, we've only marked restored buffers as dirty. This PR changes
that behavior in case the buffer has been associated with a file and
that file has changed on disk since the last time Zed stored its
contents.

Example timeline:

1. User edits file in Zed, buffer is dirty
2. User quites Zed with `cmd-q`
3. User changes file on disk: `echo foobar >> file.txt` or `git checkout
file.txt`
4. User starts Zed
5. File/buffer are now marked as having a conflict (yellow icon)

Release Notes:

- Unsaved files that are restored when Zed starts are now marked as
having a conflict if they have been changed on disk since the last time
they were stored.

Demo:



https://github.com/user-attachments/assets/6098b485-b325-49b7-b694-fd2fc60cce64

Change summary

crates/editor/src/items.rs       | 359 +++++++++++++++++++++++++++------
crates/editor/src/persistence.rs | 201 ++++++++++++++----
crates/fs/src/fs.rs              |   5 
3 files changed, 446 insertions(+), 119 deletions(-)

Detailed changes

crates/editor/src/items.rs 🔗

@@ -1,7 +1,9 @@
 use crate::{
-    editor_settings::SeedQuerySetting, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll,
-    Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot,
-    NavigationData, SearchWithinRange, ToPoint as _,
+    editor_settings::SeedQuerySetting,
+    persistence::{SerializedEditor, DB},
+    scroll::ScrollAnchor,
+    Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer,
+    MultiBufferSnapshot, NavigationData, SearchWithinRange, ToPoint as _,
 };
 use anyhow::{anyhow, Context as _, Result};
 use collections::HashSet;
@@ -914,18 +916,23 @@ impl SerializableItem for Editor {
         item_id: ItemId,
         cx: &mut ViewContext<Pane>,
     ) -> Task<Result<View<Self>>> {
-        let path_content_language = match DB
-            .get_path_and_contents(item_id, workspace_id)
+        let serialized_editor = match DB
+            .get_serialized_editor(item_id, workspace_id)
             .context("Failed to query editor state")
         {
-            Ok(Some((path, content, language))) => {
+            Ok(Some(serialized_editor)) => {
                 if ProjectSettings::get_global(cx)
                     .session
                     .restore_unsaved_buffers
                 {
-                    (path, content, language)
+                    serialized_editor
                 } else {
-                    (path, None, None)
+                    SerializedEditor {
+                        path: serialized_editor.path,
+                        contents: None,
+                        language: None,
+                        mtime: None,
+                    }
                 }
             }
             Ok(None) => {
@@ -936,34 +943,48 @@ impl SerializableItem for Editor {
             }
         };
 
-        match path_content_language {
-            (None, Some(content), language_name) => cx.spawn(|_, mut cx| async move {
-                let language = if let Some(language_name) = language_name {
-                    let language_registry =
-                        project.update(&mut cx, |project, _| project.languages().clone())?;
-
-                    Some(language_registry.language_for_name(&language_name).await?)
-                } else {
-                    None
-                };
+        let buffer_task = match serialized_editor {
+            SerializedEditor {
+                path: None,
+                contents: Some(contents),
+                language,
+                ..
+            } => cx.spawn(|_, mut cx| {
+                let project = project.clone();
+                async move {
+                    let language = if let Some(language_name) = language {
+                        let language_registry =
+                            project.update(&mut cx, |project, _| project.languages().clone())?;
+
+                        // We don't fail here, because we'd rather not set the language if the name changed
+                        // than fail to restore the buffer.
+                        language_registry
+                            .language_for_name(&language_name)
+                            .await
+                            .ok()
+                    } else {
+                        None
+                    };
 
-                // First create the empty buffer
-                let buffer = project.update(&mut cx, |project, cx| {
-                    project.create_local_buffer("", language, cx)
-                })?;
+                    // First create the empty buffer
+                    let buffer = project.update(&mut cx, |project, cx| {
+                        project.create_local_buffer("", language, cx)
+                    })?;
 
-                // Then set the text so that the dirty bit is set correctly
-                buffer.update(&mut cx, |buffer, cx| {
-                    buffer.set_text(content, cx);
-                })?;
+                    // Then set the text so that the dirty bit is set correctly
+                    buffer.update(&mut cx, |buffer, cx| {
+                        buffer.set_text(contents, cx);
+                    })?;
 
-                cx.new_view(|cx| {
-                    let mut editor = Editor::for_buffer(buffer, Some(project), cx);
-                    editor.read_scroll_position_from_db(item_id, workspace_id, cx);
-                    editor
-                })
+                    anyhow::Ok(buffer)
+                }
             }),
-            (Some(path), contents, _) => {
+            SerializedEditor {
+                path: Some(path),
+                contents,
+                mtime,
+                ..
+            } => {
                 let project_item = project.update(cx, |project, cx| {
                     let (worktree, path) = project
                         .find_worktree(&path, cx)
@@ -978,7 +999,7 @@ impl SerializableItem for Editor {
 
                 project_item
                     .map(|project_item| {
-                        cx.spawn(|pane, mut cx| async move {
+                        cx.spawn(|_, mut cx| async move {
                             let (_, project_item) = project_item.await?;
                             let buffer = project_item.downcast::<Buffer>().map_err(|_| {
                                 anyhow!("Project item at stored path was not a buffer")
@@ -988,27 +1009,43 @@ impl SerializableItem for Editor {
                             // disk and then overwrite the content.
                             // But for now, it keeps the implementation of the content serialization
                             // simple, because we don't have to persist all of the metadata that we get
-                            // by loading the file (git diff base, mtime, ...).
+                            // by loading the file (git diff base, ...).
                             if let Some(buffer_text) = contents {
                                 buffer.update(&mut cx, |buffer, cx| {
+                                    // If we did restore an mtime, we want to store it on the buffer
+                                    // so that the next edit will mark the buffer as dirty/conflicted.
+                                    if mtime.is_some() {
+                                        buffer.did_reload(
+                                            buffer.version(),
+                                            buffer.line_ending(),
+                                            mtime,
+                                            cx,
+                                        );
+                                    }
                                     buffer.set_text(buffer_text, cx);
                                 })?;
                             }
 
-                            pane.update(&mut cx, |_, cx| {
-                                cx.new_view(|cx| {
-                                    let mut editor = Editor::for_buffer(buffer, Some(project), cx);
-
-                                    editor.read_scroll_position_from_db(item_id, workspace_id, cx);
-                                    editor
-                                })
-                            })
+                            Ok(buffer)
                         })
                     })
                     .unwrap_or_else(|error| Task::ready(Err(error)))
             }
-            _ => Task::ready(Err(anyhow!("No path or contents found for buffer"))),
-        }
+            _ => return Task::ready(Err(anyhow!("No path or contents found for buffer"))),
+        };
+
+        cx.spawn(|pane, mut cx| async move {
+            let buffer = buffer_task.await?;
+
+            pane.update(&mut cx, |_, cx| {
+                cx.new_view(|cx| {
+                    let mut editor = Editor::for_buffer(buffer, Some(project), cx);
+
+                    editor.read_scroll_position_from_db(item_id, workspace_id, cx);
+                    editor
+                })
+            })
+        })
     }
 
     fn serialize(
@@ -1036,36 +1073,33 @@ impl SerializableItem for Editor {
         let buffer = self.buffer().read(cx).as_singleton()?;
 
         let is_dirty = buffer.read(cx).is_dirty();
-        let path = buffer
-            .read(cx)
-            .file()
-            .and_then(|file| file.as_local())
-            .map(|file| file.abs_path(cx));
+        let local_file = buffer.read(cx).file().and_then(|file| file.as_local());
+        let path = local_file.map(|file| file.abs_path(cx));
+        let mtime = buffer.read(cx).saved_mtime();
+
         let snapshot = buffer.read(cx).snapshot();
 
         Some(cx.spawn(|_this, cx| async move {
             cx.background_executor()
                 .spawn(async move {
-                    if let Some(path) = path {
-                        DB.save_path(item_id, workspace_id, path.clone())
-                            .await
-                            .context("failed to save path of buffer")?
-                    }
+                    let (contents, language) = if serialize_dirty_buffers && is_dirty {
+                        let contents = snapshot.text();
+                        let language = snapshot.language().map(|lang| lang.name().to_string());
+                        (Some(contents), language)
+                    } else {
+                        (None, None)
+                    };
 
-                    if serialize_dirty_buffers {
-                        let (contents, language) = if is_dirty {
-                            let contents = snapshot.text();
-                            let language = snapshot.language().map(|lang| lang.name().to_string());
-                            (Some(contents), language)
-                        } else {
-                            (None, None)
-                        };
-
-                        DB.save_contents(item_id, workspace_id, contents, language)
-                            .await?;
-                    }
+                    let editor = SerializedEditor {
+                        path,
+                        contents,
+                        language,
+                        mtime,
+                    };
 
-                    anyhow::Ok(())
+                    DB.save_serialized_editor(item_id, workspace_id, editor)
+                        .await
+                        .context("failed to save serialized editor")
                 })
                 .await
                 .context("failed to save contents of buffer")?;
@@ -1474,10 +1508,16 @@ fn path_for_file<'a>(
 
 #[cfg(test)]
 mod tests {
+    use crate::editor_tests::init_test;
+
     use super::*;
-    use gpui::AppContext;
-    use language::TestFile;
-    use std::path::Path;
+    use gpui::{AppContext, VisualTestContext};
+    use language::{LanguageMatcher, TestFile};
+    use project::FakeFs;
+    use std::{
+        path::{Path, PathBuf},
+        time::SystemTime,
+    };
 
     #[gpui::test]
     fn test_path_for_file(cx: &mut AppContext) {
@@ -1487,4 +1527,183 @@ mod tests {
         };
         assert_eq!(path_for_file(&file, 0, false, cx), None);
     }
+
+    async fn deserialize_editor(
+        item_id: ItemId,
+        workspace_id: WorkspaceId,
+        workspace: View<Workspace>,
+        project: Model<Project>,
+        cx: &mut VisualTestContext,
+    ) -> View<Editor> {
+        workspace
+            .update(cx, |workspace, cx| {
+                let pane = workspace.active_pane();
+                pane.update(cx, |_, cx| {
+                    Editor::deserialize(
+                        project.clone(),
+                        workspace.weak_handle(),
+                        workspace_id,
+                        item_id,
+                        cx,
+                    )
+                })
+            })
+            .await
+            .unwrap()
+    }
+
+    fn rust_language() -> Arc<language::Language> {
+        Arc::new(language::Language::new(
+            language::LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        ))
+    }
+
+    #[gpui::test]
+    async fn test_deserialize(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let now = SystemTime::now();
+        let fs = FakeFs::new(cx.executor());
+        fs.set_next_mtime(now);
+        fs.insert_file("/file.rs", Default::default()).await;
+
+        // Test case 1: Deserialize with path and contents
+        {
+            let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
+            let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+            let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
+            let item_id = 1234 as ItemId;
+
+            let serialized_editor = SerializedEditor {
+                path: Some(PathBuf::from("/file.rs")),
+                contents: Some("fn main() {}".to_string()),
+                language: Some("Rust".to_string()),
+                mtime: Some(now),
+            };
+
+            DB.save_serialized_editor(item_id, workspace_id, serialized_editor.clone())
+                .await
+                .unwrap();
+
+            let deserialized =
+                deserialize_editor(item_id, workspace_id, workspace, project, cx).await;
+
+            deserialized.update(cx, |editor, cx| {
+                assert_eq!(editor.text(cx), "fn main() {}");
+                assert!(editor.is_dirty(cx));
+                assert!(!editor.has_conflict(cx));
+                let buffer = editor.buffer().read(cx).as_singleton().unwrap().read(cx);
+                assert!(buffer.file().is_some());
+            });
+        }
+
+        // Test case 2: Deserialize with only path
+        {
+            let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
+            let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+
+            let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
+
+            let item_id = 5678 as ItemId;
+            let serialized_editor = SerializedEditor {
+                path: Some(PathBuf::from("/file.rs")),
+                contents: None,
+                language: None,
+                mtime: None,
+            };
+
+            DB.save_serialized_editor(item_id, workspace_id, serialized_editor)
+                .await
+                .unwrap();
+
+            let deserialized =
+                deserialize_editor(item_id, workspace_id, workspace, project, cx).await;
+
+            deserialized.update(cx, |editor, cx| {
+                assert_eq!(editor.text(cx), ""); // The file should be empty as per our initial setup
+                assert!(!editor.is_dirty(cx));
+                assert!(!editor.has_conflict(cx));
+
+                let buffer = editor.buffer().read(cx).as_singleton().unwrap().read(cx);
+                assert!(buffer.file().is_some());
+            });
+        }
+
+        // Test case 3: Deserialize with no path (untitled buffer, with content and language)
+        {
+            let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
+            // Add Rust to the language, so that we can restore the language of the buffer
+            project.update(cx, |project, _| project.languages().add(rust_language()));
+
+            let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+
+            let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
+
+            let item_id = 9012 as ItemId;
+            let serialized_editor = SerializedEditor {
+                path: None,
+                contents: Some("hello".to_string()),
+                language: Some("Rust".to_string()),
+                mtime: None,
+            };
+
+            DB.save_serialized_editor(item_id, workspace_id, serialized_editor)
+                .await
+                .unwrap();
+
+            let deserialized =
+                deserialize_editor(item_id, workspace_id, workspace, project, cx).await;
+
+            deserialized.update(cx, |editor, cx| {
+                assert_eq!(editor.text(cx), "hello");
+                assert!(editor.is_dirty(cx)); // The editor should be dirty for an untitled buffer
+
+                let buffer = editor.buffer().read(cx).as_singleton().unwrap().read(cx);
+                assert_eq!(
+                    buffer.language().map(|lang| lang.name()).as_deref(),
+                    Some("Rust")
+                ); // Language should be set to Rust
+                assert!(buffer.file().is_none()); // The buffer should not have an associated file
+            });
+        }
+
+        // Test case 4: Deserialize with path, content, and old mtime
+        {
+            let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
+            let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+
+            let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
+
+            let item_id = 9345 as ItemId;
+            let old_mtime = now
+                .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
+                .unwrap();
+            let serialized_editor = SerializedEditor {
+                path: Some(PathBuf::from("/file.rs")),
+                contents: Some("fn main() {}".to_string()),
+                language: Some("Rust".to_string()),
+                mtime: Some(old_mtime),
+            };
+
+            DB.save_serialized_editor(item_id, workspace_id, serialized_editor)
+                .await
+                .unwrap();
+
+            let deserialized =
+                deserialize_editor(item_id, workspace_id, workspace, project, cx).await;
+
+            deserialized.update(cx, |editor, cx| {
+                assert_eq!(editor.text(cx), "fn main() {}");
+                assert!(editor.has_conflict(cx)); // The editor should have a conflict
+            });
+        }
+    }
 }

crates/editor/src/persistence.rs 🔗

@@ -1,12 +1,80 @@
 use anyhow::Result;
+use db::sqlez::bindable::{Bind, Column, StaticColumnCount};
 use db::sqlez::statement::Statement;
 use std::path::PathBuf;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
 
 use db::sqlez_macros::sql;
 use db::{define_connection, query};
 
 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
 
+#[derive(Clone, Debug, PartialEq, Default)]
+pub(crate) struct SerializedEditor {
+    pub(crate) path: Option<PathBuf>,
+    pub(crate) contents: Option<String>,
+    pub(crate) language: Option<String>,
+    pub(crate) mtime: Option<SystemTime>,
+}
+
+impl StaticColumnCount for SerializedEditor {
+    fn column_count() -> usize {
+        5
+    }
+}
+
+impl Bind for SerializedEditor {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let start_index = statement.bind(&self.path, start_index)?;
+        let start_index = statement.bind(&self.contents, start_index)?;
+        let start_index = statement.bind(&self.language, start_index)?;
+
+        let mtime = self.mtime.and_then(|mtime| {
+            mtime
+                .duration_since(UNIX_EPOCH)
+                .ok()
+                .map(|duration| (duration.as_secs() as i64, duration.subsec_nanos() as i32))
+        });
+        let start_index = match mtime {
+            Some((seconds, nanos)) => {
+                let start_index = statement.bind(&seconds, start_index)?;
+                statement.bind(&nanos, 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 (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)| UNIX_EPOCH + Duration::new(seconds as u64, nanos as u32));
+
+        let editor = Self {
+            path,
+            contents,
+            language,
+            mtime,
+        };
+        Ok((editor, start_index))
+    }
+}
+
 define_connection!(
     // Current schema shape using pseudo-rust syntax:
     // editors(
@@ -18,6 +86,8 @@ define_connection!(
     //   scroll_horizontal_offset: f32,
     //   content: Option<String>,
     //   language: Option<String>,
+    //   mtime_seconds: Option<i64>,
+    //   mtime_nanos: Option<i32>,
     // )
     pub static ref DB: EditorDb<WorkspaceDb> =
         &[sql! (
@@ -61,41 +131,36 @@ define_connection!(
             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;
+        ),
+        ];
 );
 
 impl EditorDb {
     query! {
-        pub fn get_path_and_contents(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(Option<PathBuf>, Option<String>, Option<String>)>> {
-            SELECT path, contents, language FROM editors
+        pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<SerializedEditor>> {
+            SELECT path, contents, language, mtime_seconds, mtime_nanos FROM editors
             WHERE item_id = ? AND workspace_id = ?
         }
     }
 
     query! {
-        pub async fn save_path(item_id: ItemId, workspace_id: WorkspaceId, path: PathBuf) -> Result<()> {
+        pub async fn save_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId, serialized_editor: SerializedEditor) -> Result<()> {
             INSERT INTO editors
-                (item_id, workspace_id, path)
+                (item_id, workspace_id, path, contents, language, mtime_seconds, mtime_nanos)
             VALUES
-                (?1, ?2, ?3)
+                (?1, ?2, ?3, ?4, ?5, ?6, ?7)
             ON CONFLICT DO UPDATE SET
                 item_id = ?1,
                 workspace_id = ?2,
-                path = ?3
-        }
-    }
-
-    query! {
-        pub async fn save_contents(item_id: ItemId, workspace: WorkspaceId, contents: Option<String>, language: Option<String>) -> Result<()> {
-            INSERT INTO editors
-                (item_id, workspace_id, contents, language)
-            VALUES
-                (?1, ?2, ?3, ?4)
-            ON CONFLICT DO UPDATE SET
-                item_id = ?1,
-                workspace_id = ?2,
-                contents = ?3,
-                language = ?4
+                path = ?3,
+                contents = ?4,
+                language = ?5,
+                mtime_seconds = ?6,
+                mtime_nanos = ?7
         }
     }
 
@@ -158,41 +223,79 @@ mod tests {
     use gpui;
 
     #[gpui::test]
-    async fn test_saving_content() {
-        env_logger::try_init().ok();
-
+    async fn test_save_and_get_serialized_editor() {
         let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
 
-        // Sanity check: make sure there is no row in the `editors` table
-        assert_eq!(DB.get_path_and_contents(1234, workspace_id).unwrap(), None);
+        let serialized_editor = SerializedEditor {
+            path: Some(PathBuf::from("testing.txt")),
+            contents: None,
+            language: None,
+            mtime: None,
+        };
 
-        // Save content/language
-        DB.save_contents(
-            1234,
-            workspace_id,
-            Some("testing".into()),
-            Some("Go".into()),
-        )
-        .await
-        .unwrap();
+        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);
 
-        // Check that it can be read from DB
-        let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap();
-        let (path, contents, language) = path_and_contents.unwrap();
-        assert!(path.is_none());
-        assert_eq!(contents, Some("testing".to_owned()));
-        assert_eq!(language, Some("Go".to_owned()));
+        // Now update contents and language
+        let serialized_editor = SerializedEditor {
+            path: Some(PathBuf::from("testing.txt")),
+            contents: Some("Test".to_owned()),
+            language: Some("Go".to_owned()),
+            mtime: None,
+        };
 
-        // Update it with NULL
-        DB.save_contents(1234, workspace_id, None, None)
+        DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
             .await
             .unwrap();
 
-        // Check that it worked
-        let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap();
-        let (path, contents, language) = path_and_contents.unwrap();
-        assert!(path.is_none());
-        assert!(contents.is_none());
-        assert!(language.is_none());
+        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 {
+            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 now = SystemTime::now();
+        let serialized_editor = SerializedEditor {
+            path: None,
+            contents: None,
+            language: None,
+            mtime: Some(now),
+        };
+
+        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);
     }
 }

crates/fs/src/fs.rs 🔗

@@ -821,6 +821,11 @@ impl FakeFs {
         })
     }
 
+    pub fn set_next_mtime(&self, next_mtime: SystemTime) {
+        let mut state = self.state.lock();
+        state.next_mtime = next_mtime;
+    }
+
     pub async fn insert_file(&self, path: impl AsRef<Path>, content: Vec<u8>) {
         self.write_file_internal(path, content).unwrap()
     }