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

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
This commit is contained in:
Thorsten Ball 2024-07-25 18:04:47 +02:00 committed by GitHub
parent 6d3eaa055f
commit 7146087b44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 447 additions and 120 deletions

View File

@ -1,7 +1,9 @@
use crate::{ use crate::{
editor_settings::SeedQuerySetting, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, editor_settings::SeedQuerySetting,
Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, persistence::{SerializedEditor, DB},
NavigationData, SearchWithinRange, ToPoint as _, scroll::ScrollAnchor,
Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer,
MultiBufferSnapshot, NavigationData, SearchWithinRange, ToPoint as _,
}; };
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use collections::HashSet; use collections::HashSet;
@ -914,18 +916,23 @@ impl SerializableItem for Editor {
item_id: ItemId, item_id: ItemId,
cx: &mut ViewContext<Pane>, cx: &mut ViewContext<Pane>,
) -> Task<Result<View<Self>>> { ) -> Task<Result<View<Self>>> {
let path_content_language = match DB let serialized_editor = match DB
.get_path_and_contents(item_id, workspace_id) .get_serialized_editor(item_id, workspace_id)
.context("Failed to query editor state") .context("Failed to query editor state")
{ {
Ok(Some((path, content, language))) => { Ok(Some(serialized_editor)) => {
if ProjectSettings::get_global(cx) if ProjectSettings::get_global(cx)
.session .session
.restore_unsaved_buffers .restore_unsaved_buffers
{ {
(path, content, language) serialized_editor
} else { } else {
(path, None, None) SerializedEditor {
path: serialized_editor.path,
contents: None,
language: None,
mtime: None,
}
} }
} }
Ok(None) => { Ok(None) => {
@ -936,34 +943,48 @@ impl SerializableItem for Editor {
} }
}; };
match path_content_language { let buffer_task = match serialized_editor {
(None, Some(content), language_name) => cx.spawn(|_, mut cx| async move { SerializedEditor {
let language = if let Some(language_name) = language_name { path: None,
let language_registry = contents: Some(contents),
project.update(&mut cx, |project, _| project.languages().clone())?; 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?) // We don't fail here, because we'd rather not set the language if the name changed
} else { // than fail to restore the buffer.
None language_registry
}; .language_for_name(&language_name)
.await
.ok()
} else {
None
};
// First create the empty buffer // First create the empty buffer
let buffer = project.update(&mut cx, |project, cx| { let buffer = project.update(&mut cx, |project, cx| {
project.create_local_buffer("", language, cx) project.create_local_buffer("", language, cx)
})?; })?;
// Then set the text so that the dirty bit is set correctly // Then set the text so that the dirty bit is set correctly
buffer.update(&mut cx, |buffer, cx| { buffer.update(&mut cx, |buffer, cx| {
buffer.set_text(content, cx); buffer.set_text(contents, cx);
})?; })?;
cx.new_view(|cx| { anyhow::Ok(buffer)
let mut editor = Editor::for_buffer(buffer, Some(project), cx); }
editor.read_scroll_position_from_db(item_id, workspace_id, cx);
editor
})
}), }),
(Some(path), contents, _) => { SerializedEditor {
path: Some(path),
contents,
mtime,
..
} => {
let project_item = project.update(cx, |project, cx| { let project_item = project.update(cx, |project, cx| {
let (worktree, path) = project let (worktree, path) = project
.find_worktree(&path, cx) .find_worktree(&path, cx)
@ -978,7 +999,7 @@ impl SerializableItem for Editor {
project_item project_item
.map(|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 (_, project_item) = project_item.await?;
let buffer = project_item.downcast::<Buffer>().map_err(|_| { let buffer = project_item.downcast::<Buffer>().map_err(|_| {
anyhow!("Project item at stored path was not a buffer") anyhow!("Project item at stored path was not a buffer")
@ -988,27 +1009,43 @@ impl SerializableItem for Editor {
// disk and then overwrite the content. // disk and then overwrite the content.
// But for now, it keeps the implementation of the content serialization // 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 // 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 { if let Some(buffer_text) = contents {
buffer.update(&mut cx, |buffer, cx| { 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); buffer.set_text(buffer_text, cx);
})?; })?;
} }
pane.update(&mut cx, |_, cx| { Ok(buffer)
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
})
})
}) })
}) })
.unwrap_or_else(|error| Task::ready(Err(error))) .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( fn serialize(
@ -1036,36 +1073,33 @@ impl SerializableItem for Editor {
let buffer = self.buffer().read(cx).as_singleton()?; let buffer = self.buffer().read(cx).as_singleton()?;
let is_dirty = buffer.read(cx).is_dirty(); let is_dirty = buffer.read(cx).is_dirty();
let path = buffer let local_file = buffer.read(cx).file().and_then(|file| file.as_local());
.read(cx) let path = local_file.map(|file| file.abs_path(cx));
.file() let mtime = buffer.read(cx).saved_mtime();
.and_then(|file| file.as_local())
.map(|file| file.abs_path(cx));
let snapshot = buffer.read(cx).snapshot(); let snapshot = buffer.read(cx).snapshot();
Some(cx.spawn(|_this, cx| async move { Some(cx.spawn(|_this, cx| async move {
cx.background_executor() cx.background_executor()
.spawn(async move { .spawn(async move {
if let Some(path) = path { let (contents, language) = if serialize_dirty_buffers && is_dirty {
DB.save_path(item_id, workspace_id, path.clone()) let contents = snapshot.text();
.await let language = snapshot.language().map(|lang| lang.name().to_string());
.context("failed to save path of buffer")? (Some(contents), language)
} } else {
(None, None)
};
if serialize_dirty_buffers { let editor = SerializedEditor {
let (contents, language) = if is_dirty { path,
let contents = snapshot.text(); contents,
let language = snapshot.language().map(|lang| lang.name().to_string()); language,
(Some(contents), language) mtime,
} else { };
(None, None)
};
DB.save_contents(item_id, workspace_id, contents, language) DB.save_serialized_editor(item_id, workspace_id, editor)
.await?; .await
} .context("failed to save serialized editor")
anyhow::Ok(())
}) })
.await .await
.context("failed to save contents of buffer")?; .context("failed to save contents of buffer")?;
@ -1474,10 +1508,16 @@ fn path_for_file<'a>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::editor_tests::init_test;
use super::*; use super::*;
use gpui::AppContext; use gpui::{AppContext, VisualTestContext};
use language::TestFile; use language::{LanguageMatcher, TestFile};
use std::path::Path; use project::FakeFs;
use std::{
path::{Path, PathBuf},
time::SystemTime,
};
#[gpui::test] #[gpui::test]
fn test_path_for_file(cx: &mut AppContext) { fn test_path_for_file(cx: &mut AppContext) {
@ -1487,4 +1527,183 @@ mod tests {
}; };
assert_eq!(path_for_file(&file, 0, false, cx), None); 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
});
}
}
} }

View File

@ -1,12 +1,80 @@
use anyhow::Result; use anyhow::Result;
use db::sqlez::bindable::{Bind, Column, StaticColumnCount};
use db::sqlez::statement::Statement; use db::sqlez::statement::Statement;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use db::sqlez_macros::sql; use db::sqlez_macros::sql;
use db::{define_connection, query}; use db::{define_connection, query};
use workspace::{ItemId, WorkspaceDb, WorkspaceId}; 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!( define_connection!(
// Current schema shape using pseudo-rust syntax: // Current schema shape using pseudo-rust syntax:
// editors( // editors(
@ -18,6 +86,8 @@ define_connection!(
// scroll_horizontal_offset: f32, // scroll_horizontal_offset: f32,
// content: Option<String>, // content: Option<String>,
// language: Option<String>, // language: Option<String>,
// mtime_seconds: Option<i64>,
// mtime_nanos: Option<i32>,
// ) // )
pub static ref DB: EditorDb<WorkspaceDb> = pub static ref DB: EditorDb<WorkspaceDb> =
&[sql! ( &[sql! (
@ -61,41 +131,36 @@ define_connection!(
DROP TABLE editors; DROP TABLE editors;
ALTER TABLE new_editors_tmp RENAME TO 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 { impl EditorDb {
query! { query! {
pub fn get_path_and_contents(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<(Option<PathBuf>, Option<String>, Option<String>)>> { pub fn get_serialized_editor(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<SerializedEditor>> {
SELECT path, contents, language FROM editors SELECT path, contents, language, mtime_seconds, mtime_nanos FROM editors
WHERE item_id = ? AND workspace_id = ? WHERE item_id = ? AND workspace_id = ?
} }
} }
query! { 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 INSERT INTO editors
(item_id, workspace_id, path) (item_id, workspace_id, path, contents, language, mtime_seconds, mtime_nanos)
VALUES VALUES
(?1, ?2, ?3) (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT DO UPDATE SET ON CONFLICT DO UPDATE SET
item_id = ?1, item_id = ?1,
workspace_id = ?2, workspace_id = ?2,
path = ?3 path = ?3,
} contents = ?4,
} language = ?5,
mtime_seconds = ?6,
query! { mtime_nanos = ?7
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
} }
} }
@ -158,41 +223,79 @@ mod tests {
use gpui; use gpui;
#[gpui::test] #[gpui::test]
async fn test_saving_content() { async fn test_save_and_get_serialized_editor() {
env_logger::try_init().ok();
let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap();
// Sanity check: make sure there is no row in the `editors` table let serialized_editor = SerializedEditor {
assert_eq!(DB.get_path_and_contents(1234, workspace_id).unwrap(), None); path: Some(PathBuf::from("testing.txt")),
contents: None,
language: None,
mtime: None,
};
// Save content/language DB.save_serialized_editor(1234, workspace_id, serialized_editor.clone())
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)
.await .await
.unwrap(); .unwrap();
// Check that it worked let have = DB
let path_and_contents = DB.get_path_and_contents(1234, workspace_id).unwrap(); .get_serialized_editor(1234, workspace_id)
let (path, contents, language) = path_and_contents.unwrap(); .unwrap()
assert!(path.is_none()); .unwrap();
assert!(contents.is_none()); assert_eq!(have, serialized_editor);
assert!(language.is_none());
// 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);
} }
} }

View File

@ -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>) { pub async fn insert_file(&self, path: impl AsRef<Path>, content: Vec<u8>) {
self.write_file_internal(path, content).unwrap() self.write_file_internal(path, content).unwrap()
} }