# File Watching `oxide_engine::watch` watches directories on disk and emits **debounced**, **deduplicated** change events. It is the Stage-6 foundation for the engine's live-reload story: - **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a texture or model edited in an external tool reappears in the running editor without restarting. - **Stage 10** — recompile and hot-swap game scripts using the same event stream and the same debounce logic. - **Editor** — drives the Project panel's "files appeared / disappeared" refresh. The same module covers all of these because the hard part — "wait until the filesystem stops twitching, then emit one event per path" — is identical in every case. ## Why debounce Filesystem events are noisy: - Most editors save in several syscalls (write the file, rename a temp file into place, chmod) — that is one logical change but several events. - Recursive watches re-fire while a directory's children are being created. - Backends collapse or split events differently across Linux, macOS, and Windows. If the engine reloaded on every raw event, one save could re-parse a model many times over. The watcher gathers raw events into a **pending set** keyed by path, then emits one event per path once that path has been **quiet** for a configurable window. ## Architecture (two layers) The module is intentionally split so most behavior is unit-testable without touching real files. ### `Debouncer` — the pure core A plain struct that takes `Instant`s from the caller. Tests drive it through a deterministic timeline; no `sleep`, no flaky timing dependence on the OS event queue. ```rust use std::time::{Duration, Instant}; use oxide_engine::watch::{ChangeKind, Debouncer}; let mut d = Debouncer::new(Duration::from_millis(100)); let t0 = Instant::now(); d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0); d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0 + Duration::from_millis(20)); // Still hot — nothing fires. assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty()); // After 100 ms of quiet, one event fires for the path. let ready = d.drain_ready(t0 + Duration::from_millis(130)); assert_eq!(ready.len(), 1); ``` Coalescing rules (chosen to match what a reloader downstream cares about): | Earlier kind | Newer kind | Emitted kind | |--------------|-----------|--------------| | `Created` | `Modified` | `Created` | | `Removed` | `Modified` | `Created` (file came back) | | anything | `Removed` | `Removed` | | anything else | newer | newer | ### `FileWatcher` — the real-world wrapper Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the debouncer with real time and forwards settled events through an `mpsc` channel. ```rust,no_run use std::time::Duration; use oxide_engine::watch::FileWatcher; let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?; watcher.watch("path/to/project/assets")?; // In the editor's per-frame tick, drain whatever has settled: while let Ok(event) = events.try_recv() { println!("{:?} at {}", event.kind, event.path.display()); } # Ok::<(), oxide_engine::watch::WatchError>(()) ``` `FileWatcher` watches recursively. Dropping it stops the worker thread and disconnects the receiver — no manual cleanup. The quiet window is a knob: too short and you get repeated events from one save; too long and the editor feels laggy. The default Stage-6 wiring uses ~150 ms. ## Asset reload `reload_changed_assets` is the wiring between the watcher and the [asset server](assets.md). For each `Created` or `Modified` event it calls `AssetServer::reload_path`, which re-runs the loader for every cached asset at that path and updates the existing handle **in place** — gameplay code holding the handle sees the new contents on its next read. ```rust,no_run use std::time::Duration; use oxide_engine::asset::AssetServer; use oxide_engine::watch::{reload_changed_assets, FileWatcher}; let assets = AssetServer::new(); let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?; watcher.watch("path/to/project/assets")?; // Per frame: let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect(); let reloaded = reload_changed_assets(&assets, batch); if reloaded > 0 { log::info!("hot-reloaded {} asset(s)", reloaded); } # Ok::<(), oxide_engine::watch::WatchError>(()) ``` `AssetServer::reload_path` is type-erased on purpose. The cache records, per entry, a function pointer that re-runs the loader for that entry's concrete type, so the watcher can react to a disk change without knowing every asset type at compile time. Paths that are not currently cached return zero work; the next `load` picks up the fresh contents anyway. `Removed` events do **not** invalidate cached handles — gameplay code may want the last-loaded copy to keep working. ## What this groundwork enables | Stage | Builds on | |-------|-----------| | 6 | Editor live-reload of assets; Project panel refresh | | 7 | Watch input-binding config for changes during a session | | 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) | | 11 | WGSL shader hot-reload | ## Testing strategy - **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast, deterministic, and they cover the coalescing rules exhaustively. - **One tolerant smoke test** writes to a temp dir and polls for an event with a generous deadline (seconds, not milliseconds). On containerized CI without a usable event backend the test prints `SKIP:` and passes — the unit tests already prove the logic is correct, this only checks the OS wiring is connected. [`watch`]: ../engine/src/watch.rs [`Debouncer`]: ../engine/src/watch.rs [`FileWatcher`]: ../engine/src/watch.rs