From 85ad20139765dc97ecff4585d83c4d473ae769d7 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 11 Sep 2026 04:13:07 +0000 Subject: [PATCH] Add a filesystem watching debug window (#64065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add `dev: Debug Filesystem Watching`, which opens a singleton, app-wide debug window for local filesystem watching. - Capture native and polling notifications before filtering/coalescing, including lost-sync/rescan markers and watcher initialization, registration, and removal errors. - Show Raw Events, Watch Roots, and Scan Exclusions in separate read-only editors with normal selection and copying. - Record only while the window is open, retain the latest 10,000 events, and periodically compact editor buffers so deleted text/history does not accumulate indefinitely. - Display local timestamps and concise event names; export full raw details, roots, exclusions, dropped-event counts, and Zed/OS version information as JSON. - Declare the renderer source that `gpui_apple`'s build script reads in Corgi's sandbox configuration. This is diagnostic tooling, not a change to watcher behavior. It captures local watcher-level events; it does not yet trace worktree scan start/completion or remote filesystems. Scan exclusions are labeled separately from OS watch roots because excluded files can still generate notifications. ## Related issues These reports motivate the capture tooling; this PR does not claim to fix them: - [#63254: Project and Git panels stop updating when the macOS FSEvents stream limit is reached](https://github.com/zed-industries/zed/issues/63254) - [#63174: Atomic file replacement leaves Linux buffers stale](https://github.com/zed-industries/zed/issues/63174) - [#58678: Worktree stops opening files/expanding folders after a lost-sync rescan storm](https://github.com/zed-industries/zed/issues/58678) - [#53480: High CPU after watcher lost sync under low file-descriptor limits](https://github.com/zed-industries/zed/issues/53480) ## Validation - `corgi build -p zed` - `corgi test -p fs --lib --features test-support fs_watcher` — 19 tests passed - `corgi test -p fs --features test-support --test integration watcher_diagnostics` — 2 tests passed, including real OS notification delivery - `cargo test -p zed watcher_debug::tests` — 7 tests passed, covering formatting, singleton lifetime, tab layout, copying/read-only behavior, selection preservation, compaction and retained storage, JSON metadata, cancellation, and save failures - Rust formatting and `git diff --check` - Manual UI smoke testing on macOS during development The UI tests use Cargo because the installed Corgi version omits `CARGO_BIN_NAME` when compiling the binary test target. Normal Corgi builds pass. ## Reviewer note The required README review acknowledgment is intentionally left for the author to remove manually after review. ## Suggested .rules additions For bounded diagnostic logs backed by an editor, trimming visible rows does not bound memory: the text buffer retains deleted text and edit history. Periodically replace the buffer/editor while preserving selection and scroll state, and test retained storage rather than only visible row count. Release Notes: - Added `dev: Debug Filesystem Watching` to inspect local watcher events, watch roots, and scan exclusions, and export diagnostic captures as JSON. --- corgi.toml | 3 +- crates/fs/src/fs.rs | 19 + crates/fs/src/fs_watcher.rs | 102 ++- crates/fs/src/fs_watcher/diagnostics.rs | 338 +++++++++ crates/fs/tests/integration/fs_tests.rs | 103 +++ crates/zed/src/main.rs | 2 + crates/zed/src/watcher_debug.rs | 929 ++++++++++++++++++++++++ 7 files changed, 1486 insertions(+), 10 deletions(-) create mode 100644 crates/fs/src/fs_watcher/diagnostics.rs create mode 100644 crates/zed/src/watcher_debug.rs diff --git a/corgi.toml b/corgi.toml index 6f5367f459..b9e1edfb47 100644 --- a/corgi.toml +++ b/corgi.toml @@ -78,7 +78,8 @@ zed_extension_api = ["wit/**"] gpui_wgpu = ["src/*.wgsl"] # Cross-package reads (and, for gpui_apple, its own metal shader source). extension_host = ["../extension_api/wit/**"] -gpui_apple = ["../gpui/src/**", "src/shaders.metal"] +# cbindgen reads the renderer source directly from build.rs to generate shader bindings. +gpui_apple = ["../gpui/src/**", "src/metal_renderer.rs", "src/shaders.metal"] eval_cli = ["../zed/Cargo.toml"] prompt_store = ["../git_ui/src/commit_message_prompt.txt"] release_channel = ["../zed/RELEASE_CHANNEL"] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 548e4a87cc..120d6e26a3 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -152,6 +152,11 @@ pub trait Fs: Send + Sync { Ok(()) } + /// Records raw local watcher notifications until the returned recording is dropped. + fn record_watcher_diagnostics(&self) -> Option { + None + } + /// Whether `path` exists, without following a final symlink. Synchronous /// because watches are registered synchronously by the worktree scanner. fn path_exists(&self, path: &Path) -> bool; @@ -1175,6 +1180,13 @@ impl Fs for RealFs { self.native_watcher.ensure_backend() } + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + fn path_exists(&self, path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok() } @@ -3397,6 +3409,13 @@ impl Fs for FakeFs { anyhow::bail!("Git config is not supported in fake Fs") } + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + fn path_exists(&self, path: &Path) -> bool { self.state .lock() diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 8965ea5b58..3a9a1d08da 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -14,7 +14,12 @@ use util::{ResultExt, paths::SanitizedPath}; use crate::{Fs, PathEvent, PathEventKind, Watcher}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +mod diagnostics; +pub use diagnostics::{ + WatchDiagnosticEvent, WatchRecording, WatchRoot, WatchSnapshot, WatcherSnapshot, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum OsWatcherKind { Native, Poll, @@ -907,6 +912,7 @@ pub struct OsWatcher { backend: Mutex>>, event_tx: async_channel::Sender>, _dispatch_task: Task<()>, + diagnostics: Arc, } impl OsWatcher { @@ -945,6 +951,7 @@ impl OsWatcher { backend: Mutex::new(backend), event_tx, _dispatch_task: dispatch_task, + diagnostics: Default::default(), }) } @@ -954,7 +961,21 @@ impl OsWatcher { &self, ) -> impl Fn(notify::Result) + Send + Sync + 'static { let event_tx = self.event_tx.clone(); - move |event| enqueue(&event_tx, event) + let diagnostics = self.diagnostics.clone(); + let kind = self.kind; + move |event| { + diagnostics.record(|| match &event { + Ok(event) => WatchDiagnosticEvent::from_notify_event(kind, event), + Err(error) => WatchDiagnosticEvent::new( + kind, + "error", + &error.paths, + format!("{error:?}"), + false, + ), + }); + enqueue(&event_tx, event); + } } pub(crate) fn is_recursive(&self) -> bool { @@ -978,6 +999,16 @@ impl OsWatcher { if !path_already_covered && !path_already_registered { if self.kind == OsWatcherKind::Native && state.is_native_watch_limit_cooldown_active() { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "watch_skipped", + &[path.as_path().to_owned()], + "Native watch limit cooldown is active; registration will be retried" + .into(), + false, + ) + }); return Ok(None); } @@ -1054,7 +1085,8 @@ impl OsWatcher { fn watch(&self, path: &Path) -> anyhow::Result<()> { self.ensure_backend()?; - self.backend + let result = self + .backend .lock() .as_mut() .expect("watcher backend initialized") @@ -1065,7 +1097,24 @@ impl OsWatcher { } else { notify::RecursiveMode::NonRecursive }, - )?; + ); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if result.is_ok() { + "watch" + } else { + "watch_error" + }, + &[path.to_owned()], + match &result { + Ok(()) => format!("recursive={}", self.recursive), + Err(error) => format!("{error:?}"), + }, + false, + ) + }); + result?; Ok(()) } @@ -1076,6 +1125,22 @@ impl OsWatcher { .as_mut() .map(|watcher| watcher.unwatch(path)); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if matches!(&watcher, Some(Err(_))) { + "unwatch_error" + } else { + "unwatch" + }, + &[path.to_owned()], + match &watcher { + Some(Err(error)) => format!("{error:?}"), + _ => String::new(), + }, + false, + ) + }); match watcher { // inotify auto-removes a watch when its directory is deleted, so a // later unwatch races that and fails with a benign error. Either way @@ -1103,19 +1168,37 @@ impl OsWatcher { // workloads like grep or language server indexing. let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); - Box::new(::new( - self.event_sink(), - config, - )?) + Box::new( + ::new( + self.event_sink(), + config, + ) + .inspect_err(|error| self.record_backend_error(error))?, + ) } OsWatcherKind::Poll => { let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); - Box::new(notify::PollWatcher::new(self.event_sink(), config)?) + Box::new( + notify::PollWatcher::new(self.event_sink(), config) + .inspect_err(|error| self.record_backend_error(error))?, + ) } }); } Ok(()) } + + fn record_backend_error(&self, error: ¬ify::Error) { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "backend_error", + &error.paths, + format!("{error:?}"), + false, + ) + }); + } } fn dispatch( @@ -1317,6 +1400,7 @@ mod tests { ), event_tx, _dispatch_task: Task::ready(()), + diagnostics: Default::default(), } } diff --git a/crates/fs/src/fs_watcher/diagnostics.rs b/crates/fs/src/fs_watcher/diagnostics.rs new file mode 100644 index 0000000000..9f9e58e425 --- /dev/null +++ b/crates/fs/src/fs_watcher/diagnostics.rs @@ -0,0 +1,338 @@ +use super::{OsWatcher, OsWatcherKind}; +use parking_lot::Mutex; +use serde::Serialize; +use std::{ + cell::LazyCell, + collections::VecDeque, + path::PathBuf, + sync::{Arc, Weak}, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +const EVENT_CAPACITY: usize = 10_000; + +/// An opt-in recording. Watchers hold only weak references to its buffer, so +/// dropping this value stops collection without changing any watches. +pub struct WatchRecording { + state: Arc>, + watchers: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchSnapshot { + pub started_at_unix_millis: u128, + pub captured_at_unix_millis: u128, + pub capacity: usize, + pub dropped_events: u64, + pub watchers: Vec, + pub events: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatcherSnapshot { + pub backend: OsWatcherKind, + pub recursive: bool, + pub roots: Vec, + pub cooldown_remaining_millis: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchRoot { + pub path: String, + pub registrations: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchDiagnosticEvent { + pub timestamp_unix_millis: u128, + pub backend: OsWatcherKind, + pub operation: String, + pub event_kind: Option, + /// Paths are displayed lossily so a non-UTF-8 filename cannot prevent export. + pub paths: Vec, + pub detail: String, + pub rescan: bool, +} + +impl WatchDiagnosticEvent { + pub(super) fn new( + backend: OsWatcherKind, + operation: &str, + paths: &[PathBuf], + detail: String, + rescan: bool, + ) -> Self { + Self { + timestamp_unix_millis: unix_millis(), + backend, + operation: operation.into(), + event_kind: None, + paths: paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + detail, + rescan, + } + } + + pub(super) fn from_notify_event(backend: OsWatcherKind, event: ¬ify::Event) -> Self { + Self { + event_kind: Some(format!("{:?}", event.kind)), + ..Self::new( + backend, + "event", + &event.paths, + format!("{:?} {:?}", event.kind, event.attrs), + event.need_rescan(), + ) + } + } +} + +struct RecordingState { + started_at_unix_millis: u128, + dropped_events: u64, + events: VecDeque>, +} + +#[derive(Default)] +pub(super) struct DiagnosticRecorder { + recordings: Mutex>>>, +} + +impl DiagnosticRecorder { + pub(super) fn record(&self, event: impl FnOnce() -> WatchDiagnosticEvent) { + let mut recordings = self.recordings.lock(); + // Formatting raw events and allocating paths must not happen when + // there is no debug window recording them. + let event = LazyCell::new(|| Arc::new(event())); + recordings.retain(|recording| { + let Some(recording) = recording.upgrade() else { + return false; + }; + let mut recording = recording.lock(); + if recording.events.len() == EVENT_CAPACITY { + recording.events.pop_front(); + recording.dropped_events += 1; + } + recording.events.push_back((*event).clone()); + true + }); + } +} + +impl WatchRecording { + pub(crate) fn new(watchers: impl IntoIterator>) -> Self { + let state = Arc::new(Mutex::new(RecordingState { + started_at_unix_millis: unix_millis(), + dropped_events: 0, + events: VecDeque::new(), + })); + let watchers: Vec<_> = watchers.into_iter().collect(); + for watcher in &watchers { + let mut recordings = watcher.diagnostics.recordings.lock(); + recordings.retain(|recording| recording.strong_count() > 0); + recordings.push(Arc::downgrade(&state)); + } + Self { state, watchers } + } + + pub fn snapshot(&self) -> WatchSnapshot { + let (started_at_unix_millis, dropped_events, events) = { + let state = self.state.lock(); + ( + state.started_at_unix_millis, + state.dropped_events, + state.events.iter().cloned().collect(), + ) + }; + let watchers = self + .watchers + .iter() + .map(|watcher| { + let state = watcher.state.lock(); + let mut roots: Vec<_> = state + .paths + .0 + .values() + .filter(|path| path.has_os_watcher) + .filter_map(|path| { + let registration = state.watchers.get(path.watcher_ids.first()?)?; + Some(WatchRoot { + path: registration.path.as_path().to_string_lossy().into_owned(), + registrations: path.watcher_ids.len(), + }) + }) + .collect(); + roots.sort_by(|left, right| left.path.cmp(&right.path)); + WatcherSnapshot { + backend: watcher.kind, + recursive: watcher.recursive, + roots, + cooldown_remaining_millis: state + .cooldown_until + .and_then(|until| until.checked_duration_since(Instant::now())) + .map(|remaining| remaining.as_millis()), + } + }) + .collect(); + WatchSnapshot { + started_at_unix_millis, + captured_at_unix_millis: unix_millis(), + capacity: EVENT_CAPACITY, + dropped_events, + watchers, + events, + } + } +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_watcher::WatchBackend; + use gpui::TestAppContext; + use notify::{Event, EventKind, event::Flag}; + use std::path::Path; + + struct Backend; + + impl WatchBackend for Backend { + fn watch(&mut self, path: &Path, _: notify::RecursiveMode) -> notify::Result<()> { + if path.ends_with("fail") { + Err(notify::Error::generic("watch failed").add_path(path.to_owned())) + } else { + Ok(()) + } + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + Err(notify::Error::generic("unwatch failed").add_path(path.to_owned())) + } + } + + fn watcher(kind: OsWatcherKind, cx: &TestAppContext) -> Arc { + OsWatcher::with_backend( + kind, + cx.background_executor.clone(), + Some(Box::new(Backend)), + ) + } + + #[gpui::test] + fn recording_is_opt_in_and_bounded(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + watcher.diagnostics.record(|| panic!("not recording")); + let recording = WatchRecording::new([watcher.clone()]); + let state = Arc::downgrade(&recording.state); + for index in 0..EVENT_CAPACITY + 3 { + watcher.diagnostics.record(|| { + WatchDiagnosticEvent::new( + OsWatcherKind::Native, + "event", + &[], + index.to_string(), + false, + ) + }); + } + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), EVENT_CAPACITY); + assert_eq!(snapshot.dropped_events, 3); + assert_eq!(snapshot.events.first().unwrap().detail, "3"); + assert_eq!( + snapshot.events.last().unwrap().detail, + (EVENT_CAPACITY + 2).to_string() + ); + + drop(recording); + assert!(state.upgrade().is_none()); + watcher + .diagnostics + .record(|| panic!("recording was dropped")); + let recording = WatchRecording::new([watcher]); + assert!(recording.snapshot().events.is_empty()); + } + + #[gpui::test] + fn recording_keeps_raw_events_errors_and_rescans(cx: &TestAppContext) { + let native = watcher(OsWatcherKind::Native, cx); + let poll = watcher(OsWatcherKind::Poll, cx); + let recording = WatchRecording::new([native.clone(), poll.clone()]); + let native_sink = native.event_sink(); + let poll_sink = poll.event_sink(); + + // Access events and duplicate overflows are filtered on their way to + // worktrees, but diagnostics must preserve what the backend reported. + native_sink(Ok(Event::new(EventKind::Access( + notify::event::AccessKind::Any, + )))); + for _ in 0..2 { + native_sink(Ok(Event::new(EventKind::Other).set_flag(Flag::Rescan))); + } + poll_sink(Err( + notify::Error::generic("read failed").add_path(util::path!("/root/file").into()) + )); + + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), 4); + assert!(snapshot.events[0].detail.contains("Access")); + assert_eq!( + snapshot.events[0].event_kind.as_deref(), + Some("Access(Any)") + ); + assert!(snapshot.events[1].rescan); + assert!(snapshot.events[2].rescan); + assert_eq!(snapshot.events[3].backend, OsWatcherKind::Poll); + assert_eq!(snapshot.events[3].operation, "error"); + assert_eq!(snapshot.events[3].event_kind, None); + assert!(snapshot.events[3].detail.contains("read failed")); + assert_eq!(snapshot.events[3].paths, [util::path!("/root/file")]); + let json = serde_json::to_value(snapshot).unwrap(); + assert_eq!(json["events"][2]["rescan"], true); + assert_eq!(json["events"][3]["paths"][0], util::path!("/root/file")); + } + + #[gpui::test] + fn recording_snapshots_existing_roots_and_registration_errors(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + let root = util::path!("/root"); + let registration = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let duplicate = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let recording = WatchRecording::new([watcher.clone()]); + let snapshot = recording.snapshot(); + assert!(snapshot.events.is_empty()); + assert_eq!(snapshot.watchers[0].roots.len(), 1); + assert_eq!(snapshot.watchers[0].roots[0].path, root); + assert_eq!(snapshot.watchers[0].roots[0].registrations, 2); + + assert!( + watcher + .add(Path::new(util::path!("/fail")).into(), false, |_| {}) + .is_err() + ); + watcher.remove(duplicate); + assert_eq!(recording.snapshot().watchers[0].roots[0].registrations, 1); + watcher.remove(registration); + let snapshot = recording.snapshot(); + assert!(snapshot.watchers[0].roots.is_empty()); + assert_eq!(snapshot.events[0].operation, "watch_error"); + assert!(snapshot.events[0].detail.contains("watch failed")); + assert_eq!(snapshot.events[1].operation, "unwatch_error"); + assert!(snapshot.events[1].detail.contains("unwatch failed")); + } +} diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index 91dfce57bd..5bd132d4e3 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -17,6 +17,56 @@ use serde_json::json; use tempfile::TempDir; use util::path; +#[gpui::test] +async fn test_watcher_diagnostics_do_not_change_event_delivery(executor: BackgroundExecutor) { + let fs = FakeFs::new(executor); + let root = Path::new(path!("/root")); + let file = root.join("file"); + fs.create_dir(root).await.unwrap(); + let (mut events, watcher) = fs.watch(root, Duration::ZERO).await; + let recording = fs.record_watcher_diagnostics().unwrap(); + assert_eq!( + recording.snapshot().watchers[0].roots[0].path, + root.to_string_lossy() + ); + assert!(recording.snapshot().events.is_empty()); + + fs.write(&file, b"first").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!(recording.snapshot().events.iter().any(|event| { + event.operation == "event" && event.paths.contains(&file.to_string_lossy().into_owned()) + })); + + fs.simulate_watcher_overflow(root); + let batch = events.next().await.unwrap(); + assert!( + batch + .iter() + .any(|event| event.kind == Some(PathEventKind::Rescan)) + ); + assert!(recording.snapshot().events.iter().any(|event| event.rescan)); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!( + fs.record_watcher_diagnostics() + .unwrap() + .snapshot() + .events + .is_empty() + ); + drop(events); + drop(watcher); + assert!( + fs.record_watcher_diagnostics().unwrap().snapshot().watchers[0] + .roots + .is_empty() + ); +} + #[gpui::test] async fn test_fake_fs(executor: BackgroundExecutor) { let fs = FakeFs::new(executor.clone()); @@ -961,6 +1011,59 @@ async fn watcher_delivered_event( } } +#[gpui::test] +async fn test_realfs_watcher_diagnostics(executor: BackgroundExecutor, cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let fs = RealFs::new(None, executor.clone()); + let directory = TempDir::new().unwrap(); + let root = std::fs::canonicalize(directory.path()).unwrap(); + // Windows canonicalization adds a verbatim prefix that watcher paths omit. + let root = util::paths::SanitizedPath::new(&root) + .as_path() + .to_path_buf(); + let recording = fs.record_watcher_diagnostics().unwrap(); + let (mut events, watcher) = fs.watch(&root, Duration::from_millis(10)).await; + let file = root.join("watcher-diagnostics.txt"); + fs.write(&file, b"first").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await, + "no watcher event matched {file:?}: {:#?}", + recording.snapshot() + ); + let snapshot = recording.snapshot(); + assert!( + snapshot + .events + .iter() + .any(|event| event.operation == "watch") + ); + assert!(snapshot.events.iter().any(|event| { + event.operation == "event" + && (event.rescan || event.paths.contains(&file.to_string_lossy().into_owned())) + })); + assert!(snapshot.watchers.iter().any(|watcher| { + watcher + .roots + .iter() + .any(|entry| entry.path == root.to_string_lossy()) + })); + serde_json::to_string_pretty(&snapshot).unwrap(); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await + ); + drop(events); + drop(watcher); +} + /// Exercises a spread of real watchers whose registered watch path is spelled /// differently from the path the OS reports events under. Each scenario watches /// some directory and then mutates the on-disk file; a correct watcher must diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 67555b9c73..d759241eeb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod reliability; +mod watcher_debug; mod zed; // Ensure the binary name stays in sync with APP_NAME so that the paths used @@ -650,6 +651,7 @@ fn main() { }); AppState::set_global(app_state.clone(), cx); + watcher_debug::init(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); auto_update_ui::init(cx); diff --git a/crates/zed/src/watcher_debug.rs b/crates/zed/src/watcher_debug.rs new file mode 100644 index 0000000000..4d432ea51d --- /dev/null +++ b/crates/zed/src/watcher_debug.rs @@ -0,0 +1,929 @@ +use chrono::{DateTime, Local, Utc}; +use editor::{Editor, MultiBufferOffset}; +use fs::fs_watcher::{WatchDiagnosticEvent, WatchRecording, WatchSnapshot}; +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, Render, Task, TitlebarOptions, + Window, WindowBounds, WindowOptions, actions, px, size, +}; +use language::{LineEnding, language_settings::SoftWrap}; +use release_channel::AppVersion; +use serde::Serialize; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + time::Duration, +}; +use ui::{Tab, TabBar, TabPosition, prelude::*}; +use util::ResultExt; +use workspace::AppState; + +actions!( + dev, + [ + /// Open an app-wide recording of raw local filesystem watcher events. + DebugFilesystemWatching + ] +); + +pub fn init(app_state: Arc, cx: &mut App) { + cx.on_action(move |_: &DebugFilesystemWatching, cx| { + if let Some(existing) = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()) + { + existing + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + }) + .log_err(); + return; + } + + let app_state = app_state.clone(); + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions::default()), + window_bounds: Some(WindowBounds::centered(size(px(1000.), px(700.)), cx)), + ..Default::default() + }, + |window, cx| { + window.set_window_title("Debug Filesystem Watching"); + let view = cx.new(|cx| WatcherDebug::new(app_state, window, cx)); + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + view + }, + ) + .log_err(); + }); +} + +#[derive(Serialize)] +struct WorktreeExclusions { + root: String, + file_scan_exclusions: Vec, +} + +#[derive(Serialize)] +struct Export { + zed_version: String, + os_name: String, + os_version: String, + watcher: WatchSnapshot, + worktree_scan_exclusions: Vec, + exclusion_scope: &'static str, +} + +const EXCLUSION_SCOPE: &str = "Patterns Zed skips when scanning your open local projects. Excluded files may still produce watcher events."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum WatcherTab { + RawEvents, + WatchRoots, + ScanExclusions, +} + +impl WatcherTab { + const ALL: [Self; 3] = [Self::RawEvents, Self::WatchRoots, Self::ScanExclusions]; + + fn label(self) -> &'static str { + match self { + Self::RawEvents => "Raw Events", + Self::WatchRoots => "Watch Roots", + Self::ScanExclusions => "Scan Exclusions", + } + } + + fn description(self) -> &'static str { + match self { + Self::RawEvents => { + "Raw watcher notifications, oldest first. Times are shown in your local time zone." + } + Self::WatchRoots => "Live native and polling watch roots across the app.", + Self::ScanExclusions => EXCLUSION_SCOPE, + } + } + + fn empty_message(self) -> &'static str { + match self { + Self::RawEvents => "Waiting for filesystem watcher events…", + Self::WatchRoots => "No watch roots.", + Self::ScanExclusions => "No local projects are open.", + } + } +} + +fn format_event(event: &WatchDiagnosticEvent) -> SharedString { + let time = i64::try_from(event.timestamp_unix_millis) + .ok() + .and_then(DateTime::::from_timestamp_millis) + .map(|time| { + time.with_timezone(&Local) + .format("%H:%M:%S%.3f") + .to_string() + }) + .unwrap_or_else(|| "--:--:--.---".into()); + let name = if event.rescan { + "Rescan" + } else { + event.event_kind.as_deref().unwrap_or(&event.operation) + }; + let name = name + .split_once('(') + .map_or(name, |(name, _)| name) + .to_lowercase(); + let mut row = format!("{time} {name}"); + for path in &event.paths { + row.push_str(" "); + row.push_str(path); + } + row.into() +} + +fn create_editor( + tab: WatcherTab, + text: String, + window: &mut Window, + cx: &mut App, +) -> Entity { + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor.set_input_enabled(false); + editor.set_use_modal_editing(false); + editor.set_show_gutter(false, cx); + editor.hide_minimap_by_default(window, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor.set_soft_wrap_mode(SoftWrap::None, cx); + editor.set_placeholder_text(tab.empty_message(), window, cx); + editor + }) +} + +fn update_editor_text(editor: &Entity, mut text: String, cx: &mut App) -> usize { + LineEnding::normalize(&mut text); + editor.update(cx, |editor, cx| { + let previous = editor.text(cx); + if previous == text { + return 0; + } + let edits = language::text_diff(&previous, &text); + let edited_bytes = edits + .iter() + .map(|(range, text)| range.len() + text.len()) + .sum(); + let edits = edits.into_iter().map(|(range, text)| { + ( + MultiBufferOffset(range.start)..MultiBufferOffset(range.end), + text, + ) + }); + editor + .buffer() + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + edited_bytes + }) +} + +fn worktree_exclusions(app_state: &AppState, cx: &App) -> Vec { + let mut seen = HashSet::new(); + let mut exclusions = Vec::new(); + for workspace in app_state.workspace_store.read(cx).workspaces() { + let Some(workspace) = workspace.upgrade() else { + continue; + }; + for worktree in workspace.read(cx).worktrees(cx) { + if !seen.insert(worktree.entity_id()) { + continue; + } + let Some(local) = worktree.read(cx).as_local() else { + continue; + }; + exclusions.push(WorktreeExclusions { + root: local.abs_path().to_string_lossy().into_owned(), + file_scan_exclusions: local + .settings() + .file_scan_exclusions + .sources() + .map(str::to_owned) + .collect(), + }); + } + } + exclusions.sort_by(|left, right| left.root.cmp(&right.root)); + exclusions +} + +struct WatcherDebug { + app_state: Arc, + recording: Option, + snapshot: Option, + active_tab: WatcherTab, + editors: [Entity; 3], + editor_edit_bytes: [usize; 3], + event_lengths: VecDeque, + save_error: Option, + saving: bool, + _poll_task: Task<()>, +} + +impl WatcherDebug { + fn new(app_state: Arc, window: &mut Window, cx: &mut Context) -> Self { + let editors = WatcherTab::ALL.map(|tab| create_editor(tab, String::new(), window, cx)); + let recording = app_state.fs.record_watcher_diagnostics(); + let poll_task = cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + if this + .update_in(cx, |this, window, cx| { + this.refresh(cx); + this.compact_editors(window, cx); + cx.notify(); + }) + .is_err() + { + break; + } + } + }); + let mut view = Self { + app_state, + recording, + snapshot: None, + active_tab: WatcherTab::RawEvents, + editors, + editor_edit_bytes: [0; 3], + event_lengths: VecDeque::new(), + save_error: None, + saving: false, + _poll_task: poll_task, + }; + view.refresh(cx); + view + } + + fn refresh(&mut self, cx: &mut Context) { + let snapshot = self.recording.as_ref().map(WatchRecording::snapshot); + let mut watch_roots = String::new(); + if let Some(snapshot) = &snapshot { + self.refresh_events(snapshot, cx); + for watcher in &snapshot.watchers { + watch_roots.push_str(&format!( + "{:?}: recursive={}, cooldown remaining={:?} ms\n", + watcher.backend, watcher.recursive, watcher.cooldown_remaining_millis + )); + for root in &watcher.roots { + watch_roots.push_str(&format!( + " {} ({} registrations)\n", + root.path, root.registrations + )); + } + } + } + self.snapshot = snapshot; + self.editor_edit_bytes[WatcherTab::WatchRoots as usize] += + update_editor_text(self.editor(WatcherTab::WatchRoots), watch_roots, cx); + let mut scan_exclusions = String::new(); + for worktree in worktree_exclusions(&self.app_state, cx) { + scan_exclusions.push_str(&worktree.root); + scan_exclusions.push('\n'); + if worktree.file_scan_exclusions.is_empty() { + scan_exclusions.push_str(" (none)\n"); + } + for pattern in worktree.file_scan_exclusions { + scan_exclusions.push_str(&format!(" {pattern}\n")); + } + } + self.editor_edit_bytes[WatcherTab::ScanExclusions as usize] += + update_editor_text(self.editor(WatcherTab::ScanExclusions), scan_exclusions, cx); + } + + fn refresh_events(&mut self, snapshot: &WatchSnapshot, cx: &mut Context) { + let previous_dropped = self + .snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.dropped_events); + let removed = usize::try_from(snapshot.dropped_events.saturating_sub(previous_dropped)) + .unwrap_or(usize::MAX) + .min(self.event_lengths.len()); + let removed_bytes: usize = self.event_lengths.drain(..removed).sum(); + let retained = self.event_lengths.len(); + let mut appended = String::new(); + for event in snapshot.events.iter().skip(retained) { + let mut line = format!("{}\n", format_event(event)); + LineEnding::normalize(&mut line); + self.event_lengths.push_back(line.len()); + appended.push_str(&line); + } + if removed_bytes == 0 && appended.is_empty() { + return; + } + self.editor_edit_bytes[WatcherTab::RawEvents as usize] += removed_bytes + appended.len(); + self.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + let length = buffer.len(cx); + // Keep anchors in retained events intact when the recording rolls over. + buffer.edit( + [ + ( + MultiBufferOffset(0)..MultiBufferOffset(removed_bytes), + String::new(), + ), + (length..length, appended), + ], + None, + cx, + ); + }); + }); + } + + fn compact_editors(&mut self, window: &mut Window, cx: &mut Context) { + for tab in WatcherTab::ALL { + let index = tab as usize; + let length = self.editors[index].read(cx).buffer().read(cx).len(cx).0; + if self.editor_edit_bytes[index] < length.saturating_mul(2).max(64 * 1024) { + continue; + } + + // Deletions leave CRDT tombstones and undo history behind. Replacing + // the editor releases that storage without invalidating live selections. + let (text, selections, scroll_position, focused) = + self.editors[index].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + ( + editor.text(cx), + editor.selections.all::(&snapshot), + editor.scroll_position(cx), + editor.focus_handle(cx).is_focused(window), + ) + }); + let editor = create_editor(tab, text, window, cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selection| { + selection.select(selections); + }, + ); + editor.set_scroll_position(scroll_position, window, cx); + if focused { + editor.focus_handle(cx).focus(window, cx); + } + }); + self.editors[index] = editor; + self.editor_edit_bytes[index] = 0; + cx.notify(); + } + } + + fn editor(&self, tab: WatcherTab) -> &Entity { + &self.editors[tab as usize] + } + + fn select_tab(&mut self, tab: WatcherTab, window: &mut Window, cx: &mut Context) { + self.active_tab = tab; + self.focus_handle(cx).focus(window, cx); + cx.notify(); + } + + fn save(&mut self, cx: &mut Context) { + let Some(recording) = &self.recording else { + return; + }; + let watcher = recording.snapshot(); + let worktree_scan_exclusions = worktree_exclusions(&self.app_state, cx); + let zed_version = AppVersion::global(cx).to_string(); + let os_name = client::telemetry::os_name(); + let directory = std::env::home_dir().unwrap_or_default(); + let path = cx.prompt_for_new_path(&directory, Some("filesystem-watcher.json")); + let fs = self.app_state.fs.clone(); + self.saving = true; + self.save_error = None; + cx.notify(); + cx.spawn(async move |this, cx| { + let result = async { + let Some(path) = path.await?? else { + return anyhow::Ok(()); + }; + cx.background_spawn(async move { + let export = Export { + zed_version, + os_name, + os_version: client::telemetry::os_version(), + watcher, + worktree_scan_exclusions, + exclusion_scope: EXCLUSION_SCOPE, + }; + let json = serde_json::to_vec_pretty(&export)?; + fs.write(&path, &json).await + }) + .await + } + .await; + this.update(cx, |this, cx| { + this.saving = false; + this.save_error = match result { + Ok(()) => None, + Err(error) => Some(format!("Save failed: {error:#}").into()), + }; + cx.notify(); + }) + .log_err(); + }) + .detach(); + } +} + +impl Focusable for WatcherDebug { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor(self.active_tab).focus_handle(cx) + } +} + +impl Render for WatcherDebug { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .text_color(cx.theme().colors().text) + .child( + TabBar::new("watcher-tabs").children(WatcherTab::ALL.map(|tab| { + Tab::new(tab.label()) + .position(match tab { + WatcherTab::RawEvents => TabPosition::First, + WatcherTab::WatchRoots => { + TabPosition::Middle(tab.cmp(&self.active_tab)) + } + WatcherTab::ScanExclusions => TabPosition::Last, + }) + .toggle_state(self.active_tab == tab) + .child(Label::new(tab.label())) + .on_click( + cx.listener(move |this, _, window, cx| { + this.select_tab(tab, window, cx) + }), + ) + })), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_hidden() + .p_3() + .gap_2() + .child( + Label::new(self.active_tab.description()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(self.save_error.clone(), |content, error| { + content.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .child(self.editor(self.active_tab).clone()), + ), + ) + .child( + h_flex() + .debug_selector(|| "watcher-footer".into()) + .flex_none() + .justify_end() + .bg(cx.theme().colors().elevated_surface_background) + .p(DynamicSpacing::Base04.rems(cx)) + .child( + Button::new("save-watcher-json", "Export as JSON") + .disabled(self.saving || self.recording.is_none()) + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Timelike}; + use fs::fs_watcher::OsWatcherKind; + + #[test] + fn formats_local_times_event_names_and_plain_paths() { + let time = Local + .with_ymd_and_hms(2026, 9, 10, 16, 25, 49) + .single() + .unwrap() + .with_nanosecond(123_000_000) + .unwrap(); + let event = WatchDiagnosticEvent { + timestamp_unix_millis: time.timestamp_millis().try_into().unwrap(), + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Modify(Name(Both))".into()), + paths: vec!["/project/old name.rs".into(), "/project/new name.rs".into()], + detail: "Extra raw backend attributes".into(), + rescan: false, + }; + assert_eq!( + format_event(&event).as_ref(), + "16:25:49.123 modify /project/old name.rs /project/new name.rs" + ); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["detail"], "Extra raw backend attributes"); + assert_eq!(json["timestamp_unix_millis"], time.timestamp_millis()); + assert_eq!(json["paths"].as_array().unwrap().len(), 2); + } + + #[test] + fn formats_pathless_rescans_and_errors() { + let mut event = WatchDiagnosticEvent { + timestamp_unix_millis: u128::MAX, + backend: OsWatcherKind::Poll, + operation: "event".into(), + event_kind: Some("Other".into()), + paths: vec![], + detail: "Raw rescan attributes".into(), + rescan: true, + }; + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- rescan"); + event.rescan = false; + event.event_kind = None; + event.operation = "watch_error".into(); + event.detail = "Permission denied".into(); + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- watch_error"); + } + + #[gpui::test] + fn editors_allow_copying_and_preserve_selections_on_updates(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + for tab in WatcherTab::ALL { + window + .update(cx, |view, window, cx| { + let editor = view.editor(tab).clone(); + update_editor_text(&editor, "prefix\nselected path\nsuffix\n".into(), cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections + .select_ranges([MultiBufferOffset(7)..MultiBufferOffset(20)]); + }, + ); + }); + view.select_tab(tab, window, cx); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + visual.simulate_input("should not edit"); + window + .update(cx, |view, _, cx| { + let editor = view.editor(tab); + assert_eq!(editor.read(cx).text(cx), "prefix\nselected path\nsuffix\n"); + update_editor_text( + editor, + "added\nprefix\nselected path\nsuffix\ntail\n".into(), + cx, + ); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn event_rollover_preserves_retained_selection(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let snapshot = |dropped_events, paths: &[&str]| WatchSnapshot { + started_at_unix_millis: 0, + captured_at_unix_millis: 0, + capacity: 2, + dropped_events, + watchers: vec![], + events: paths + .iter() + .map(|path| { + Arc::new(WatchDiagnosticEvent { + timestamp_unix_millis: 0, + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Create(File)".into()), + paths: vec![(*path).into()], + detail: String::new(), + rescan: false, + }) + }) + .collect(), + }; + window + .update(cx, |view, window, cx| { + let first = snapshot(0, &["/old\r\nfile", "/kept"]); + view.refresh_events(&first, cx); + view.snapshot = Some(first); + view.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + let start = editor.text(cx).find("/kept").unwrap(); + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + MultiBufferOffset(start)..MultiBufferOffset(start + 5) + ]); + }, + ); + }); + let next = snapshot(1, &["/kept", "/new"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/old")); + assert!(text.contains("/new")); + view.select_tab(WatcherTab::RawEvents, window, cx); + }) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + let old_editor = window + .update(cx, |view, window, cx| { + let old_editor = view.editor(WatcherTab::RawEvents).downgrade(); + view.editor_edit_bytes[WatcherTab::RawEvents as usize] = 64 * 1024; + view.compact_editors(window, cx); + old_editor + }) + .unwrap(); + visual.run_until_parked(); + assert!(old_editor.upgrade().is_none()); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + window + .update(cx, |view, window, cx| { + let next = snapshot(10, &["/entirely", "/different"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/kept")); + assert!(!text.contains("/new")); + assert!(text.contains("/entirely")); + assert_eq!(view.event_lengths.len(), 2); + let mut compactions = 0; + for cycle in 0..40 { + let path = format!("/{cycle}/{}", "x".repeat(4096)); + let next = snapshot(12 + cycle * 2, &[&path, &path]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let previous_editor = view.editor(WatcherTab::RawEvents).entity_id(); + view.compact_editors(window, cx); + let editor = view.editor(WatcherTab::RawEvents); + if previous_editor != editor.entity_id() { + compactions += 1; + } + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let buffer = buffer.as_singleton().unwrap(); + assert!(buffer.deleted_text().len() < 64 * 1024); + assert_eq!(buffer.text().lines().count(), 2); + } + assert!(compactions > 1); + window.remove_window(); + }) + .unwrap(); + } + + #[gpui::test] + async fn tabs_separate_rows_and_keep_footer_visible(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/watched")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + fs.write(&root.join("event.txt"), b"first").await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, WatcherTab::RawEvents); + assert!( + view.editor(WatcherTab::RawEvents) + .read(cx) + .text(cx) + .contains("event.txt") + ); + }) + .unwrap(); + + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + assert_eq!( + visual.window_title().as_deref(), + Some("Debug Filesystem Watching") + ); + visual.run_until_parked(); + let footer = visual.debug_bounds("watcher-footer").unwrap(); + let status_bar_height = visual.update(|window, cx| { + ButtonSize::Default.rems().to_pixels(window.rem_size()) + + DynamicSpacing::Base04.rems(cx).to_pixels(window.rem_size()) * 2. + }); + assert_eq!(footer.size.height, status_bar_height); + for (selector, tab) in [ + ("TAB-Watch Roots", WatcherTab::WatchRoots), + ("TAB-Scan Exclusions", WatcherTab::ScanExclusions), + ("TAB-Raw Events", WatcherTab::RawEvents), + ] { + let bounds = visual.debug_bounds(selector).unwrap(); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + assert_eq!(visual.debug_bounds("watcher-footer"), Some(footer)); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, tab); + let editor = view.editor(tab).read(cx); + assert!(editor.read_only(cx)); + let text = editor.text(cx); + match tab { + WatcherTab::RawEvents => { + assert!(text.contains("event.txt")) + } + WatcherTab::WatchRoots => { + assert!(text.contains("Native")); + assert!(text.contains(root.to_str().unwrap())); + } + WatcherTab::ScanExclusions => { + assert!(text.is_empty()); + } + } + }) + .unwrap(); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn singleton_releases_recording_view_on_close(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .expect("diagnostics window should open") + }); + let view = window + .update(cx, |view, _, cx| { + assert!(view.recording.is_some()); + cx.weak_entity() + }) + .expect("diagnostics window should exist"); + cx.update(|cx| cx.dispatch_action(&DebugFilesystemWatching)); + cx.update(|cx| { + assert_eq!( + cx.windows() + .into_iter() + .filter(|window| window.downcast::().is_some()) + .count(), + 1 + ); + }); + window + .update(cx, |_, window, _| window.remove_window()) + .expect("diagnostics window should close"); + cx.run_until_parked(); + assert!(view.upgrade().is_none()); + } + + #[gpui::test] + async fn saves_json_handles_cancellation_and_reports_errors(cx: &mut gpui::TestAppContext) { + cx.update(|cx| release_channel::init("1.2.3+dev.test".parse().unwrap(), cx)); + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/exports")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + assert!(cx.did_prompt_for_new_path()); + cx.simulate_new_path_selection(|_| None); + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + let path = root.join("watcher.json"); + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(path.clone())); + cx.run_until_parked(); + let json: serde_json::Value = serde_json::from_str(&fs.load(&path).await.unwrap()).unwrap(); + assert_eq!(json["zed_version"], "1.2.3+dev.test"); + assert_eq!(json["os_name"], client::telemetry::os_name()); + let os_version = cx + .background_executor + .spawn(async { client::telemetry::os_version() }) + .await; + assert_eq!(json["os_version"], os_version); + assert!(!os_version.is_empty()); + assert!(json.get("schema_version").is_none()); + assert_eq!(json["watcher"]["capacity"], 10_000); + assert_eq!( + json["watcher"]["watchers"][0]["roots"][0]["path"], + root.to_string_lossy().as_ref() + ); + assert_eq!(json["exclusion_scope"], EXCLUSION_SCOPE); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(root.to_owned())); + cx.run_until_parked(); + window + .update(cx, |view, window, _| { + assert!(!view.saving); + assert!( + view.save_error + .as_ref() + .unwrap() + .starts_with("Save failed:") + ); + window.remove_window(); + }) + .unwrap(); + } +}