Add size to DiskState to detect file changes (#49436)

Imamuzzaki Abu Salam , Claude Sonnet 4.5 , Ben Kunkle , and Jakub Konka created

## Summary

This fix addresses the cross-platform root cause identified in issue
#38109 where open buffers go stale or empty when external tools write
files.

## The Problem

The buffer's `file_updated()` method was only comparing `mtime` to
determine if a buffer needed to be reloaded. This caused a race
condition when external tools write files using `std::fs::write()`,
which uses `O_TRUNC` and creates a brief window where the file is 0
bytes:

1. Scanner re-stats → sees 0 bytes, mtime T
2. `file_updated()` sees mtime changed → emits `ReloadNeeded`
3. Buffer reloads to empty, stamps `saved_mtime = T`
4. Tool finishes writing → file has content, but mtime is still T (or
same-second granularity)
5. Scanner re-stats → mtime T matches `saved_mtime` → **no reload
triggered**
6. Buffer permanently stuck empty

## The Fix

Release Notes:

- Add the file `size` to `DiskState::Present`, so that even when mtime
stays the same, size changes (0 → N bytes) will trigger a reload. This
is the same fix that was identified in the issue by @lex00.

## Changes

- `crates/language/src/buffer.rs`: Add `size: u64` to
`DiskState::Present`, add `size()` method
- `crates/worktree/src/worktree.rs`: Pass size when constructing File
and DiskState::Present
- `crates/project/src/buffer_store.rs`: Pass size when constructing File
- `crates/project/src/image_store.rs`: Pass size when constructing File
- `crates/copilot/src/copilot.rs`: Update test mock

## Test plan

- [ ] Open a file in Zed
- [ ] Write to that file from an external tool (e.g., `echo "content" >
file`)
- [ ] Verify the buffer updates correctly without needing to reload

Fixes #38109

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>

Change summary

crates/copilot/src/copilot.rs      |  1 +
crates/language/src/buffer.rs      | 16 +++++++++++++---
crates/project/src/buffer_store.rs |  5 ++++-
crates/project/src/image_store.rs  |  5 ++++-
crates/worktree/src/worktree.rs    | 10 ++++++++--
5 files changed, 30 insertions(+), 7 deletions(-)

Detailed changes

crates/copilot/src/copilot.rs 🔗

@@ -1779,6 +1779,7 @@ mod tests {
         fn disk_state(&self) -> language::DiskState {
             language::DiskState::Present {
                 mtime: ::fs::MTime::from_seconds_and_nanos(100, 42),
+                size: 0,
             }
         }
 

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,17 @@ impl DiskState {
     pub fn mtime(self) -> Option<MTime> {
         match self {
             DiskState::New => None,
-            DiskState::Present { mtime } => Some(mtime),
+            DiskState::Present { mtime, .. } => Some(mtime),
+            DiskState::Deleted => None,
+            DiskState::Historic { .. } => None,
+        }
+    }
+
+    /// Returns the file's size on disk in bytes.
+    pub fn size(self) -> Option<u64> {
+        match self {
+            DiskState::New => None,
+            DiskState::Present { size, .. } => Some(size),
             DiskState::Deleted => None,
             DiskState::Historic { .. } => None,
         }
@@ -2377,7 +2387,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()
                 }

crates/project/src/buffer_store.rs 🔗

@@ -527,7 +527,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,

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,

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,
@@ -3289,7 +3292,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
             },
@@ -3318,7 +3324,7 @@ impl File {
         } else if proto.is_deleted {
             DiskState::Deleted
         } else if let Some(mtime) = proto.mtime.map(&Into::into) {
-            DiskState::Present { mtime }
+            DiskState::Present { mtime, size: 0 }
         } else {
             DiskState::New
         };