Add a filesystem watching debug window (#64065)

## 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.
This commit is contained in:
Conrad Irwin authored and GitHub committed 2026-09-11 04:13:07 +00:00
1 parent d66b5d2050
commit 85ad201397
7 files changed
+1486 -10

No files matched your search

+2 -1
View File
@@ -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"]
+19
View File
@@ -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<fs_watcher::WatchRecording> {
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<fs_watcher::WatchRecording> {
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<fs_watcher::WatchRecording> {
Some(fs_watcher::WatchRecording::new([
self.native_watcher.clone(),
self.poll_watcher.clone(),
]))
}
fn path_exists(&self, path: &Path) -> bool {
self.state
.lock()
+93 -9
View File
@@ -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<Option<Box<dyn WatchBackend>>>,
event_tx: async_channel::Sender<notify::Result<notify::Event>>,
_dispatch_task: Task<()>,
diagnostics: Arc<diagnostics::DiagnosticRecorder>,
}
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<notify::Event>) + 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(<notify::RecommendedWatcher as notify::Watcher>::new(
self.event_sink(),
config,
)?)
Box::new(
<notify::RecommendedWatcher as notify::Watcher>::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: &notify::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(),
}
}
+338
View File
@@ -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<Mutex<RecordingState>>,
watchers: Vec<Arc<OsWatcher>>,
}
#[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<WatcherSnapshot>,
pub events: Vec<Arc<WatchDiagnosticEvent>>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WatcherSnapshot {
pub backend: OsWatcherKind,
pub recursive: bool,
pub roots: Vec<WatchRoot>,
pub cooldown_remaining_millis: Option<u128>,
}
#[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<String>,
/// Paths are displayed lossily so a non-UTF-8 filename cannot prevent export.
pub paths: Vec<String>,
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: &notify::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<Arc<WatchDiagnosticEvent>>,
}
#[derive(Default)]
pub(super) struct DiagnosticRecorder {
recordings: Mutex<Vec<Weak<Mutex<RecordingState>>>>,
}
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<Item = Arc<OsWatcher>>) -> 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> {
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"));
}
}
+103
View File
@@ -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
+2
View File
@@ -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);
+929
View File
@@ -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<AppState>, cx: &mut App) {
cx.on_action(move |_: &DebugFilesystemWatching, cx| {
if let Some(existing) = cx
.windows()
.into_iter()
.find_map(|window| window.downcast::<WatcherDebug>())
{
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<String>,
}
#[derive(Serialize)]
struct Export {
zed_version: String,
os_name: String,
os_version: String,
watcher: WatchSnapshot,
worktree_scan_exclusions: Vec<WorktreeExclusions>,
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::<Utc>::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<Editor> {
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<Editor>, 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<WorktreeExclusions> {
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<AppState>,
recording: Option<WatchRecording>,
snapshot: Option<WatchSnapshot>,
active_tab: WatcherTab,
editors: [Entity<Editor>; 3],
editor_edit_bytes: [usize; 3],
event_lengths: VecDeque<usize>,
save_error: Option<SharedString>,
saving: bool,
_poll_task: Task<()>,
}
impl WatcherDebug {
fn new(app_state: Arc<AppState>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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::<MultiBufferOffset>(&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<Editor> {
&self.editors[tab as usize]
}
fn select_tab(&mut self, tab: WatcherTab, window: &mut Window, cx: &mut Context<Self>) {
self.active_tab = tab;
self.focus_handle(cx).focus(window, cx);
cx.notify();
}
fn save(&mut self, cx: &mut Context<Self>) {
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<Self>) -> 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::<WatcherDebug>())
.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::<WatcherDebug>())
.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::<WatcherDebug>().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::<WatcherDebug>())
.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();
}
}