Merge pull request #54 from zed-industries/file-changed-on-disk

Max Brunsfeld created

Handle buffers' files changing on disk from outside of Zed

Change summary

Cargo.lock                      |   7 +
gpui/src/app.rs                 |  56 ++++++++
gpui/src/lib.rs                 |   2 
gpui/src/platform/mac/window.rs |  49 +++++++
gpui/src/platform/mod.rs        |  14 ++
gpui/src/platform/test.rs       |  10 +
zed/Cargo.toml                  |   1 
zed/src/editor/buffer/mod.rs    | 226 +++++++++++++++++++++++++++++++++-
zed/src/editor/buffer_view.rs   |   5 
zed/src/workspace.rs            | 118 +++++++++++++++---
zed/src/workspace/pane.rs       |  27 ++-
zed/src/worktree.rs             | 196 +++++++++++++++++++-----------
12 files changed, 596 insertions(+), 115 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2451,6 +2451,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "similar"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec"
+
 [[package]]
 name = "simplecss"
 version = "0.2.0"
@@ -2976,6 +2982,7 @@ dependencies = [
  "seahash",
  "serde 1.0.125",
  "serde_json 1.0.64",
+ "similar",
  "simplelog",
  "smallvec",
  "smol",

gpui/src/app.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     elements::ElementBox,
     executor,
     keymap::{self, Keystroke},
-    platform::{self, WindowOptions},
+    platform::{self, PromptLevel, WindowOptions},
     presenter::Presenter,
     util::{post_inc, timeout},
     AssetCache, AssetSource, ClipboardItem, FontCache, PathPromptOptions, TextLayoutCache,
@@ -361,6 +361,23 @@ impl TestAppContext {
     pub fn did_prompt_for_new_path(&self) -> bool {
         self.1.as_ref().did_prompt_for_new_path()
     }
+
+    pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) {
+        let mut state = self.0.borrow_mut();
+        let (_, window) = state
+            .presenters_and_platform_windows
+            .get_mut(&window_id)
+            .unwrap();
+        let test_window = window
+            .as_any_mut()
+            .downcast_mut::<platform::test::Window>()
+            .unwrap();
+        let callback = test_window
+            .last_prompt
+            .take()
+            .expect("prompt was not called");
+        (callback)(answer);
+    }
 }
 
 impl AsyncAppContext {
@@ -383,6 +400,10 @@ impl AsyncAppContext {
     {
         self.update(|ctx| ctx.add_model(build_model))
     }
+
+    pub fn background_executor(&self) -> Arc<executor::Background> {
+        self.0.borrow().ctx.background.clone()
+    }
 }
 
 impl UpdateModel for AsyncAppContext {
@@ -688,6 +709,31 @@ impl MutableAppContext {
         self.platform.set_menus(menus);
     }
 
+    fn prompt<F>(
+        &self,
+        window_id: usize,
+        level: PromptLevel,
+        msg: &str,
+        answers: &[&str],
+        done_fn: F,
+    ) where
+        F: 'static + FnOnce(usize, &mut MutableAppContext),
+    {
+        let app = self.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let foreground = self.foreground.clone();
+        let (_, window) = &self.presenters_and_platform_windows[&window_id];
+        window.prompt(
+            level,
+            msg,
+            answers,
+            Box::new(move |answer| {
+                foreground
+                    .spawn(async move { (done_fn)(answer, &mut *app.borrow_mut()) })
+                    .detach();
+            }),
+        );
+    }
+
     pub fn prompt_for_paths<F>(&self, options: PathPromptOptions, done_fn: F)
     where
         F: 'static + FnOnce(Option<Vec<PathBuf>>, &mut MutableAppContext),
@@ -1731,6 +1777,14 @@ impl<'a, T: View> ViewContext<'a, T> {
         &self.app.ctx.background
     }
 
+    pub fn prompt<F>(&self, level: PromptLevel, msg: &str, answers: &[&str], done_fn: F)
+    where
+        F: 'static + FnOnce(usize, &mut MutableAppContext),
+    {
+        self.app
+            .prompt(self.window_id, level, msg, answers, done_fn)
+    }
+
     pub fn prompt_for_paths<F>(&self, options: PathPromptOptions, done_fn: F)
     where
         F: 'static + FnOnce(Option<Vec<PathBuf>>, &mut MutableAppContext),

gpui/src/lib.rs 🔗

@@ -25,7 +25,7 @@ pub mod json;
 pub mod keymap;
 mod platform;
 pub use gpui_macros::test;
-pub use platform::{Event, PathPromptOptions};
+pub use platform::{Event, PathPromptOptions, PromptLevel};
 pub use presenter::{
     AfterLayoutContext, Axis, DebugContext, EventContext, LayoutContext, PaintContext,
     SizeConstraint, Vector2FExt,

gpui/src/platform/mac/window.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
     platform::{self, Event, WindowContext},
     Scene,
 };
+use block::ConcreteBlock;
 use cocoa::{
     appkit::{
         NSApplication, NSBackingStoreBuffered, NSScreen, NSView, NSViewHeightSizable,
@@ -26,7 +27,9 @@ use objc::{
 use pathfinder_geometry::vector::vec2f;
 use smol::Timer;
 use std::{
-    cell::RefCell,
+    any::Any,
+    cell::{Cell, RefCell},
+    convert::TryInto,
     ffi::c_void,
     mem, ptr,
     rc::{Rc, Weak},
@@ -261,6 +264,10 @@ impl Drop for Window {
 }
 
 impl platform::Window for Window {
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+
     fn on_event(&mut self, callback: Box<dyn FnMut(Event)>) {
         self.0.as_ref().borrow_mut().event_callback = Some(callback);
     }
@@ -272,6 +279,42 @@ impl platform::Window for Window {
     fn on_close(&mut self, callback: Box<dyn FnOnce()>) {
         self.0.as_ref().borrow_mut().close_callback = Some(callback);
     }
+
+    fn prompt(
+        &self,
+        level: platform::PromptLevel,
+        msg: &str,
+        answers: &[&str],
+        done_fn: Box<dyn FnOnce(usize)>,
+    ) {
+        unsafe {
+            let alert: id = msg_send![class!(NSAlert), alloc];
+            let alert: id = msg_send![alert, init];
+            let alert_style = match level {
+                platform::PromptLevel::Info => 1,
+                platform::PromptLevel::Warning => 0,
+                platform::PromptLevel::Critical => 2,
+            };
+            let _: () = msg_send![alert, setAlertStyle: alert_style];
+            let _: () = msg_send![alert, setMessageText: ns_string(msg)];
+            for (ix, answer) in answers.into_iter().enumerate() {
+                let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)];
+                let _: () = msg_send![button, setTag: ix as NSInteger];
+            }
+            let done_fn = Cell::new(Some(done_fn));
+            let block = ConcreteBlock::new(move |answer: NSInteger| {
+                if let Some(done_fn) = done_fn.take() {
+                    (done_fn)(answer.try_into().unwrap());
+                }
+            });
+            let block = block.copy();
+            let _: () = msg_send![
+                alert,
+                beginSheetModalForWindow: self.0.borrow().native_window
+                completionHandler: block
+            ];
+        }
+    }
 }
 
 impl platform::WindowContext for Window {
@@ -515,3 +558,7 @@ async fn synthetic_drag(
         }
     }
 }
+
+unsafe fn ns_string(string: &str) -> id {
+    NSString::alloc(nil).init_str(string).autorelease()
+}

gpui/src/platform/mod.rs 🔗

@@ -68,9 +68,17 @@ pub trait Dispatcher: Send + Sync {
 }
 
 pub trait Window: WindowContext {
+    fn as_any_mut(&mut self) -> &mut dyn Any;
     fn on_event(&mut self, callback: Box<dyn FnMut(Event)>);
     fn on_resize(&mut self, callback: Box<dyn FnMut(&mut dyn WindowContext)>);
     fn on_close(&mut self, callback: Box<dyn FnOnce()>);
+    fn prompt(
+        &self,
+        level: PromptLevel,
+        msg: &str,
+        answers: &[&str],
+        done_fn: Box<dyn FnOnce(usize)>,
+    );
 }
 
 pub trait WindowContext {
@@ -90,6 +98,12 @@ pub struct PathPromptOptions {
     pub multiple: bool,
 }
 
+pub enum PromptLevel {
+    Info,
+    Warning,
+    Critical,
+}
+
 pub trait FontSystem: Send + Sync {
     fn load_family(&self, name: &str) -> anyhow::Result<Vec<FontId>>;
     fn select_font(

gpui/src/platform/test.rs 🔗

@@ -24,6 +24,7 @@ pub struct Window {
     event_handlers: Vec<Box<dyn FnMut(super::Event)>>,
     resize_handlers: Vec<Box<dyn FnMut(&mut dyn super::WindowContext)>>,
     close_handlers: Vec<Box<dyn FnOnce()>>,
+    pub(crate) last_prompt: RefCell<Option<Box<dyn FnOnce(usize)>>>,
 }
 
 impl Platform {
@@ -123,6 +124,7 @@ impl Window {
             close_handlers: Vec::new(),
             scale_factor: 1.0,
             current_scene: None,
+            last_prompt: RefCell::new(None),
         }
     }
 }
@@ -152,6 +154,10 @@ impl super::WindowContext for Window {
 }
 
 impl super::Window for Window {
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+
     fn on_event(&mut self, callback: Box<dyn FnMut(crate::Event)>) {
         self.event_handlers.push(callback);
     }
@@ -163,6 +169,10 @@ impl super::Window for Window {
     fn on_close(&mut self, callback: Box<dyn FnOnce()>) {
         self.close_handlers.push(callback);
     }
+
+    fn prompt(&self, _: crate::PromptLevel, _: &str, _: &[&str], f: Box<dyn FnOnce(usize)>) {
+        self.last_prompt.replace(Some(f));
+    }
 }
 
 pub(crate) fn platform() -> Platform {

zed/Cargo.toml 🔗

@@ -34,6 +34,7 @@ rand = "0.8.3"
 rust-embed = "5.9.0"
 seahash = "4.1"
 serde = {version = "1", features = ["derive"]}
+similar = "1.3"
 simplelog = "0.9"
 smallvec = "1.6.1"
 smol = "1.2.5"

zed/src/editor/buffer/mod.rs 🔗

@@ -7,6 +7,7 @@ pub use anchor::*;
 pub use point::*;
 use seahash::SeaHasher;
 pub use selection::*;
+use similar::{ChangeTag, TextDiff};
 pub use text::*;
 
 use crate::{
@@ -27,7 +28,7 @@ use std::{
     ops::{AddAssign, Range},
     str,
     sync::Arc,
-    time::{Duration, Instant},
+    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
 };
 
 const UNDO_GROUP_INTERVAL: Duration = Duration::from_millis(300);
@@ -60,6 +61,7 @@ pub struct Buffer {
     insertion_splits: HashMap<time::Local, SumTree<InsertionSplit>>,
     pub version: time::Global,
     saved_version: time::Global,
+    saved_mtime: SystemTime,
     last_edit: time::Local,
     undo_map: UndoMap,
     history: History,
@@ -374,13 +376,42 @@ impl Buffer {
         file: Option<FileHandle>,
         ctx: &mut ModelContext<Self>,
     ) -> Self {
+        let saved_mtime;
         if let Some(file) = file.as_ref() {
+            saved_mtime = file.mtime();
             file.observe_from_model(ctx, |this, file, ctx| {
-                if this.version == this.saved_version && file.is_deleted() {
-                    ctx.emit(Event::Dirtied);
+                let version = this.version.clone();
+                if this.version == this.saved_version {
+                    if file.is_deleted() {
+                        ctx.emit(Event::Dirtied);
+                    } else {
+                        ctx.spawn(|handle, mut ctx| async move {
+                            let (current_version, history) = handle.read_with(&ctx, |this, ctx| {
+                                (this.version.clone(), file.load_history(ctx.as_ref()))
+                            });
+                            if let (Ok(history), true) = (history.await, current_version == version)
+                            {
+                                let operations = handle
+                                    .update(&mut ctx, |this, ctx| {
+                                        this.set_text_via_diff(history.base_text, ctx)
+                                    })
+                                    .await;
+                                if operations.is_some() {
+                                    handle.update(&mut ctx, |this, ctx| {
+                                        this.saved_version = this.version.clone();
+                                        this.saved_mtime = file.mtime();
+                                        ctx.emit(Event::Reloaded);
+                                    });
+                                }
+                            }
+                        })
+                        .detach();
+                    }
                 }
                 ctx.emit(Event::FileHandleChanged);
             });
+        } else {
+            saved_mtime = UNIX_EPOCH;
         }
 
         let mut insertion_splits = HashMap::default();
@@ -449,6 +480,7 @@ impl Buffer {
             undo_map: Default::default(),
             history,
             file,
+            saved_mtime,
             selections: HashMap::default(),
             selections_last_update: 0,
             deferred_ops: OperationQueue::new(),
@@ -500,14 +532,75 @@ impl Buffer {
         if file.is_some() {
             self.file = file;
         }
+        if let Some(file) = &self.file {
+            self.saved_mtime = file.mtime();
+        }
         self.saved_version = version;
         ctx.emit(Event::Saved);
     }
 
+    fn set_text_via_diff(
+        &mut self,
+        new_text: Arc<str>,
+        ctx: &mut ModelContext<Self>,
+    ) -> Task<Option<Vec<Operation>>> {
+        let version = self.version.clone();
+        let old_text = self.text();
+        ctx.spawn(|handle, mut ctx| async move {
+            let diff = ctx
+                .background_executor()
+                .spawn({
+                    let new_text = new_text.clone();
+                    async move {
+                        TextDiff::from_lines(old_text.as_str(), new_text.as_ref())
+                            .iter_all_changes()
+                            .map(|c| (c.tag(), c.value().len()))
+                            .collect::<Vec<_>>()
+                    }
+                })
+                .await;
+            handle.update(&mut ctx, |this, ctx| {
+                if this.version == version {
+                    this.start_transaction(None).unwrap();
+                    let mut operations = Vec::new();
+                    let mut offset = 0;
+                    for (tag, len) in diff {
+                        let range = offset..(offset + len);
+                        match tag {
+                            ChangeTag::Equal => offset += len,
+                            ChangeTag::Delete => operations
+                                .extend_from_slice(&this.edit(Some(range), "", Some(ctx)).unwrap()),
+                            ChangeTag::Insert => {
+                                operations.extend_from_slice(
+                                    &this
+                                        .edit(Some(offset..offset), &new_text[range], Some(ctx))
+                                        .unwrap(),
+                                );
+                                offset += len;
+                            }
+                        }
+                    }
+                    this.end_transaction(None, Some(ctx)).unwrap();
+                    Some(operations)
+                } else {
+                    None
+                }
+            })
+        })
+    }
+
     pub fn is_dirty(&self) -> bool {
         self.version > self.saved_version || self.file.as_ref().map_or(false, |f| f.is_deleted())
     }
 
+    pub fn has_conflict(&self) -> bool {
+        self.version > self.saved_version
+            && self
+                .file
+                .as_ref()
+                .map_or(false, |f| f.mtime() > self.saved_mtime)
+    }
+
     pub fn version(&self) -> time::Global {
         self.version.clone()
     }
@@ -1818,6 +1911,7 @@ impl Clone for Buffer {
             insertion_splits: self.insertion_splits.clone(),
             version: self.version.clone(),
             saved_version: self.saved_version.clone(),
+            saved_mtime: self.saved_mtime,
             last_edit: self.last_edit.clone(),
             undo_map: self.undo_map.clone(),
             history: self.history.clone(),
@@ -1849,6 +1943,7 @@ pub enum Event {
     Dirtied,
     Saved,
     FileHandleChanged,
+    Reloaded,
 }
 
 impl Entity for Buffer {
@@ -2380,7 +2475,10 @@ impl ToPoint for usize {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{test::temp_tree, worktree::Worktree};
+    use crate::{
+        test::temp_tree,
+        worktree::{Worktree, WorktreeHandle},
+    };
     use cmp::Ordering;
     use gpui::App;
     use serde_json::json;
@@ -2969,8 +3067,6 @@ mod tests {
 
     #[test]
     fn test_is_dirty() {
-        use crate::worktree::WorktreeHandle;
-
         App::test_async((), |mut app| async move {
             let dir = temp_tree(json!({
                 "file1": "",
@@ -2978,9 +3074,10 @@ mod tests {
                 "file3": "",
             }));
             let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx));
+            tree.flush_fs_events(&app).await;
             app.read(|ctx| tree.read(ctx).scan_complete()).await;
 
-            let file1 = app.read(|ctx| tree.file("file1", ctx));
+            let file1 = app.update(|ctx| tree.file("file1", ctx)).await;
             let buffer1 = app.add_model(|ctx| {
                 Buffer::from_history(0, History::new("abc".into()), Some(file1), ctx)
             });
@@ -3040,7 +3137,7 @@ mod tests {
 
             // When a file is deleted, the buffer is considered dirty.
             let events = Rc::new(RefCell::new(Vec::new()));
-            let file2 = app.read(|ctx| tree.file("file2", ctx));
+            let file2 = app.update(|ctx| tree.file("file2", ctx)).await;
             let buffer2 = app.add_model(|ctx: &mut ModelContext<Buffer>| {
                 ctx.subscribe(&ctx.handle(), {
                     let events = events.clone();
@@ -3050,7 +3147,6 @@ mod tests {
                 Buffer::from_history(0, History::new("abc".into()), Some(file2), ctx)
             });
 
-            tree.flush_fs_events(&app).await;
             fs::remove_file(dir.path().join("file2")).unwrap();
             tree.update(&mut app, |tree, ctx| tree.next_scan_complete(ctx))
                 .await;
@@ -3062,7 +3158,7 @@ mod tests {
 
             // When a file is already dirty when deleted, we don't emit a Dirtied event.
             let events = Rc::new(RefCell::new(Vec::new()));
-            let file3 = app.read(|ctx| tree.file("file3", ctx));
+            let file3 = app.update(|ctx| tree.file("file3", ctx)).await;
             let buffer3 = app.add_model(|ctx: &mut ModelContext<Buffer>| {
                 ctx.subscribe(&ctx.handle(), {
                     let events = events.clone();
@@ -3085,6 +3181,116 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_file_changes_on_disk(mut app: gpui::TestAppContext) {
+        let initial_contents = "aaa\nbbbbb\nc\n";
+        let dir = temp_tree(json!({ "the-file": initial_contents }));
+        let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx));
+        app.read(|ctx| tree.read(ctx).scan_complete()).await;
+
+        let abs_path = dir.path().join("the-file");
+        let file = app.update(|ctx| tree.file("the-file", ctx)).await;
+        let buffer = app.add_model(|ctx| {
+            Buffer::from_history(0, History::new(initial_contents.into()), Some(file), ctx)
+        });
+
+        // Add a cursor at the start of each row.
+        let (selection_set_id, _) = buffer.update(&mut app, |buffer, ctx| {
+            assert!(!buffer.is_dirty());
+            buffer.add_selection_set(
+                (0..3)
+                    .map(|row| {
+                        let anchor = buffer
+                            .anchor_at(Point::new(row, 0), AnchorBias::Right)
+                            .unwrap();
+                        Selection {
+                            id: row as usize,
+                            start: anchor.clone(),
+                            end: anchor,
+                            reversed: false,
+                            goal: SelectionGoal::None,
+                        }
+                    })
+                    .collect::<Vec<_>>(),
+                Some(ctx),
+            )
+        });
+
+        // Change the file on disk, adding two new lines of text, and removing
+        // one line.
+        buffer.update(&mut app, |buffer, _| {
+            assert!(!buffer.is_dirty());
+            assert!(!buffer.has_conflict());
+        });
+        tree.flush_fs_events(&app).await;
+        let new_contents = "AAAA\naaa\nBB\nbbbbb\n";
+
+        fs::write(&abs_path, new_contents).unwrap();
+
+        // Because the buffer was not modified, it is reloaded from disk. Its
+        // contents are edited according to the diff between the old and new
+        // file contents.
+        buffer
+            .condition_with_duration(Duration::from_millis(500), &app, |buffer, _| {
+                buffer.text() != initial_contents
+            })
+            .await;
+
+        buffer.update(&mut app, |buffer, _| {
+            assert_eq!(buffer.text(), new_contents);
+            assert!(!buffer.is_dirty());
+            assert!(!buffer.has_conflict());
+
+            let selections = buffer.selections(selection_set_id).unwrap();
+            let cursor_positions = selections
+                .iter()
+                .map(|selection| {
+                    assert_eq!(selection.start, selection.end);
+                    selection.start.to_point(&buffer).unwrap()
+                })
+                .collect::<Vec<_>>();
+            assert_eq!(
+                cursor_positions,
+                &[Point::new(1, 0), Point::new(3, 0), Point::new(4, 0),]
+            );
+        });
+
+        // Modify the buffer
+        buffer.update(&mut app, |buffer, ctx| {
+            buffer.edit(vec![0..0], " ", Some(ctx)).unwrap();
+            assert!(buffer.is_dirty());
+        });
+
+        // Change the file on disk again, adding blank lines to the beginning.
+        fs::write(&abs_path, "\n\n\nAAAA\naaa\nBB\nbbbbb\n").unwrap();
+
+        // Becaues the buffer is modified, it doesn't reload from disk, but is
+        // marked as having a conflict.
+        buffer
+            .condition_with_duration(Duration::from_millis(500), &app, |buffer, _| {
+                buffer.has_conflict()
+            })
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_set_text_via_diff(mut app: gpui::TestAppContext) {
+        let text = "a\nbb\nccc\ndddd\neeeee\nffffff\n";
+        let buffer = app.add_model(|ctx| Buffer::new(0, text, ctx));
+
+        let text = "a\nccc\ndddd\nffffff\n";
+        buffer
+            .update(&mut app, |b, ctx| b.set_text_via_diff(text.into(), ctx))
+            .await;
+        app.read(|ctx| assert_eq!(buffer.read(ctx).text(), text));
+
+        let text = "a\n1\n\nccc\ndd2dd\nffffff\n";
+        buffer
+            .update(&mut app, |b, ctx| b.set_text_via_diff(text.into(), ctx))
+            .await;
+        app.read(|ctx| assert_eq!(buffer.read(ctx).text(), text));
+    }
+
     #[gpui::test]
     fn test_undo_redo(app: &mut gpui::MutableAppContext) {
         app.add_model(|ctx| {

zed/src/editor/buffer_view.rs 🔗

@@ -2396,6 +2396,7 @@ impl BufferView {
             buffer::Event::Dirtied => ctx.emit(Event::Dirtied),
             buffer::Event::Saved => ctx.emit(Event::Saved),
             buffer::Event::FileHandleChanged => ctx.emit(Event::FileHandleChanged),
+            buffer::Event::Reloaded => ctx.emit(Event::FileHandleChanged),
         }
     }
 }
@@ -2500,6 +2501,10 @@ impl workspace::ItemView for BufferView {
     fn is_dirty(&self, ctx: &AppContext) -> bool {
         self.buffer.read(ctx).is_dirty()
     }
+
+    fn has_conflict(&self, ctx: &AppContext) -> bool {
+        self.buffer.read(ctx).has_conflict()
+    }
 }
 
 #[cfg(test)]

zed/src/workspace.rs 🔗

@@ -9,8 +9,8 @@ use crate::{
 use futures_core::Future;
 use gpui::{
     color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext,
-    ClipboardItem, Entity, ModelHandle, MutableAppContext, PathPromptOptions, Task, View,
-    ViewContext, ViewHandle, WeakModelHandle,
+    ClipboardItem, Entity, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, Task,
+    View, ViewContext, ViewHandle, WeakModelHandle,
 };
 use log::error;
 pub use pane::*;
@@ -119,6 +119,9 @@ pub trait ItemView: View {
     fn is_dirty(&self, _: &AppContext) -> bool {
         false
     }
+    fn has_conflict(&self, _: &AppContext) -> bool {
+        false
+    }
     fn save(
         &mut self,
         _: Option<FileHandle>,
@@ -157,6 +160,7 @@ pub trait ItemViewHandle: Send + Sync {
     fn id(&self) -> usize;
     fn to_any(&self) -> AnyViewHandle;
     fn is_dirty(&self, ctx: &AppContext) -> bool;
+    fn has_conflict(&self, ctx: &AppContext) -> bool;
     fn save(
         &self,
         file: Option<FileHandle>,
@@ -247,6 +251,10 @@ impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
         self.read(ctx).is_dirty(ctx)
     }
 
+    fn has_conflict(&self, ctx: &AppContext) -> bool {
+        self.read(ctx).has_conflict(ctx)
+    }
+
     fn id(&self) -> usize {
         self.id()
     }
@@ -361,6 +369,7 @@ impl Workspace {
             .map(|(abs_path, file)| {
                 let is_file = bg.spawn(async move { abs_path.is_file() });
                 ctx.spawn(|this, mut ctx| async move {
+                    let file = file.await;
                     let is_file = is_file.await;
                     this.update(&mut ctx, |this, ctx| {
                         if is_file {
@@ -381,14 +390,14 @@ impl Workspace {
         }
     }
 
-    fn file_for_path(&mut self, abs_path: &Path, ctx: &mut ViewContext<Self>) -> FileHandle {
+    fn file_for_path(&mut self, abs_path: &Path, ctx: &mut ViewContext<Self>) -> Task<FileHandle> {
         for tree in self.worktrees.iter() {
             if let Ok(relative_path) = abs_path.strip_prefix(tree.read(ctx).abs_path()) {
-                return tree.file(relative_path, ctx.as_ref());
+                return tree.file(relative_path, ctx.as_mut());
             }
         }
         let worktree = self.add_worktree(&abs_path, ctx);
-        worktree.file(Path::new(""), ctx.as_ref())
+        worktree.file(Path::new(""), ctx.as_mut())
     }
 
     pub fn add_worktree(
@@ -489,18 +498,19 @@ impl Workspace {
             }
         };
 
-        let file = worktree.file(path.clone(), ctx.as_ref());
+        let file = worktree.file(path.clone(), ctx.as_mut());
         if let Entry::Vacant(entry) = self.loading_items.entry(entry.clone()) {
             let (mut tx, rx) = postage::watch::channel();
             entry.insert(rx);
             let replica_id = self.replica_id;
-            let history = ctx
-                .background_executor()
-                .spawn(file.load_history(ctx.as_ref()));
 
             ctx.as_mut()
                 .spawn(|mut ctx| async move {
-                    *tx.borrow_mut() = Some(match history.await {
+                    let file = file.await;
+                    let history = ctx.read(|ctx| file.load_history(ctx));
+                    let history = ctx.background_executor().spawn(history).await;
+
+                    *tx.borrow_mut() = Some(match history {
                         Ok(history) => Ok(Box::new(ctx.add_model(|ctx| {
                             Buffer::from_history(replica_id, history, Some(file), ctx)
                         }))),
@@ -545,8 +555,8 @@ impl Workspace {
 
     pub fn save_active_item(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
         if let Some(item) = self.active_item(ctx) {
+            let handle = ctx.handle();
             if item.entry_id(ctx.as_ref()).is_none() {
-                let handle = ctx.handle();
                 let start_path = self
                     .worktrees
                     .iter()
@@ -556,8 +566,9 @@ impl Workspace {
                 ctx.prompt_for_new_path(&start_path, move |path, ctx| {
                     if let Some(path) = path {
                         ctx.spawn(|mut ctx| async move {
-                            let file =
-                                handle.update(&mut ctx, |me, ctx| me.file_for_path(&path, ctx));
+                            let file = handle
+                                .update(&mut ctx, |me, ctx| me.file_for_path(&path, ctx))
+                                .await;
                             if let Err(error) = ctx.update(|ctx| item.save(Some(file), ctx)).await {
                                 error!("failed to save item: {:?}, ", error);
                             }
@@ -566,16 +577,32 @@ impl Workspace {
                     }
                 });
                 return;
-            }
-
-            let save = item.save(None, ctx.as_mut());
-            ctx.foreground()
-                .spawn(async move {
-                    if let Err(e) = save.await {
-                        error!("failed to save item: {:?}, ", e);
+            } else if item.has_conflict(ctx.as_ref()) {
+                const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
+
+                ctx.prompt(
+                    PromptLevel::Warning,
+                    CONFLICT_MESSAGE,
+                    &["Overwrite", "Cancel"],
+                    move |answer, ctx| {
+                        if answer == 0 {
+                            ctx.spawn(|mut ctx| async move {
+                                if let Err(error) = ctx.update(|ctx| item.save(None, ctx)).await {
+                                    error!("failed to save item: {:?}, ", error);
+                                }
+                            })
+                            .detach();
+                        }
+                    },
+                );
+            } else {
+                ctx.spawn(|_, mut ctx| async move {
+                    if let Err(error) = ctx.update(|ctx| item.save(None, ctx)).await {
+                        error!("failed to save item: {:?}, ", error);
                     }
                 })
                 .detach();
+            }
         }
     }
 
@@ -732,7 +759,7 @@ mod tests {
     use super::*;
     use crate::{editor::BufferView, settings, test::temp_tree};
     use serde_json::json;
-    use std::collections::HashSet;
+    use std::{collections::HashSet, fs};
     use tempdir::TempDir;
 
     #[gpui::test]
@@ -970,6 +997,55 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_save_conflicting_item(mut app: gpui::TestAppContext) {
+        let dir = temp_tree(json!({
+            "a.txt": "",
+        }));
+
+        let settings = settings::channel(&app.font_cache()).unwrap().1;
+        let (window_id, workspace) = app.add_window(|ctx| {
+            let mut workspace = Workspace::new(0, settings, ctx);
+            workspace.add_worktree(dir.path(), ctx);
+            workspace
+        });
+        let tree = app.read(|ctx| {
+            let mut trees = workspace.read(ctx).worktrees().iter();
+            trees.next().unwrap().clone()
+        });
+        tree.flush_fs_events(&app).await;
+
+        // Open a file within an existing worktree.
+        app.update(|ctx| {
+            workspace.update(ctx, |view, ctx| {
+                view.open_paths(&[dir.path().join("a.txt")], ctx)
+            })
+        })
+        .await;
+        let editor = app.read(|ctx| {
+            let pane = workspace.read(ctx).active_pane().read(ctx);
+            let item = pane.active_item().unwrap();
+            item.to_any().downcast::<BufferView>().unwrap()
+        });
+
+        app.update(|ctx| editor.update(ctx, |editor, ctx| editor.insert(&"x".to_string(), ctx)));
+        fs::write(dir.path().join("a.txt"), "changed").unwrap();
+        tree.flush_fs_events(&app).await;
+        app.read(|ctx| {
+            assert!(editor.is_dirty(ctx));
+            assert!(editor.has_conflict(ctx));
+        });
+
+        app.update(|ctx| workspace.update(ctx, |w, ctx| w.save_active_item(&(), ctx)));
+        app.simulate_prompt_answer(window_id, 0);
+        tree.update(&mut app, |tree, ctx| tree.next_scan_complete(ctx))
+            .await;
+        app.read(|ctx| {
+            assert!(!editor.is_dirty(ctx));
+            assert!(!editor.has_conflict(ctx));
+        });
+    }
+
     #[gpui::test]
     async fn test_open_and_save_new_file(mut app: gpui::TestAppContext) {
         let dir = TempDir::new("test-new-file").unwrap();

zed/src/workspace/pane.rs 🔗

@@ -228,6 +228,7 @@ impl Pane {
                                         line_height - 2.,
                                         mouse_state.hovered,
                                         item.is_dirty(ctx),
+                                        item.has_conflict(ctx),
                                         ctx,
                                     ))
                                     .right()
@@ -296,15 +297,25 @@ impl Pane {
         item_id: usize,
         close_icon_size: f32,
         tab_hovered: bool,
-        is_modified: bool,
+        is_dirty: bool,
+        has_conflict: bool,
         ctx: &AppContext,
     ) -> ElementBox {
         enum TabCloseButton {}
 
-        let modified_color = ColorU::from_u32(0x556de8ff);
-        let mut clicked_color = modified_color;
+        let dirty_color = ColorU::from_u32(0x556de8ff);
+        let conflict_color = ColorU::from_u32(0xe45349ff);
+        let mut clicked_color = dirty_color;
         clicked_color.a = 180;
 
+        let current_color = if has_conflict {
+            Some(conflict_color)
+        } else if is_dirty {
+            Some(dirty_color)
+        } else {
+            None
+        };
+
         let icon = if tab_hovered {
             let mut icon = Svg::new("icons/x.svg");
 
@@ -314,13 +325,13 @@ impl Pane {
                         .with_background_color(if mouse_state.clicked {
                             clicked_color
                         } else {
-                            modified_color
+                            dirty_color
                         })
                         .with_corner_radius(close_icon_size / 2.)
                         .boxed()
                 } else {
-                    if is_modified {
-                        icon = icon.with_color(modified_color);
+                    if let Some(current_color) = current_color {
+                        icon = icon.with_color(current_color);
                     }
                     icon.boxed()
                 }
@@ -331,11 +342,11 @@ impl Pane {
             let diameter = 8.;
             ConstrainedBox::new(
                 Canvas::new(move |bounds, ctx| {
-                    if is_modified {
+                    if let Some(current_color) = current_color {
                         let square = RectF::new(bounds.origin(), vec2f(diameter, diameter));
                         ctx.scene.push_quad(Quad {
                             bounds: square,
-                            background: Some(modified_color),
+                            background: Some(current_color),
                             border: Default::default(),
                             corner_radius: diameter / 2.,
                         });

zed/src/worktree.rs 🔗

@@ -9,7 +9,7 @@ use crate::{
 use ::ignore::gitignore::Gitignore;
 use anyhow::{Context, Result};
 pub use fuzzy::{match_paths, PathMatch};
-use gpui::{scoped_pool, AppContext, Entity, ModelContext, ModelHandle, Task};
+use gpui::{scoped_pool, AppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
 use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use postage::{
@@ -28,7 +28,7 @@ use std::{
     os::unix::{ffi::OsStrExt, fs::MetadataExt},
     path::{Path, PathBuf},
     sync::{Arc, Weak},
-    time::Duration,
+    time::{Duration, SystemTime, UNIX_EPOCH},
 };
 
 use self::{char_bag::CharBag, ignore::IgnoreStack};
@@ -63,6 +63,7 @@ pub struct FileHandle {
 struct FileHandleState {
     path: Arc<Path>,
     is_deleted: bool,
+    mtime: SystemTime,
 }
 
 impl Worktree {
@@ -201,9 +202,10 @@ impl Worktree {
         path: &Path,
         ctx: &AppContext,
     ) -> impl Future<Output = Result<History>> {
-        let abs_path = self.absolutize(path);
+        let path = path.to_path_buf();
+        let abs_path = self.absolutize(&path);
         ctx.background_executor().spawn(async move {
-            let mut file = std::fs::File::open(&abs_path)?;
+            let mut file = fs::File::open(&abs_path)?;
             let mut base_text = String::new();
             file.read_to_string(&mut base_text)?;
             Ok(History::new(Arc::from(base_text)))
@@ -221,20 +223,29 @@ impl Worktree {
         let abs_path = self.absolutize(&path);
         ctx.background_executor().spawn(async move {
             let buffer_size = content.text_summary().bytes.min(10 * 1024);
-            let file = std::fs::File::create(&abs_path)?;
-            let mut writer = std::io::BufWriter::with_capacity(buffer_size, file);
+            let file = fs::File::create(&abs_path)?;
+            let mut writer = io::BufWriter::with_capacity(buffer_size, &file);
             for chunk in content.fragments() {
                 writer.write(chunk.as_bytes())?;
             }
             writer.flush()?;
-
-            if let Some(handle) = handles.lock().get(path.as_path()).and_then(Weak::upgrade) {
-                handle.lock().is_deleted = false;
-            }
-
+            Self::update_file_handle(&file, &path, &handles)?;
             Ok(())
         })
     }
+
+    fn update_file_handle(
+        file: &fs::File,
+        path: &Path,
+        handles: &Mutex<HashMap<Arc<Path>, Weak<Mutex<FileHandleState>>>>,
+    ) -> Result<()> {
+        if let Some(handle) = handles.lock().get(path).and_then(Weak::upgrade) {
+            let mut handle = handle.lock();
+            handle.mtime = file.metadata()?.modified()?;
+            handle.is_deleted = false;
+        }
+        Ok(())
+    }
 }
 
 impl Entity for Worktree {
@@ -457,6 +468,10 @@ impl FileHandle {
         self.state.lock().is_deleted
     }
 
+    pub fn mtime(&self) -> SystemTime {
+        self.state.lock().mtime
+    }
+
     pub fn exists(&self) -> bool {
         !self.is_deleted()
     }
@@ -927,41 +942,63 @@ impl BackgroundScanner {
         };
 
         let mut renamed_paths: HashMap<u64, PathBuf> = HashMap::new();
+        let mut handles = self.handles.lock();
         let mut updated_handles = HashMap::new();
         for event in &events {
+            let path = if let Ok(path) = event.path.strip_prefix(&root_abs_path) {
+                path
+            } else {
+                continue;
+            };
+
+            let metadata = fs::metadata(&event.path);
             if event.flags.contains(fsevent::StreamFlags::ITEM_RENAMED) {
-                if let Ok(path) = event.path.strip_prefix(&root_abs_path) {
-                    if let Some(inode) = snapshot.inode_for_path(path) {
-                        renamed_paths.insert(inode, path.to_path_buf());
-                    } else if let Ok(metadata) = fs::metadata(&event.path) {
-                        let new_path = path;
-                        let mut handles = self.handles.lock();
-                        if let Some(old_path) = renamed_paths.get(&metadata.ino()) {
-                            handles.retain(|handle_path, handle_state| {
-                                if let Ok(path_suffix) = handle_path.strip_prefix(&old_path) {
-                                    let new_handle_path: Arc<Path> =
-                                        if path_suffix.file_name().is_some() {
-                                            new_path.join(path_suffix)
-                                        } else {
-                                            new_path.to_path_buf()
-                                        }
-                                        .into();
-                                    if let Some(handle_state) = Weak::upgrade(&handle_state) {
-                                        handle_state.lock().path = new_handle_path.clone();
-                                        updated_handles
-                                            .insert(new_handle_path, Arc::downgrade(&handle_state));
+                if let Some(inode) = snapshot.inode_for_path(path) {
+                    renamed_paths.insert(inode, path.to_path_buf());
+                } else if let Ok(metadata) = &metadata {
+                    let new_path = path;
+                    if let Some(old_path) = renamed_paths.get(&metadata.ino()) {
+                        handles.retain(|handle_path, handle_state| {
+                            if let Ok(path_suffix) = handle_path.strip_prefix(&old_path) {
+                                let new_handle_path: Arc<Path> =
+                                    if path_suffix.file_name().is_some() {
+                                        new_path.join(path_suffix)
+                                    } else {
+                                        new_path.to_path_buf()
                                     }
-                                    false
-                                } else {
-                                    true
+                                    .into();
+                                if let Some(handle_state) = Weak::upgrade(&handle_state) {
+                                    let mut state = handle_state.lock();
+                                    state.path = new_handle_path.clone();
+                                    updated_handles
+                                        .insert(new_handle_path, Arc::downgrade(&handle_state));
                                 }
-                            });
-                            handles.extend(updated_handles.drain());
+                                false
+                            } else {
+                                true
+                            }
+                        });
+                        handles.extend(updated_handles.drain());
+                    }
+                }
+            }
+
+            for state in handles.values_mut() {
+                if let Some(state) = Weak::upgrade(&state) {
+                    let mut state = state.lock();
+                    if state.path.as_ref() == path {
+                        if let Ok(metadata) = &metadata {
+                            state.mtime = metadata.modified().unwrap();
+                        }
+                    } else if state.path.starts_with(path) {
+                        if let Ok(metadata) = fs::metadata(state.path.as_ref()) {
+                            state.mtime = metadata.modified().unwrap();
                         }
                     }
                 }
             }
         }
+        drop(handles);
 
         events.sort_unstable_by(|a, b| a.path.cmp(&b.path));
         let mut abs_paths = events.into_iter().map(|e| e.path).peekable();
@@ -1189,7 +1226,7 @@ struct UpdateIgnoreStatusJob {
 }
 
 pub trait WorktreeHandle {
-    fn file(&self, path: impl AsRef<Path>, app: &AppContext) -> FileHandle;
+    fn file(&self, path: impl AsRef<Path>, app: &mut MutableAppContext) -> Task<FileHandle>;
 
     #[cfg(test)]
     fn flush_fs_events<'a>(
@@ -1199,34 +1236,51 @@ pub trait WorktreeHandle {
 }
 
 impl WorktreeHandle for ModelHandle<Worktree> {
-    fn file(&self, path: impl AsRef<Path>, app: &AppContext) -> FileHandle {
-        let path = path.as_ref();
+    fn file(&self, path: impl AsRef<Path>, app: &mut MutableAppContext) -> Task<FileHandle> {
+        let path = Arc::from(path.as_ref());
+        let handle = self.clone();
         let tree = self.read(app);
-        let mut handles = tree.handles.lock();
-        let state = if let Some(state) = handles.get(path).and_then(Weak::upgrade) {
-            state
-        } else {
-            let handle_state = if let Some(entry) = tree.entry_for_path(path) {
-                FileHandleState {
-                    path: entry.path().clone(),
-                    is_deleted: false,
-                }
-            } else {
-                FileHandleState {
-                    path: path.into(),
-                    is_deleted: !tree.path_is_pending(path),
-                }
-            };
-
-            let state = Arc::new(Mutex::new(handle_state.clone()));
-            handles.insert(handle_state.path, Arc::downgrade(&state));
-            state
-        };
+        let abs_path = tree.absolutize(&path);
+        app.spawn(|ctx| async move {
+            let mtime = ctx
+                .background_executor()
+                .spawn(async move {
+                    if let Ok(metadata) = fs::metadata(&abs_path) {
+                        metadata.modified().unwrap()
+                    } else {
+                        UNIX_EPOCH
+                    }
+                })
+                .await;
+            let state = handle.read_with(&ctx, |tree, _| {
+                let mut handles = tree.handles.lock();
+                if let Some(state) = handles.get(&path).and_then(Weak::upgrade) {
+                    state
+                } else {
+                    let handle_state = if let Some(entry) = tree.entry_for_path(&path) {
+                        FileHandleState {
+                            path: entry.path().clone(),
+                            is_deleted: false,
+                            mtime,
+                        }
+                    } else {
+                        FileHandleState {
+                            path: path.clone(),
+                            is_deleted: !tree.path_is_pending(path),
+                            mtime,
+                        }
+                    };
 
-        FileHandle {
-            worktree: self.clone(),
-            state,
-        }
+                    let state = Arc::new(Mutex::new(handle_state.clone()));
+                    handles.insert(handle_state.path, Arc::downgrade(&state));
+                    state
+                }
+            });
+            FileHandle {
+                worktree: handle.clone(),
+                state,
+            }
+        })
     }
 
     // When the worktree's FS event stream sometimes delivers "redundant" events for FS changes that
@@ -1484,7 +1538,7 @@ mod tests {
         let buffer =
             app.add_model(|ctx| Buffer::new(1, "a line of text.\n".repeat(10 * 1024), ctx));
 
-        let file = app.read(|ctx| tree.file("", ctx));
+        let file = app.update(|ctx| tree.file("", ctx)).await;
         app.update(|ctx| {
             assert_eq!(file.path().file_name(), None);
             smol::block_on(file.save(buffer.read(ctx).snapshot(), ctx.as_ref())).unwrap();
@@ -1511,15 +1565,11 @@ mod tests {
         }));
 
         let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx));
-        let (file2, file3, file4, file5, non_existent_file) = app.read(|ctx| {
-            (
-                tree.file("a/file2", ctx),
-                tree.file("a/file3", ctx),
-                tree.file("b/c/file4", ctx),
-                tree.file("b/c/file5", ctx),
-                tree.file("a/filex", ctx),
-            )
-        });
+        let file2 = app.update(|ctx| tree.file("a/file2", ctx)).await;
+        let file3 = app.update(|ctx| tree.file("a/file3", ctx)).await;
+        let file4 = app.update(|ctx| tree.file("b/c/file4", ctx)).await;
+        let file5 = app.update(|ctx| tree.file("b/c/file5", ctx)).await;
+        let non_existent_file = app.update(|ctx| tree.file("a/file_x", ctx)).await;
 
         // The worktree hasn't scanned the directories containing these paths,
         // so it can't determine that the paths are deleted.