use std::path::PathBuf;
use crate::diff::ReconcileOutcome;
use crate::ui::ModalKind;
use crate::watcher::{WatchedChange, WatchedEvent};
use super::flash::MessageKind;
use super::modal::dirty_conflict_discard_confirm::DirtyConflictDiscardConfirmModal;
use super::modal::dirty_conflict_save_copy::DirtyConflictSaveCopyModal;
use super::modal::{DirtyConflictModal, FileDeletedModal, SaveAsModal};
use super::App;
impl App {
pub(crate) fn handle_watcher_event(&mut self, event: WatchedEvent) {
match event {
WatchedEvent::Change(change) => self.handle_file_changed(change),
WatchedEvent::Removed { path } => self.handle_file_removed(path),
WatchedEvent::ReadError { path, error } => {
if self.file_path.as_deref() != Some(path.as_path()) {
return;
}
self.notify(
format!("Could not read {}: {}", path.display(), error),
ModalKind::Warning,
);
}
}
}
pub(crate) fn handle_file_removed(&mut self, path: PathBuf) {
if self.file_path.as_deref() != Some(path.as_path()) {
return;
}
if self.modal_stack.contains::<FileDeletedModal>()
|| self
.modal_stack
.find_first::<SaveAsModal>()
.is_some_and(|m| m.is_deletion_recovery())
{
return;
}
if self.editor.diff.is_some() {
self.exit_diff_mode_discarding();
}
self.modal_stack.push(Box::new(FileDeletedModal::new(path)));
self.needs_draw = true;
}
pub(crate) fn handle_file_changed(&mut self, mut change: WatchedChange) {
if self.file_path.as_deref() != Some(change.path.as_path()) {
return;
}
change.contents = crate::document::buffer::normalize_newlines(change.contents);
if self
.modal_stack
.find_first::<SaveAsModal>()
.is_some_and(|m| m.is_deletion_recovery())
{
return;
}
self.modal_stack.remove_first::<FileDeletedModal>();
let incoming_hash = seahash::hash(change.contents.as_bytes());
if self.last_disk_hash == Some(incoming_hash) {
return;
}
if self.editor.diff.is_some() {
self.last_disk_hash = Some(incoming_hash); self.reconcile_diff_with_disk(change.contents);
return;
}
let buffer_text = self.editor.buffer.contents();
let buffer_hash = seahash::hash(buffer_text.as_bytes());
if incoming_hash == buffer_hash {
self.last_disk_hash = Some(incoming_hash);
return;
}
self.last_disk_hash = Some(incoming_hash);
if self.editor.dirty {
let has_save_copy = self.modal_stack.contains::<DirtyConflictSaveCopyModal>();
let has_discard_confirm = self
.modal_stack
.contains::<DirtyConflictDiscardConfirmModal>();
if has_save_copy || has_discard_confirm {
if let Some(parent) = self.modal_stack.find_first_mut::<DirtyConflictModal>() {
parent.set_on_disk_contents(change.contents.clone());
}
if has_save_copy {
if let Some(child) = self
.modal_stack
.find_first_mut::<DirtyConflictSaveCopyModal>()
{
child.set_on_disk_contents(change.contents);
}
} else if let Some(child) = self
.modal_stack
.find_first_mut::<DirtyConflictDiscardConfirmModal>()
{
child.set_on_disk_contents(change.contents);
}
return;
}
self.modal_stack.remove_first::<DirtyConflictModal>();
self.modal_stack
.push(Box::new(DirtyConflictModal::new(change.contents)));
} else if self.config.editor.diff_on_change {
self.enter_diff_mode(change.contents);
} else {
self.reload_buffer_from_disk(change.contents);
}
}
fn reconcile_diff_with_disk(&mut self, new_disk: String) {
let outcome = self
.editor
.diff
.as_mut()
.expect("guarded by diff.is_some()")
.reconcile_with_disk(&new_disk);
match outcome {
ReconcileOutcome::StillReviewing { reset } => {
self.editor.refresh_diff_parse();
self.editor.pending_focus_scroll = true;
self.flash(
if reset > 0 {
"File changed on disk — updated hunks reset for review"
} else {
"File changed on disk — review updated"
},
MessageKind::Info,
);
}
ReconcileOutcome::NoChangesRemain => {
self.editor.exit_diff_mode();
self.flash(
"On-disk changes reverted — nothing to review",
MessageKind::Info,
);
}
}
self.needs_draw = true;
}
pub(crate) fn reload_buffer_from_disk(&mut self, contents: String) {
let Some(path) = self.editor.buffer.path().map(|p| p.to_path_buf()) else {
return;
};
let previous_version = self.editor.buffer.version();
let line_ending = self.editor.buffer.line_ending();
let new_buffer =
crate::document::Buffer::reload(&path, &contents, previous_version, line_ending);
self.exit_search_flow();
self.editor.replace_buffer(new_buffer);
self.on_document_contents_swapped();
self.needs_draw = true;
self.flash("Reloaded from disk", super::flash::MessageKind::Info);
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::app::modal::DirtyConflictModal;
use crate::app::test_utils::make_app;
use crate::document::Buffer;
use crate::watcher::WatchedChange;
fn app_with_temp_file(initial: &str) -> (crate::app::App, tempfile::NamedTempFile) {
let tmp = tempfile::NamedTempFile::new().expect("temp file");
std::fs::write(tmp.path(), initial).expect("seed");
let mut app = make_app();
app.editor.buffer = Buffer::for_new_file(tmp.path());
if !initial.is_empty() {
app.editor.buffer.insert(0, initial);
}
app.editor.refresh_parsed();
app.file_path = Some(tmp.path().to_path_buf());
app.set_disk_hash(initial.as_bytes());
(app, tmp)
}
fn file_changed_event(path: PathBuf, contents: &str) -> WatchedChange {
WatchedChange {
path,
contents: contents.to_owned(),
}
}
#[test]
fn own_write_echo_is_dropped() {
let (mut app, tmp) = app_with_temp_file("alpha");
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "alpha"));
assert!(
!app.modal_stack.contains::<DirtyConflictModal>(),
"own-write echo must not open the dirty-conflict modal",
);
assert!(
app.transient.is_none(),
"own-write echo must not produce a flash",
);
}
#[test]
fn external_change_with_clean_buffer_enters_diff() {
let (mut app, tmp) = app_with_temp_file("alpha");
assert!(!app.editor.dirty);
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "beta"));
assert!(
app.editor.diff.is_some(),
"clean buffer + external change must enter diff review",
);
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
assert!(
!app.modal_stack.contains::<DirtyConflictModal>(),
"clean entry must not open the dirty-conflict modal",
);
assert_eq!(
app.editor.buffer.contents(),
"alpha",
"buffer must not be silently overwritten",
);
assert_eq!(app.last_disk_hash, Some(seahash::hash(b"beta")));
}
#[test]
fn clean_buffer_with_diff_disabled_reloads_silently() {
let (mut app, tmp) = app_with_temp_file("alpha");
app.config.editor.diff_on_change = false;
assert!(!app.editor.dirty);
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "beta"));
assert!(
app.editor.diff.is_none(),
"diff-on-change off must not enter diff review",
);
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
assert_eq!(
app.editor.buffer.contents(),
"beta",
"clean buffer must be reloaded with the disk contents",
);
assert_eq!(app.last_disk_hash, Some(seahash::hash(b"beta")));
}
#[test]
fn dirty_buffer_with_diff_disabled_still_prompts() {
let (mut app, tmp) = app_with_temp_file("alpha");
app.config.editor.diff_on_change = false;
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, '!');
app.editor.dirty = true;
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "external"));
assert!(
app.modal_stack.contains::<DirtyConflictModal>(),
"a dirty buffer must still prompt when diff-on-change is off",
);
assert!(app.editor.buffer.contents().ends_with('!'));
}
#[test]
fn clean_buffer_byte_identical_disk_does_not_enter_diff() {
let (mut app, tmp) = app_with_temp_file("alpha");
assert!(!app.editor.dirty);
app.last_disk_hash = Some(seahash::hash(b"stale"));
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "alpha"));
assert!(
app.editor.diff.is_none(),
"byte-identical disk must not enter diff even with a clean buffer",
);
assert!(!app.modal_stack.contains::<DirtyConflictModal>());
assert_eq!(app.last_disk_hash, Some(seahash::hash(b"alpha")));
}
#[test]
fn external_change_with_dirty_buffer_opens_modal() {
let (mut app, tmp) = app_with_temp_file("alpha");
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, '!');
app.editor.dirty = true;
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "external"));
assert!(
app.modal_stack.contains::<DirtyConflictModal>(),
"dirty buffer + external change must open the conflict modal",
);
assert!(app.editor.buffer.contents().ends_with('!'));
}
#[test]
fn disk_equal_to_buffer_skips_modal_and_stamps_hash() {
let (mut app, tmp) = app_with_temp_file("alpha");
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, '!');
app.editor.dirty = true;
app.last_disk_hash = Some(seahash::hash(b"alpha"));
let buffer_text = app.editor.buffer.contents();
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), &buffer_text));
assert!(
!app.modal_stack.contains::<DirtyConflictModal>(),
"byte-identical change must skip the modal",
);
assert_eq!(
app.last_disk_hash,
Some(seahash::hash(buffer_text.as_bytes()))
);
}
#[test]
fn external_change_in_diff_preserves_decisions() {
use crate::diff::Decision;
let (mut app, tmp) = app_with_temp_file("a\nb\nc\nd\ne\n");
app.handle_file_changed(file_changed_event(
tmp.path().to_path_buf(),
"a\nB\nc\nD\ne\n",
));
assert!(app.editor.diff.is_some(), "first change enters diff");
let diff = app.editor.diff.as_ref().unwrap();
assert_eq!(diff.hunks.len(), 2);
let h0_id = diff.hunks[0].id;
app.editor.diff.as_mut().unwrap().decisions[0] = Decision::Accepted;
app.transient = None;
app.handle_file_changed(file_changed_event(
tmp.path().to_path_buf(),
"a\nB\nc\nDD\ne\n",
));
assert!(app.editor.diff.is_some());
assert_eq!(app.editor.mode, crate::editor::Mode::Diff);
let diff = app.editor.diff.as_ref().unwrap();
let h0 = diff.hunks.iter().position(|h| h.id == h0_id).expect("h0");
assert_eq!(diff.decisions[h0], Decision::Accepted);
assert!(app.transient.is_some(), "reconcile records a flash");
}
#[test]
fn external_revert_in_diff_exits_diff() {
let (mut app, tmp) = app_with_temp_file("a\nb\n");
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nB\n"));
assert!(app.editor.diff.is_some(), "first change enters diff");
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nb\n"));
assert!(app.editor.diff.is_none(), "revert exits diff mode");
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
assert_eq!(app.editor.buffer.contents(), "a\nb\n");
}
#[test]
fn deletion_opens_file_deleted_modal_for_clean_buffer() {
use crate::app::modal::FileDeletedModal;
let (mut app, tmp) = app_with_temp_file("alpha");
assert!(!app.editor.dirty);
app.handle_file_removed(tmp.path().to_path_buf());
assert!(
app.modal_stack.contains::<FileDeletedModal>(),
"deletion must surface the file-deleted modal",
);
assert!(app.editor.diff.is_none());
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
}
#[test]
fn deletion_is_idempotent() {
use crate::app::modal::FileDeletedModal;
let (mut app, tmp) = app_with_temp_file("alpha");
let base = app.modal_stack.len();
app.handle_file_removed(tmp.path().to_path_buf());
app.handle_file_removed(tmp.path().to_path_buf());
assert_eq!(
app.modal_stack.len() - base,
1,
"a repeated deletion signal must not stack duplicate modals",
);
assert!(app.modal_stack.contains::<FileDeletedModal>());
}
#[test]
fn deletion_for_other_path_is_ignored() {
use crate::app::modal::FileDeletedModal;
let (mut app, _tmp) = app_with_temp_file("alpha");
app.handle_file_removed(PathBuf::from("/nonexistent/other.md"));
assert!(!app.modal_stack.contains::<FileDeletedModal>());
}
#[test]
fn deletion_during_diff_exits_diff_and_prompts() {
use crate::app::modal::FileDeletedModal;
let (mut app, tmp) = app_with_temp_file("a\nb\n");
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nB\n"));
assert!(app.editor.diff.is_some(), "first change enters diff");
app.handle_file_removed(tmp.path().to_path_buf());
assert!(app.editor.diff.is_none(), "deletion must exit diff mode");
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
assert!(app.modal_stack.contains::<FileDeletedModal>());
}
#[test]
fn deletion_during_diff_clears_orphaned_diff_modals() {
use crate::app::modal::{DiffQuitConfirmModal, FileDeletedModal};
let (mut app, tmp) = app_with_temp_file("a\nb\n");
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nB\n"));
assert!(app.editor.diff.is_some());
app.modal_stack.push(Box::new(DiffQuitConfirmModal::new()));
app.handle_file_removed(tmp.path().to_path_buf());
assert!(
!app.modal_stack.contains::<DiffQuitConfirmModal>(),
"the orphaned diff-quit confirmation must be removed",
);
assert!(app.modal_stack.contains::<FileDeletedModal>());
}
#[test]
fn deletion_is_idempotent_across_save_as_flow() {
use crate::app::modal::{FileDeletedModal, SaveAsModal};
let (mut app, tmp) = app_with_temp_file("alpha");
app.handle_file_removed(tmp.path().to_path_buf());
app.modal_stack.remove_first::<FileDeletedModal>();
app.modal_stack
.push(Box::new(SaveAsModal::for_deleted_file("x".into())));
let base = app.modal_stack.len();
app.handle_file_removed(tmp.path().to_path_buf());
assert_eq!(
app.modal_stack.len(),
base,
"must not stack a duplicate prompt"
);
assert!(!app.modal_stack.contains::<FileDeletedModal>());
assert!(app.modal_stack.contains::<SaveAsModal>());
}
#[test]
fn recreate_while_deleted_modal_open_dismisses_it_and_reviews() {
use crate::app::modal::FileDeletedModal;
let (mut app, tmp) = app_with_temp_file("a\nb\n");
app.handle_file_removed(tmp.path().to_path_buf());
assert!(app.modal_stack.contains::<FileDeletedModal>());
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nB\n"));
assert!(
!app.modal_stack.contains::<FileDeletedModal>(),
"the file-deleted prompt must be dismissed once the file returns",
);
assert!(app.editor.diff.is_some(), "the recreate enters diff review");
}
#[test]
fn recreate_during_save_as_does_not_enter_diff_behind_it() {
use crate::app::modal::{FileDeletedModal, SaveAsModal};
let (mut app, tmp) = app_with_temp_file("a\nb\n");
app.handle_file_removed(tmp.path().to_path_buf());
app.modal_stack.remove_first::<FileDeletedModal>();
app.modal_stack
.push(Box::new(SaveAsModal::for_deleted_file("x".into())));
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "a\nB\n"));
assert!(
app.modal_stack.contains::<SaveAsModal>(),
"the save-as path entry must stay open",
);
assert!(
app.editor.diff.is_none(),
"an external recreate must not enter diff mode behind save-as",
);
assert_ne!(app.editor.mode, crate::editor::Mode::Diff);
}
#[test]
fn voluntary_save_as_does_not_suppress_external_change() {
use crate::app::modal::{DirtyConflictModal, SaveAsModal};
let (mut app, tmp) = app_with_temp_file("alpha");
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, '!');
app.editor.dirty = true;
app.modal_stack.push(Box::new(SaveAsModal::for_buffer_path(
Some(tmp.path()),
None,
)));
assert!(
!app.modal_stack
.find_first::<SaveAsModal>()
.unwrap()
.is_deletion_recovery(),
"this must be the voluntary (non-recovery) variant",
);
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "external"));
assert!(
app.modal_stack.contains::<DirtyConflictModal>(),
"a voluntary save-as must not suppress the dirty-conflict modal",
);
}
#[test]
fn change_for_other_path_is_ignored() {
let (mut app, _tmp) = app_with_temp_file("alpha");
let other = PathBuf::from("/nonexistent/path.md");
app.handle_file_changed(file_changed_event(other, "anything"));
assert!(!app.modal_stack.contains::<DirtyConflictModal>());
assert_eq!(app.last_disk_hash, Some(seahash::hash(b"alpha")));
}
#[test]
fn second_external_change_refreshes_open_discard_confirm_modal() {
use crate::app::modal::dirty_conflict_discard_confirm::DirtyConflictDiscardConfirmModal;
let (mut app, tmp) = app_with_temp_file("alpha");
let len = app.editor.buffer.len_chars();
app.editor.buffer.insert_char(len, '!');
app.editor.dirty = true;
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "first"));
assert!(app.modal_stack.contains::<DirtyConflictModal>());
app.modal_stack
.push(Box::new(DirtyConflictDiscardConfirmModal::new(
"first".to_owned(),
)));
app.handle_file_changed(file_changed_event(tmp.path().to_path_buf(), "second"));
let child = app
.modal_stack
.find_first_mut::<DirtyConflictDiscardConfirmModal>()
.expect("child modal still on stack");
let carried = std::mem::take(&mut child.on_disk_contents);
assert_eq!(carried, "second");
let parent = app
.modal_stack
.find_first_mut::<DirtyConflictModal>()
.expect("parent modal still on stack");
let parent_carried = std::mem::take(&mut parent.on_disk_contents);
assert_eq!(parent_carried, "second");
}
}