notify-file-watcher
Cross-platform file system watching with the notify crate
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
notify-file-watcher
Cross-platform filesystem notification library for Rust. Provides native event-driven watching on macOS (FSEvents), Linux (inotify), and Windows (ReadDirectoryChangesW).
Key Types
RecommendedWatcher
Platform-specific watcher selected automatically. Use recommended_watcher() for convenience:
use notify::{recommended_watcher, RecursiveMode, Watcher};
let mut watcher = recommended_watcher(|res| {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
})?;
watcher.watch(Path::new("/path"), RecursiveMode::Recursive)?;
Event
Contains:
-
kind: EventKind- What happened -
paths: Vec<PathBuf>- Affected paths -
attrs: EventAttributes- Optional metadata (tracker ID, info, source)
EventKind
Top-level event classification:
-
Any- Catch-all for unknown events -
Access(AccessKind)- File opened/closed/executed (not all platforms) -
Create(CreateKind)- File/folder created -
Modify(ModifyKind)- Content/name/metadata changed -
Remove(RemoveKind)- File/folder deleted -
Other- Meta-events about the watch itself
RecursiveMode
-
Recursive- Watch directory and all subdirectories -
NonRecursive- Watch only the specified directory
Usage in script-kit-gpui
Watcher Architecture
Script-kit uses three specialized watchers in src/watcher.rs:
-
ConfigWatcher - Watches
~/.scriptkit/kit/config.ts- NonRecursive on parent directory
- Filters events to target filename only
-
ThemeWatcher - Watches
~/.scriptkit/kit/theme.json- Same pattern as ConfigWatcher
-
ScriptWatcher - Watches
~/.scriptkit/kit/main/scripts/and/extensions/- Recursive watching
- Filters by extension (.ts, .js, .md)
- Dynamic watch addition when extensions directory appears
Callback Pattern
Events are forwarded to a control channel for processing:
let (control_tx, control_rx) = channel::<ControlMsg>();
let mut watcher = recommended_watcher({
let tx = control_tx.clone();
move |res: notify::Result<notify::Event>| {
let _ = tx.send(ControlMsg::Notify(res));
}
})?;
Filtering Events
Filter irrelevant events early:
fn is_relevant_event_kind(kind: ¬ify::EventKind) -> bool {
!matches!(kind, notify::EventKind::Access(_)) // Ignore access events
}
fn is_relevant_script_file(path: &Path) -> bool {
// Skip hidden files
if path.file_name().and_then(|n| n.to_str()).map(|n| n.starts_with('.')).unwrap_or(false) {
return false;
}
// Check extensions
matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts") | Some("js") | Some("md"))
}
Debouncing
Manual Debouncing (script-kit approach)
Trailing-edge debounce with per-file tracking:
const DEBOUNCE_MS: u64 = 500;
// Per-file pending events with timestamps
let mut pending: HashMap<PathBuf, (Event, Instant)> = HashMap::new();
// On event: update timestamp
pending.insert(path.clone(), (event, Instant::now()));
// On timeout: flush expired events
let now = Instant::now();
pending.retain(|path, (ev, timestamp)| {
if now.duration_since(*timestamp) >= Duration::from_millis(DEBOUNCE_MS) {
emit_event(ev);
false // Remove from pending
} else {
true // Keep waiting
}
});
Built-in Debouncer (notify-debouncer-mini/full)
For simpler use cases:
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::time::Duration;
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;
debouncer.watcher().watch(path, RecursiveMode::Recursive)?;
notify-debouncer-mini: Lightweight, simple events notify-debouncer-full: Includes file ID tracking, renames, cache
Event Types
Create Events
notify::EventKind::Create(CreateKind::File) // New file
notify::EventKind::Create(CreateKind::Folder) // New directory
notify::EventKind::Create(CreateKind::Any) // Unknown creation
Modify Events
notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)) // File content changed
notify::EventKind::Modify(ModifyKind::Data(DataChange::Size)) // Size changed
notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) // Renamed from
notify::EventKind::Modify(ModifyKind::Name(RenameMode::To)) // Renamed to
notify::EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)) // Attributes changed
Remove Events
notify::EventKind::Remove(RemoveKind::File) // File deleted
notify::EventKind::Remove(RemoveKind::Folder) // Directory deleted
Access Events (platform-specific)
notify::EventKind::Access(AccessKind::Open(AccessMode::Read)) // File opened
notify::EventKind::Access(AccessKind::Close(AccessMode::Write)) // File closed after write
Recursive vs Non-recursive
Use Recursive When:
- Watching a scripts/plugins directory with subdirectories
- Unknown directory structure depth
- Need to catch all nested changes
watcher.watch(&scripts_path, RecursiveMode::Recursive)?;
Use NonRecursive When:
- Watching a single config file (watch parent directory)
- Watching for directory creation in a known location
- Performance-critical with known shallow structure
// Watch parent to catch config file changes including atomic saves
let watch_path = config_path.parent().unwrap();
watcher.watch(watch_path, RecursiveMode::NonRecursive)?;
// Then filter to target file
let touches_target = event.paths.iter()
.any(|p| p.file_name() == Some(target_name));
Error Handling
Supervisor Pattern with Exponential Backoff
Script-kit wraps watchers in a supervisor loop:
const INITIAL_BACKOFF_MS: u64 = 100;
const MAX_BACKOFF_MS: u64 = 30_000;
const MAX_NOTIFY_ERRORS: u32 = 10;
fn compute_backoff(attempt: u32) -> Duration {
let delay_ms = INITIAL_BACKOFF_MS.saturating_mul(2u64.saturating_pow(attempt));
Duration::from_millis(delay_ms.min(MAX_BACKOFF_MS))
}
// In supervisor loop
loop {
match watch_loop(...) {
Ok(()) => break, // Normal shutdown
Err(e) => {
let backoff = compute_backoff(attempt);
sleep(backoff);
attempt += 1;
}
}
}
Consecutive Error Threshold
Restart watcher after too many consecutive errors:
if consecutive_errors >= MAX_NOTIFY_ERRORS {
return Err(notify::Error::generic("Too many consecutive notify errors"));
}
Storm Coalescing
When many events arrive quickly (git operations, bulk copy), collapse to a single reload:
const STORM_THRESHOLD: usize = 200;
if pending.len() >= STORM_THRESHOLD {
pending.clear();
full_reload_at = Some(Instant::now()); // Emit single FullReload after debounce
}
Atomic Save Handling
Editors save files differently:
- Truncate: Modify existing file (simple modify event)
- Atomic: Write to temp file, rename/move over original (Delete + Create sequence)
Merge delete+create into FileChanged:
fn merge_script_event(pending: &mut HashMap<PathBuf, (Event, Instant)>, path: &PathBuf, new_event: Event, timestamp: Instant) {
if let Some((existing, _)) = pending.get(path) {
let merged = match (existing, &new_event) {
(FileDeleted(_), FileCreated(_)) | (FileCreated(_), FileDeleted(_)) => {
Some(FileChanged(path.clone()))
}
_ => None,
};
if let Some(merged) = merged {
pending.insert(path.clone(), (merged, timestamp));
return;
}
}
pending.insert(path.clone(), (new_event, timestamp));
}
Anti-patterns
1. Blocking in Callback
Bad: The callback runs on notify's internal thread
// DON'T: blocks notify's event processing
let watcher = recommended_watcher(|res| {
heavy_processing(res); // Blocks!
})?;
Good: Forward to channel, process elsewhere
let watcher = recommended_watcher(move |res| {
let _ = tx.send(res); // Non-blocking
})?;
2. Ignoring Platform Differences
Different platforms emit different event sequences. Always handle:
-
EventKind::AnyandEventKind::Other - Missing sub-kind information (e.g.,
ModifyKind::Any)
3. Not Handling Watcher Lifetime
The watcher stops when dropped. Keep it alive:
// DON'T
{
let watcher = recommended_watcher(...)?;
} // Watcher dropped, watching stops!
// DO
struct MyApp {
_watcher: Box<dyn Watcher>, // Keep alive
}
4. Watching Non-existent Paths
Notify errors if path doesn't exist. Check first or handle the error:
if path.exists() {
watcher.watch(&path, RecursiveMode::Recursive)?;
}
5. Not Debouncing
Raw events can be noisy. Always debounce for UI/reload triggers.
6. Watching Network Filesystems
NFS, SMB, and WSL paths may not emit events. Use PollWatcher as fallback:
use notify::poll::PollWatcher;
let watcher = PollWatcher::new(callback, Config::default()
.with_poll_interval(Duration::from_secs(2)))?;
Platform-Specific Notes
macOS (FSEvents)
- Requires watching parent directory for single-file changes
- May not emit events for files you don't own
- Use
macos_kqueuefeature for kqueue backend instead
Linux (inotify)
- Has per-user watch limits (
/proc/sys/fs/inotify/max_user_watches) - Increase with:
sysctl fs.inotify.max_user_watches=524288 - Not 100% reliable for large directories
Windows (ReadDirectoryChangesW)
- Generally reliable
- May have issues with network paths
Quick Reference
use notify::{
recommended_watcher,
RecursiveMode,
Result,
Watcher,
Event,
EventKind,
event::{CreateKind, ModifyKind, RemoveKind, AccessKind},
};
use std::path::Path;
use std::sync::mpsc::channel;
fn watch_files() -> Result<()> {
let (tx, rx) = channel();
let mut watcher = recommended_watcher(move |res: Result<Event>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
})?;
watcher.watch(Path::new("./src"), RecursiveMode::Recursive)?;
for event in rx {
match event.kind {
EventKind::Create(_) => println!("Created: {:?}", event.paths),
EventKind::Modify(_) => println!("Modified: {:?}", event.paths),
EventKind::Remove(_) => println!("Removed: {:?}", event.paths),
_ => {}
}
}
Ok(())
}
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-02-03
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!