@@ -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
+ });
+ }
+ }
}
@@ -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);
}
}