buffer: Reload after undo when file changed while dirty (#51037)

lex00 , Claude Opus 4.6 , and Ben Kunkle created

Closes #48697
Supersedes #48698
Related to #38109

## Problem

If you edit a file and an external tool writes to it while you have
unsaved changes, Zed tracks the new file but skips the reload to
preserve your edits. If you then undo everything, the buffer goes back
to clean but still shows the old content. The disk has moved on, but
nothing triggers a reload.

## Fix

In `did_edit()`, when the buffer transitions from dirty to clean, check
if the file's mtime changed while it was dirty. If so, emit
`ReloadNeeded`. Only fires for files that still exist on disk
(`DiskState::Present`).

7 lines in `crates/language/src/buffer.rs`.

### No double reload

`file_updated()` suppresses `ReloadNeeded` when the buffer is dirty
(that's the whole bug). So by the time `did_edit()` fires on
dirty-to-clean, no prior reload was emitted for this file change. The
two paths are mutually exclusive.

## Test plan

- New: `test_dirty_buffer_reloads_after_undo`
- No regression in `test_buffer_is_dirty` or other buffer tests
- All project integration tests pass
- clippy clean

Release Notes:

- Fixed an issue where buffer content could become stale after undoing
edits when an external tool wrote to the file while the buffer was
dirty.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/language/src/buffer.rs                     | 12 ++
crates/project/tests/integration/project_tests.rs | 69 +++++++++++++++++
2 files changed, 80 insertions(+), 1 deletion(-)

Detailed changes

crates/language/src/buffer.rs 🔗

@@ -2859,9 +2859,19 @@ impl Buffer {
 
         self.reparse(cx, true);
         cx.emit(BufferEvent::Edited { is_local });
-        if was_dirty != self.is_dirty() {
+        let is_dirty = self.is_dirty();
+        if was_dirty != is_dirty {
             cx.emit(BufferEvent::DirtyChanged);
         }
+        if was_dirty && !is_dirty {
+            if let Some(file) = self.file.as_ref() {
+                if matches!(file.disk_state(), DiskState::Present { .. })
+                    && file.disk_state().mtime() != self.saved_mtime
+                {
+                    cx.emit(BufferEvent::ReloadNeeded);
+                }
+            }
+        }
         cx.notify();
     }
 

crates/project/tests/integration/project_tests.rs 🔗

@@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
     cx.update(|cx| assert!(buffer3.read(cx).is_dirty()));
 }
 
+#[gpui::test]
+async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            "file.txt": "version 1",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let buffer = project
+        .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx))
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "version 1");
+        assert!(!buffer.is_dirty());
+    });
+
+    // User makes an edit, making the buffer dirty.
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit([(0..0, "user edit: ")], None, cx);
+    });
+
+    buffer.read_with(cx, |buffer, _| {
+        assert!(buffer.is_dirty());
+        assert_eq!(buffer.text(), "user edit: version 1");
+    });
+
+    // External tool writes new content while buffer is dirty.
+    // file_updated() updates the File but suppresses ReloadNeeded.
+    fs.save(
+        path!("/dir/file.txt").as_ref(),
+        &"version 2 from external tool".into(),
+        Default::default(),
+    )
+    .await
+    .unwrap();
+    cx.executor().run_until_parked();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert!(buffer.has_conflict());
+        assert_eq!(buffer.text(), "user edit: version 1");
+    });
+
+    // User undoes their edit. Buffer becomes clean, but disk has different
+    // content. did_edit() detects the dirty->clean transition and checks if
+    // disk changed while dirty. Since mtime differs from saved_mtime, it
+    // emits ReloadNeeded.
+    buffer.update(cx, |buffer, cx| {
+        buffer.undo(cx);
+    });
+    cx.executor().run_until_parked();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "version 2 from external tool",
+            "buffer should reload from disk after undo makes it clean"
+        );
+        assert!(!buffer.is_dirty());
+    });
+}
+
 #[gpui::test]
 async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
     init_test(cx);