Require save confirmation and prevent autosave for deleted files (#20742)

* `has_conflict` will now return true if the file has been deleted on
disk.  This is for treating multi-buffers as conflicted, and also
blocks auto-save.

* `has_deleted_file` is added so that the single-file buffer save can
specifically mention the delete conflict. This does not yet handle
discard (#20745).

Closes #9101
Closes #9568
Closes #20462

Release Notes:

- Improved handling of externally deleted files: auto-save will be
disabled, multibuffers will treat this as a save conflict, and single
buffers will ask for restore confirmation.

Co-authored-by: Conrad <conrad@zed.dev>
This commit is contained in:
Michael Sloan 2024-11-15 15:01:16 -07:00 committed by GitHub
parent ac5ecf5487
commit 369828f51c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 27 deletions

View file

@ -727,6 +727,10 @@ impl Item for ProjectDiagnosticsEditor {
self.excerpts.read(cx).is_dirty(cx) self.excerpts.read(cx).is_dirty(cx)
} }
fn has_deleted_file(&self, cx: &AppContext) -> bool {
self.excerpts.read(cx).has_deleted_file(cx)
}
fn has_conflict(&self, cx: &AppContext) -> bool { fn has_conflict(&self, cx: &AppContext) -> bool {
self.excerpts.read(cx).has_conflict(cx) self.excerpts.read(cx).has_conflict(cx)
} }

View file

@ -708,6 +708,10 @@ impl Item for Editor {
self.buffer().read(cx).read(cx).is_dirty() self.buffer().read(cx).read(cx).is_dirty()
} }
fn has_deleted_file(&self, cx: &AppContext) -> bool {
self.buffer().read(cx).read(cx).has_deleted_file()
}
fn has_conflict(&self, cx: &AppContext) -> bool { fn has_conflict(&self, cx: &AppContext) -> bool {
self.buffer().read(cx).read(cx).has_conflict() self.buffer().read(cx).read(cx).has_conflict()
} }

View file

@ -1749,13 +1749,20 @@ impl Buffer {
.map_or(false, |file| file.is_deleted() || !file.is_created())) .map_or(false, |file| file.is_deleted() || !file.is_created()))
} }
pub fn is_deleted(&self) -> bool {
self.file.as_ref().map_or(false, |file| file.is_deleted())
}
/// Checks if the buffer and its file have both changed since the buffer /// Checks if the buffer and its file have both changed since the buffer
/// was last saved or reloaded. /// was last saved or reloaded.
pub fn has_conflict(&self) -> bool { pub fn has_conflict(&self) -> bool {
self.has_conflict if self.has_conflict {
|| self.file.as_ref().map_or(false, |file| { return true;
file.mtime() > self.saved_mtime && self.has_unsaved_edits() }
}) let Some(file) = self.file.as_ref() else {
return false;
};
file.is_deleted() || (file.mtime() > self.saved_mtime && self.has_unsaved_edits())
} }
/// Gets a [`Subscription`] that tracks all of the changes to the buffer's text. /// Gets a [`Subscription`] that tracks all of the changes to the buffer's text.

View file

@ -186,6 +186,7 @@ pub struct MultiBufferSnapshot {
non_text_state_update_count: usize, non_text_state_update_count: usize,
edit_count: usize, edit_count: usize,
is_dirty: bool, is_dirty: bool,
has_deleted_file: bool,
has_conflict: bool, has_conflict: bool,
show_headers: bool, show_headers: bool,
} }
@ -494,6 +495,10 @@ impl MultiBuffer {
self.read(cx).is_dirty() self.read(cx).is_dirty()
} }
pub fn has_deleted_file(&self, cx: &AppContext) -> bool {
self.read(cx).has_deleted_file()
}
pub fn has_conflict(&self, cx: &AppContext) -> bool { pub fn has_conflict(&self, cx: &AppContext) -> bool {
self.read(cx).has_conflict() self.read(cx).has_conflict()
} }
@ -1419,6 +1424,7 @@ impl MultiBuffer {
snapshot.excerpts = Default::default(); snapshot.excerpts = Default::default();
snapshot.trailing_excerpt_update_count += 1; snapshot.trailing_excerpt_update_count += 1;
snapshot.is_dirty = false; snapshot.is_dirty = false;
snapshot.has_deleted_file = false;
snapshot.has_conflict = false; snapshot.has_conflict = false;
self.subscriptions.publish_mut([Edit { self.subscriptions.publish_mut([Edit {
@ -2003,6 +2009,7 @@ impl MultiBuffer {
let mut excerpts_to_edit = Vec::new(); let mut excerpts_to_edit = Vec::new();
let mut non_text_state_updated = false; let mut non_text_state_updated = false;
let mut is_dirty = false; let mut is_dirty = false;
let mut has_deleted_file = false;
let mut has_conflict = false; let mut has_conflict = false;
let mut edited = false; let mut edited = false;
let mut buffers = self.buffers.borrow_mut(); let mut buffers = self.buffers.borrow_mut();
@ -2028,6 +2035,7 @@ impl MultiBuffer {
edited |= buffer_edited; edited |= buffer_edited;
non_text_state_updated |= buffer_non_text_state_updated; non_text_state_updated |= buffer_non_text_state_updated;
is_dirty |= buffer.is_dirty(); is_dirty |= buffer.is_dirty();
has_deleted_file |= buffer.file().map_or(false, |file| file.is_deleted());
has_conflict |= buffer.has_conflict(); has_conflict |= buffer.has_conflict();
} }
if edited { if edited {
@ -2037,6 +2045,7 @@ impl MultiBuffer {
snapshot.non_text_state_update_count += 1; snapshot.non_text_state_update_count += 1;
} }
snapshot.is_dirty = is_dirty; snapshot.is_dirty = is_dirty;
snapshot.has_deleted_file = has_deleted_file;
snapshot.has_conflict = has_conflict; snapshot.has_conflict = has_conflict;
excerpts_to_edit.sort_unstable_by_key(|(locator, _, _)| *locator); excerpts_to_edit.sort_unstable_by_key(|(locator, _, _)| *locator);
@ -3691,6 +3700,10 @@ impl MultiBufferSnapshot {
self.is_dirty self.is_dirty
} }
pub fn has_deleted_file(&self) -> bool {
self.has_deleted_file
}
pub fn has_conflict(&self) -> bool { pub fn has_conflict(&self) -> bool {
self.has_conflict self.has_conflict
} }

View file

@ -228,6 +228,9 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
fn is_dirty(&self, _: &AppContext) -> bool { fn is_dirty(&self, _: &AppContext) -> bool {
false false
} }
fn has_deleted_file(&self, _: &AppContext) -> bool {
false
}
fn has_conflict(&self, _: &AppContext) -> bool { fn has_conflict(&self, _: &AppContext) -> bool {
false false
} }
@ -405,6 +408,7 @@ pub trait ItemHandle: 'static + Send {
fn item_id(&self) -> EntityId; fn item_id(&self) -> EntityId;
fn to_any(&self) -> AnyView; fn to_any(&self) -> AnyView;
fn is_dirty(&self, cx: &AppContext) -> bool; fn is_dirty(&self, cx: &AppContext) -> bool;
fn has_deleted_file(&self, cx: &AppContext) -> bool;
fn has_conflict(&self, cx: &AppContext) -> bool; fn has_conflict(&self, cx: &AppContext) -> bool;
fn can_save(&self, cx: &AppContext) -> bool; fn can_save(&self, cx: &AppContext) -> bool;
fn save( fn save(
@ -768,6 +772,10 @@ impl<T: Item> ItemHandle for View<T> {
self.read(cx).is_dirty(cx) self.read(cx).is_dirty(cx)
} }
fn has_deleted_file(&self, cx: &AppContext) -> bool {
self.read(cx).has_deleted_file(cx)
}
fn has_conflict(&self, cx: &AppContext) -> bool { fn has_conflict(&self, cx: &AppContext) -> bool {
self.read(cx).has_conflict(cx) self.read(cx).has_conflict(cx)
} }

View file

@ -1541,18 +1541,25 @@ impl Pane {
const CONFLICT_MESSAGE: &str = const CONFLICT_MESSAGE: &str =
"This file has changed on disk since you started editing it. Do you want to overwrite it?"; "This file has changed on disk since you started editing it. Do you want to overwrite it?";
const DELETED_MESSAGE: &str =
"This file has been deleted on disk since you started editing it. Do you want to recreate it?";
if save_intent == SaveIntent::Skip { if save_intent == SaveIntent::Skip {
return Ok(true); return Ok(true);
} }
let (mut has_conflict, mut is_dirty, mut can_save, can_save_as) = cx.update(|cx| { let (mut has_conflict, mut is_dirty, mut can_save, is_singleton, has_deleted_file) = cx
( .update(|cx| {
item.has_conflict(cx), (
item.is_dirty(cx), item.has_conflict(cx),
item.can_save(cx), item.is_dirty(cx),
item.is_singleton(cx), item.can_save(cx),
) item.is_singleton(cx),
})?; item.has_deleted_file(cx),
)
})?;
let can_save_as = is_singleton;
// when saving a single buffer, we ignore whether or not it's dirty. // when saving a single buffer, we ignore whether or not it's dirty.
if save_intent == SaveIntent::Save || save_intent == SaveIntent::SaveWithoutFormat { if save_intent == SaveIntent::Save || save_intent == SaveIntent::SaveWithoutFormat {
@ -1572,22 +1579,45 @@ impl Pane {
let should_format = save_intent != SaveIntent::SaveWithoutFormat; let should_format = save_intent != SaveIntent::SaveWithoutFormat;
if has_conflict && can_save { if has_conflict && can_save {
let answer = pane.update(cx, |pane, cx| { if has_deleted_file && is_singleton {
pane.activate_item(item_ix, true, true, cx); let answer = pane.update(cx, |pane, cx| {
cx.prompt( pane.activate_item(item_ix, true, true, cx);
PromptLevel::Warning, cx.prompt(
CONFLICT_MESSAGE, PromptLevel::Warning,
None, DELETED_MESSAGE,
&["Overwrite", "Discard", "Cancel"], None,
) &["Overwrite", "Close", "Cancel"],
})?; )
match answer.await { })?;
Ok(0) => { match answer.await {
pane.update(cx, |_, cx| item.save(should_format, project, cx))? Ok(0) => {
.await? pane.update(cx, |_, cx| item.save(should_format, project, cx))?
.await?
}
Ok(1) => {
pane.update(cx, |pane, cx| pane.remove_item(item_ix, false, false, cx))?;
}
_ => return Ok(false),
}
return Ok(true);
} else {
let answer = pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, true, true, cx);
cx.prompt(
PromptLevel::Warning,
CONFLICT_MESSAGE,
None,
&["Overwrite", "Discard", "Cancel"],
)
})?;
match answer.await {
Ok(0) => {
pane.update(cx, |_, cx| item.save(should_format, project, cx))?
.await?
}
Ok(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?,
_ => return Ok(false),
} }
Ok(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?,
_ => return Ok(false),
} }
} else if is_dirty && (can_save || can_save_as) { } else if is_dirty && (can_save || can_save_as) {
if save_intent == SaveIntent::Close { if save_intent == SaveIntent::Close {