Introduce cross-platform file-watching (#6855)

Amin Yahyaabadi created

This adds cross-platform file-watching via the
[Notify](https://github.com/notify-rs/notify) crate. The previous
fs-events implementation is now only used on MacOS, and on other
platforms Notify is used. The watching function interface is the same.

Related to #5391 #5395 #5394.

Release Notes:

- N/A

Change summary

Cargo.lock                     | 72 +++++++++++++++++++++++++++++++++++
crates/fs/Cargo.toml           |  7 +++
crates/fs/src/fs.rs            | 59 ++++++++++++++++++++++++++++-
crates/project/src/worktree.rs | 15 +++----
4 files changed, 141 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2737,6 +2737,7 @@ dependencies = [
  "lazy_static",
  "libc",
  "log",
+ "notify",
  "parking_lot 0.11.2",
  "regex",
  "rope",
@@ -2756,7 +2757,7 @@ name = "fsevent"
 version = "2.0.2"
 dependencies = [
  "bitflags 1.3.2",
- "fsevent-sys",
+ "fsevent-sys 3.1.0",
  "parking_lot 0.11.2",
  "tempfile",
 ]
@@ -2770,6 +2771,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "fuchsia-zircon"
 version = "0.3.3"
@@ -3509,6 +3519,26 @@ dependencies = [
  "syn 2.0.48",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "install_cli"
 version = "0.1.0"
@@ -3726,6 +3756,26 @@ dependencies = [
  "winapi-build",
 ]
 
+[[package]]
+name = "kqueue"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
 [[package]]
 name = "kurbo"
 version = "0.8.3"
@@ -4281,6 +4331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
 dependencies = [
  "libc",
+ "log",
  "wasi 0.11.0+wasi-snapshot-preview1",
  "windows-sys 0.48.0",
 ]
@@ -4534,6 +4585,25 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "notify"
+version = "6.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
+dependencies = [
+ "bitflags 2.4.1",
+ "crossbeam-channel",
+ "filetime",
+ "fsevent-sys 4.1.0",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio 0.8.8",
+ "walkdir",
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "ntapi"
 version = "0.3.7"

crates/fs/Cargo.toml 🔗

@@ -20,7 +20,6 @@ anyhow.workspace = true
 async-trait.workspace = true
 futures.workspace = true
 tempfile = "3"
-fsevent = { path = "../fsevent" }
 lazy_static.workspace = true
 parking_lot.workspace = true
 smol.workspace = true
@@ -35,6 +34,12 @@ time.workspace = true
 
 gpui = { path = "../gpui", optional = true}
 
+[target.'cfg(target_os = "macos")'.dependencies]
+fsevent = { path = "../fsevent" }
+
+[target.'cfg(not(target_os = "macos"))'.dependencies]
+notify = "6.1.1"
+
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }
 

crates/fs/src/fs.rs 🔗

@@ -1,7 +1,16 @@
 pub mod repository;
 
 use anyhow::{anyhow, Result};
+#[cfg(target_os = "macos")]
+pub use fsevent::Event;
+#[cfg(target_os = "macos")]
 use fsevent::EventStream;
+
+#[cfg(not(target_os = "macos"))]
+pub use notify::Event;
+#[cfg(not(target_os = "macos"))]
+use notify::{Config, Watcher};
+
 use futures::{future::BoxFuture, Stream, StreamExt};
 use git2::Repository as LibGitRepository;
 use parking_lot::Mutex;
@@ -48,11 +57,13 @@ pub trait Fs: Send + Sync {
         &self,
         path: &Path,
     ) -> Result<Pin<Box<dyn Send + Stream<Item = Result<PathBuf>>>>>;
+
     async fn watch(
         &self,
         path: &Path,
         latency: Duration,
-    ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>>;
+
     fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<Mutex<dyn GitRepository>>>;
     fn is_fake(&self) -> bool;
     #[cfg(any(test, feature = "test-support"))]
@@ -251,11 +262,12 @@ impl Fs for RealFs {
         Ok(Box::pin(result))
     }
 
+    #[cfg(target_os = "macos")]
     async fn watch(
         &self,
         path: &Path,
         latency: Duration,
-    ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>> {
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
         let (tx, rx) = smol::channel::unbounded();
         let (stream, handle) = EventStream::new(&[path], latency);
         std::thread::spawn(move || {
@@ -267,6 +279,35 @@ impl Fs for RealFs {
         })))
     }
 
+    #[cfg(not(target_os = "macos"))]
+    async fn watch(
+        &self,
+        path: &Path,
+        latency: Duration,
+    ) -> Pin<Box<dyn Send + Stream<Item = Vec<Event>>>> {
+        let (tx, rx) = smol::channel::unbounded();
+
+        let mut watcher = notify::recommended_watcher(move |res| match res {
+            Ok(event) => {
+                let _ = tx.try_send(vec![event]);
+            }
+            Err(err) => {
+                eprintln!("watch error: {:?}", err);
+            }
+        })
+        .unwrap();
+
+        watcher
+            .configure(Config::default().with_poll_interval(latency))
+            .unwrap();
+
+        watcher
+            .watch(path, notify::RecursiveMode::Recursive)
+            .unwrap();
+
+        Box::pin(rx)
+    }
+
     fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<Mutex<dyn GitRepository>>> {
         LibGitRepository::open(&dotgit_path)
             .log_err()
@@ -284,6 +325,20 @@ impl Fs for RealFs {
     }
 }
 
+#[cfg(target_os = "macos")]
+pub fn fs_events_paths(events: Vec<Event>) -> Vec<PathBuf> {
+    events.into_iter().map(|event| event.path).collect()
+}
+
+#[cfg(not(target_os = "macos"))]
+pub fn fs_events_paths(events: Vec<Event>) -> Vec<PathBuf> {
+    events
+        .into_iter()
+        .map(|event| event.paths.into_iter())
+        .flatten()
+        .collect()
+}
+
 #[cfg(any(test, feature = "test-support"))]
 pub struct FakeFs {
     // Use an unfair lock to ensure tests are deterministic.

crates/project/src/worktree.rs 🔗

@@ -3221,10 +3221,7 @@ impl BackgroundScanner {
         }
     }
 
-    async fn run(
-        &mut self,
-        mut fs_events_rx: Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>,
-    ) {
+    async fn run(&mut self, mut fs_events_rx: Pin<Box<dyn Send + Stream<Item = Vec<fs::Event>>>>) {
         use futures::FutureExt as _;
 
         // Populate ignores above the root.
@@ -3271,9 +3268,10 @@ impl BackgroundScanner {
         // have the previous state loaded yet.
         self.phase = BackgroundScannerPhase::EventsReceivedDuringInitialScan;
         if let Poll::Ready(Some(events)) = futures::poll!(fs_events_rx.next()) {
-            let mut paths = events.into_iter().map(|e| e.path).collect::<Vec<_>>();
+            let mut paths = fs::fs_events_paths(events);
+
             while let Poll::Ready(Some(more_events)) = futures::poll!(fs_events_rx.next()) {
-                paths.extend(more_events.into_iter().map(|e| e.path));
+                paths.extend(fs::fs_events_paths(more_events));
             }
             self.process_events(paths).await;
         }
@@ -3312,9 +3310,10 @@ impl BackgroundScanner {
 
                 events = fs_events_rx.next().fuse() => {
                     let Some(events) = events else { break };
-                    let mut paths = events.into_iter().map(|e| e.path).collect::<Vec<_>>();
+                    let mut paths = fs::fs_events_paths(events);
+
                     while let Poll::Ready(Some(more_events)) = futures::poll!(fs_events_rx.next()) {
-                        paths.extend(more_events.into_iter().map(|e| e.path));
+                        paths.extend(fs::fs_events_paths(more_events));
                     }
                     self.process_events(paths.clone()).await;
                 }