From 50aef1f115493aab506df9d5b33da5435dc36bfc Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:22:03 -0600 Subject: [PATCH] buffer: Reload after undo when file changed while dirty (#51037) 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 Co-authored-by: Ben Kunkle --- crates/language/src/buffer.rs | 12 +++- .../tests/integration/project_tests.rs | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index a8bf8dd83ca76f8e9bd9892c1355ca8a7835867a..f92ae2419edf61aaa20643c3f87dac2f4af8bf4e 100644 --- a/crates/language/src/buffer.rs +++ b/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(); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 2cecc5054df29b024530e39b6bf61f74c64fa850..0080236758214b284b74abc2f1831b9f9978241e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/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);