windows: Fix fs watch when file doesn't exist or is a symlink (#22660)

tims created

Closes #22659

More context can be found in attached issue.

This is specific to Windows:

1. Add parent directory watching for fs watch when the file doesn't
exist. For example, when Zed is first launched and `settings.json` isn't
there.
2. Add proper symlink handling for fs watch. For example, when
`settings.json` is a symlink.

This is exactly same as how we handle it on Linux.

Release Notes:

- Fixed an issue where items on the Welcome page could not be toggled on
Windows, either on first launch or when `settings.json` is a symlink.

Change summary

crates/fs/src/fs.rs                   | 61 ++--------------------------
crates/fs/src/fs_watcher.rs           | 25 ++++++-----
crates/worktree/src/worktree_tests.rs |  4 
crates/zed/src/zed.rs                 | 38 ++++++++++++++++-
4 files changed, 56 insertions(+), 72 deletions(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -1,8 +1,8 @@
 #[cfg(target_os = "macos")]
 mod mac_watcher;
 
-#[cfg(any(target_os = "linux", target_os = "freebsd"))]
-pub mod linux_watcher;
+#[cfg(not(target_os = "macos"))]
+pub mod fs_watcher;
 
 use anyhow::{anyhow, Result};
 use git::GitHostingProviderRegistry;
@@ -700,7 +700,7 @@ impl Fs for RealFs {
         )
     }
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    #[cfg(not(target_os = "macos"))]
     async fn watch(
         &self,
         path: &Path,
@@ -710,10 +710,11 @@ impl Fs for RealFs {
         Arc<dyn Watcher>,
     ) {
         use parking_lot::Mutex;
+        use util::paths::SanitizedPath;
 
         let (tx, rx) = smol::channel::unbounded();
         let pending_paths: Arc<Mutex<Vec<PathEvent>>> = Default::default();
-        let watcher = Arc::new(linux_watcher::LinuxWatcher::new(tx, pending_paths.clone()));
+        let watcher = Arc::new(fs_watcher::FsWatcher::new(tx, pending_paths.clone()));
 
         if watcher.add(path).is_err() {
             // If the path doesn't exist yet (e.g. settings.json), watch the parent dir to learn when it's created.
@@ -731,7 +732,7 @@ impl Fs for RealFs {
                 if let Some(parent) = path.parent() {
                     target = parent.join(target);
                     if let Ok(canonical) = self.canonicalize(&target).await {
-                        target = canonical;
+                        target = SanitizedPath::from(canonical).as_path().to_path_buf();
                     }
                 }
             }
@@ -758,56 +759,6 @@ impl Fs for RealFs {
         )
     }
 
-    #[cfg(target_os = "windows")]
-    async fn watch(
-        &self,
-        path: &Path,
-        _latency: Duration,
-    ) -> (
-        Pin<Box<dyn Send + Stream<Item = Vec<PathEvent>>>>,
-        Arc<dyn Watcher>,
-    ) {
-        use notify::{EventKind, Watcher};
-
-        let (tx, rx) = smol::channel::unbounded();
-
-        let mut file_watcher = notify::recommended_watcher({
-            let tx = tx.clone();
-            move |event: Result<notify::Event, _>| {
-                if let Some(event) = event.log_err() {
-                    let kind = match event.kind {
-                        EventKind::Create(_) => Some(PathEventKind::Created),
-                        EventKind::Modify(_) => Some(PathEventKind::Changed),
-                        EventKind::Remove(_) => Some(PathEventKind::Removed),
-                        _ => None,
-                    };
-
-                    tx.try_send(
-                        event
-                            .paths
-                            .into_iter()
-                            .map(|path| PathEvent { path, kind })
-                            .collect::<Vec<_>>(),
-                    )
-                    .ok();
-                }
-            }
-        })
-        .expect("Could not start file watcher");
-
-        file_watcher
-            .watch(path, notify::RecursiveMode::Recursive)
-            .log_err();
-
-        (
-            Box::pin(rx.chain(futures::stream::once(async move {
-                drop(file_watcher);
-                vec![]
-            }))),
-            Arc::new(RealWatcher {}),
-        )
-    }
-
     fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<dyn GitRepository>> {
         // with libgit2, we can open git repo from an existing work dir
         // https://libgit2.org/docs/reference/main/repository/git_repository_open.html

crates/fs/src/linux_watcher.rs → crates/fs/src/fs_watcher.rs 🔗

@@ -5,12 +5,12 @@ use util::ResultExt;
 
 use crate::{PathEvent, PathEventKind, Watcher};
 
-pub struct LinuxWatcher {
+pub struct FsWatcher {
     tx: smol::channel::Sender<()>,
     pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
 }
 
-impl LinuxWatcher {
+impl FsWatcher {
     pub fn new(
         tx: smol::channel::Sender<()>,
         pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
@@ -22,7 +22,7 @@ impl LinuxWatcher {
     }
 }
 
-impl Watcher for LinuxWatcher {
+impl Watcher for FsWatcher {
     fn add(&self, path: &std::path::Path) -> gpui::Result<()> {
         let root_path = path.to_path_buf();
 
@@ -69,7 +69,7 @@ impl Watcher for LinuxWatcher {
         })?;
 
         global(|g| {
-            g.inotify
+            g.watcher
                 .lock()
                 .watch(path, notify::RecursiveMode::NonRecursive)
         })??;
@@ -79,16 +79,18 @@ impl Watcher for LinuxWatcher {
 
     fn remove(&self, path: &std::path::Path) -> gpui::Result<()> {
         use notify::Watcher;
-        Ok(global(|w| w.inotify.lock().unwatch(path))??)
+        Ok(global(|w| w.watcher.lock().unwatch(path))??)
     }
 }
 
 pub struct GlobalWatcher {
-    // two mutexes because calling inotify.add triggers an inotify.event, which needs watchers.
+    // two mutexes because calling watcher.add triggers an watcher.event, which needs watchers.
     #[cfg(target_os = "linux")]
-    pub(super) inotify: Mutex<notify::INotifyWatcher>,
+    pub(super) watcher: Mutex<notify::INotifyWatcher>,
     #[cfg(target_os = "freebsd")]
-    pub(super) inotify: Mutex<notify::KqueueWatcher>,
+    pub(super) watcher: Mutex<notify::KqueueWatcher>,
+    #[cfg(target_os = "windows")]
+    pub(super) watcher: Mutex<notify::ReadDirectoryChangesWatcher>,
     pub(super) watchers: Mutex<Vec<Box<dyn Fn(&notify::Event) + Send + Sync>>>,
 }
 
@@ -98,7 +100,8 @@ impl GlobalWatcher {
     }
 }
 
-static INOTIFY_INSTANCE: OnceLock<anyhow::Result<GlobalWatcher, notify::Error>> = OnceLock::new();
+static FS_WATCHER_INSTANCE: OnceLock<anyhow::Result<GlobalWatcher, notify::Error>> =
+    OnceLock::new();
 
 fn handle_event(event: Result<notify::Event, notify::Error>) {
     let Some(event) = event.log_err() else { return };
@@ -111,9 +114,9 @@ fn handle_event(event: Result<notify::Event, notify::Error>) {
 }
 
 pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
-    let result = INOTIFY_INSTANCE.get_or_init(|| {
+    let result = FS_WATCHER_INSTANCE.get_or_init(|| {
         notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
-            inotify: Mutex::new(file_watcher),
+            watcher: Mutex::new(file_watcher),
             watchers: Default::default(),
         })
     });

crates/worktree/src/worktree_tests.rs 🔗

@@ -854,8 +854,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
     .await
     .unwrap();
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    fs::linux_watcher::global(|_| {}).unwrap();
+    #[cfg(not(target_os = "macos"))]
+    fs::fs_watcher::global(|_| {}).unwrap();
 
     cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
         .await;

crates/zed/src/zed.rs 🔗

@@ -153,8 +153,8 @@ pub fn initialize_workspace(
         })
         .detach();
 
-        #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-        initialize_linux_file_watcher(cx);
+        #[cfg(not(target_os = "macos"))]
+        initialize_file_watcher(cx);
 
         if let Some(specs) = cx.gpu_specs() {
             log::info!("Using GPU: {:?}", specs);
@@ -235,8 +235,8 @@ fn feature_gate_zed_pro_actions(cx: &mut AppContext) {
 }
 
 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-fn initialize_linux_file_watcher(cx: &mut ViewContext<Workspace>) {
-    if let Err(e) = fs::linux_watcher::global(|_| {}) {
+fn initialize_file_watcher(cx: &mut ViewContext<Workspace>) {
+    if let Err(e) = fs::fs_watcher::global(|_| {}) {
         let message = format!(
             db::indoc! {r#"
             inotify_init returned {}
@@ -264,6 +264,36 @@ fn initialize_linux_file_watcher(cx: &mut ViewContext<Workspace>) {
     }
 }
 
+#[cfg(target_os = "windows")]
+fn initialize_file_watcher(cx: &mut ViewContext<Workspace>) {
+    if let Err(e) = fs::fs_watcher::global(|_| {}) {
+        let message = format!(
+            db::indoc! {r#"
+            ReadDirectoryChangesW initialization failed: {}
+
+            This may occur on network filesystems and WSL paths. For troubleshooting see: https://zed.dev/docs/windows
+            "#},
+            e
+        );
+        let prompt = cx.prompt(
+            PromptLevel::Critical,
+            "Could not start ReadDirectoryChangesW",
+            Some(&message),
+            &["Troubleshoot and Quit"],
+        );
+        cx.spawn(|_, mut cx| async move {
+            if prompt.await == Ok(0) {
+                cx.update(|cx| {
+                    cx.open_url("https://zed.dev/docs/windows");
+                    cx.quit()
+                })
+                .ok();
+            }
+        })
+        .detach()
+    }
+}
+
 fn show_software_emulation_warning_if_needed(
     specs: gpui::GpuSpecs,
     cx: &mut ViewContext<Workspace>,