diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 9e27a6f871650f..836c914b296e89 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1780,6 +1780,7 @@ mod tests { fn disk_state(&self) -> language::DiskState { language::DiskState::Present { mtime: ::fs::MTime::from_seconds_and_nanos(100, 42), + size: 0, } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2721c1fc552ad8..ba1bc5c6bf00eb 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -435,7 +435,7 @@ pub enum DiskState { /// File created in Zed that has not been saved. New, /// File present on the filesystem. - Present { mtime: MTime }, + Present { mtime: MTime, size: u64 }, /// Deleted file that was previously present. Deleted, /// An old version of a file that was previously present @@ -448,7 +448,7 @@ impl DiskState { pub fn mtime(self) -> Option { match self { DiskState::New => None, - DiskState::Present { mtime } => Some(mtime), + DiskState::Present { mtime, .. } => Some(mtime), DiskState::Deleted => None, DiskState::Historic { .. } => None, } @@ -2373,7 +2373,7 @@ impl Buffer { }; match file.disk_state() { DiskState::New => false, - DiskState::Present { mtime } => match self.saved_mtime { + DiskState::Present { mtime, .. } => match self.saved_mtime { Some(saved_mtime) => { mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits() } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 8b65caca7e2c2e..abeabba4bdbe73 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -532,7 +532,10 @@ impl LocalBufferStore { let new_file = if let Some(entry) = snapshot_entry { File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 654fb0344db4b7..0ba9787d2e4144 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -808,7 +808,10 @@ impl LocalImageStore { let new_file = if let Some(entry) = snapshot_entry { worktree::File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 2394542a761a54..1edc347a035894 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -12249,3 +12249,135 @@ mod disable_ai_settings_tests { }); } } + +#[gpui::test] +async fn test_buffer_reload_during_truncate_then_write(cx: &mut gpui::TestAppContext) { + // Reproduces #38109: scanner catches a truncated file mid-write, + // buffer reloads to empty, and the final write has the same mtime. + // Without size in DiskState, the buffer stays stuck empty. + use worktree::WorktreeModelHandle as _; + + init_test(cx); + cx.executor().allow_parking(); + + let dir = TempTree::new(json!({ + "file.txt": "original content that should not be lost", + })); + + let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + tree.flush_fs_events(cx).await; + + let buffer = project + .update(cx, |p, cx| { + p.open_local_buffer(dir.path().join("file.txt"), cx) + }) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "original content that should not be lost"); + assert!(!buffer.is_dirty()); + }); + + let file_path = dir.path().join("file.txt"); + + // Truncate the file (first half of std::fs::write). + std::fs::write(&file_path, "").unwrap(); + let truncated_mtime = std::fs::metadata(&file_path).unwrap().modified().unwrap(); + + // Scanner picks up the truncation. + tree.flush_fs_events(cx).await; + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "", + "buffer should be empty after reloading truncated file" + ); + }); + + // Write actual content (second half of std::fs::write). + std::fs::write(&file_path, "new content from AI agent").unwrap(); + + // Force mtime to match the truncated file's mtime, simulating + // coarse-grained timestamps or rapid sequential writes. + let times = std::fs::FileTimes::new().set_modified(truncated_mtime); + let file_handle = std::fs::File::options() + .write(true) + .open(&file_path) + .unwrap(); + file_handle.set_times(times).unwrap(); + drop(file_handle); + + tree.flush_fs_events(cx).await; + + // Size changed (0 -> 25 bytes) even though mtime is the same. + buffer.read_with(cx, |buffer, _| { + let file = buffer.file().expect("buffer should have a file"); + assert!( + matches!(file.disk_state(), DiskState::Present { .. }), + "disk state should be Present, got {:?}", + file.disk_state() + ); + + assert_eq!( + buffer.text(), + "new content from AI agent", + "buffer should recover when size changes even if mtime matches" + ); + }); +} + +#[gpui::test] +async fn test_buffer_recovers_from_truncate_when_mtime_differs(cx: &mut gpui::TestAppContext) { + // Control: same sequence without forcing mtime to match. + // APFS nanosecond precision means the mtime naturally differs, + // so the buffer recovers without the size fix. + use worktree::WorktreeModelHandle as _; + + init_test(cx); + cx.executor().allow_parking(); + + let dir = TempTree::new(json!({ + "file.txt": "original content", + })); + + let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + tree.flush_fs_events(cx).await; + + let buffer = project + .update(cx, |p, cx| { + p.open_local_buffer(dir.path().join("file.txt"), cx) + }) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "original content"); + }); + + let file_path = dir.path().join("file.txt"); + + // Truncate + std::fs::write(&file_path, "").unwrap(); + tree.flush_fs_events(cx).await; + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "", "buffer should be empty after truncation"); + }); + + // Write new content (mtime will naturally differ on APFS) + std::fs::write(&file_path, "recovered content").unwrap(); + tree.flush_fs_events(cx).await; + + // Different mtime triggers reload via the existing path. + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "recovered content", + "buffer should recover when mtime differs after truncate-then-write" + ); + }); +} diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 86589423022d3d..28b28fe6bd0eda 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1322,6 +1322,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1378,6 +1379,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1575,6 +1577,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, entry_id: None, is_local: true, @@ -3280,7 +3283,10 @@ impl File { worktree, path: entry.path.clone(), disk_state: if let Some(mtime) = entry.mtime { - DiskState::Present { mtime } + DiskState::Present { + mtime, + size: entry.size, + } } else { DiskState::New }, @@ -3309,7 +3315,9 @@ impl File { } else if proto.is_deleted { DiskState::Deleted } else if let Some(mtime) = proto.mtime.map(&Into::into) { - DiskState::Present { mtime } + // Size is not sent over the wire. Remote buffers don't make + // local reload decisions so this is unused in practice. + DiskState::Present { mtime, size: 0 } } else { DiskState::New };