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:
1 parent
d66b5d2050
commit
85ad201397
7 files changed
+1486
-10
No files matched your search
+2
-1
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: ¬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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: ¬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<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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user