diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index a2fe0922ab..50b91f4465 100644 --- a/crates/editor/src/items.rs +++ b/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, ) -> Task>> { - 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())?; + 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())?; - Some(language_registry.language_for_name(&language_name).await?) - } else { - None - }; + // 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::().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) - }; + let editor = SerializedEditor { + path, + contents, + language, + mtime, + }; - DB.save_contents(item_id, workspace_id, contents, language) - .await?; - } - - 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, + project: Model, + cx: &mut VisualTestContext, + ) -> View { + 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 { + 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 + }); + } + } } diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index ade605e99e..17c371d65e 100644 --- a/crates/editor/src/persistence.rs +++ b/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, + pub(crate) contents: Option, + pub(crate) language: Option, + pub(crate) mtime: Option, +} + +impl StaticColumnCount for SerializedEditor { + fn column_count() -> usize { + 5 + } +} + +impl Bind for SerializedEditor { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + 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::>(&None, start_index)?; + statement.bind::>(&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, i32) = Column::column(statement, start_index)?; + let (contents, start_index): (Option, i32) = + Column::column(statement, start_index)?; + let (language, start_index): (Option, i32) = + Column::column(statement, start_index)?; + let (mtime_seconds, start_index): (Option, i32) = + Column::column(statement, start_index)?; + let (mtime_nanos, start_index): (Option, 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, // language: Option, + // mtime_seconds: Option, + // mtime_nanos: Option, // ) pub static ref DB: EditorDb = &[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)>> { - SELECT path, contents, language FROM editors + pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + 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, language: Option) -> 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(); - - // 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())); - - // 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 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, + }; + + 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 { + 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); } } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 924fb9a215..7ff20df54f 100644 --- a/crates/fs/src/fs.rs +++ b/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, content: Vec) { self.write_file_internal(path, content).unwrap() }