use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::mpsc::{Receiver, channel};
use bevy::prelude::*;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use crate::xaml::XamlRegistry;
#[derive(Resource)]
pub struct NoesisHotReload {
inner: Mutex<Inner>,
}
struct Inner {
watcher: RecommendedWatcher,
rx: Receiver<notify::Result<notify::Event>>,
files: HashMap<PathBuf, String>,
dirs: HashSet<PathBuf>,
}
impl NoesisHotReload {
fn new() -> Option<Self> {
let (tx, rx) = channel();
let watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(err) => {
warn!("NoesisHotReload: could not create filesystem watcher: {err}");
return None;
}
};
Some(Self {
inner: Mutex::new(Inner {
watcher,
rx,
files: HashMap::new(),
dirs: HashSet::new(),
}),
})
}
pub fn watch(&self, uri: impl Into<String>, fs_path: impl AsRef<Path>) {
let uri = uri.into();
let raw = fs_path.as_ref();
let canonical = match std::fs::canonicalize(raw) {
Ok(p) => p,
Err(err) => {
warn!(
"NoesisHotReload: cannot watch {} ({uri}): {err}",
raw.display()
);
return;
}
};
let Some(parent) = canonical.parent().map(Path::to_path_buf) else {
warn!(
"NoesisHotReload: {} has no parent directory; not watched",
canonical.display()
);
return;
};
let mut inner = self.inner.lock().expect("NoesisHotReload mutex poisoned");
if inner.dirs.insert(parent.clone()) {
if let Err(err) = inner.watcher.watch(&parent, RecursiveMode::NonRecursive) {
warn!(
"NoesisHotReload: failed to watch {}: {err}",
parent.display()
);
inner.dirs.remove(&parent);
return;
}
}
inner.files.insert(canonical, uri);
}
}
#[allow(clippy::needless_pass_by_value)] pub fn poll_hot_reload(hot: Option<Res<NoesisHotReload>>, mut registry: ResMut<XamlRegistry>) {
let Some(hot) = hot else {
return;
};
let dirty: HashMap<String, PathBuf> = {
let guard = hot.inner.lock().expect("NoesisHotReload mutex poisoned");
let mut dirty = HashMap::new();
while let Ok(event) = guard.rx.try_recv() {
let event = match event {
Ok(ev) => ev,
Err(err) => {
warn!("NoesisHotReload: watch error: {err}");
continue;
}
};
if !is_reload_trigger(&event.kind) {
continue;
}
for path in &event.paths {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
if let Some(uri) = guard.files.get(&key) {
dirty.insert(uri.clone(), key);
}
}
}
dirty
};
for (uri, path) in dirty {
match std::fs::read(&path) {
Ok(bytes) => {
info!(
"NoesisHotReload: reloading {uri} from {} ({} bytes)",
path.display(),
bytes.len()
);
registry.insert(uri, std::sync::Arc::new(bytes));
}
Err(err) => warn!(
"NoesisHotReload: re-read of {} failed: {err}",
path.display()
),
}
}
}
fn is_reload_trigger(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Any
)
}
pub struct NoesisHotReloadPlugin;
impl Plugin for NoesisHotReloadPlugin {
fn build(&self, app: &mut App) {
if let Some(hot) = NoesisHotReload::new() {
app.insert_resource(hot);
}
app.add_systems(Update, poll_hot_reload);
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind};
#[test]
fn reload_triggers_on_data_changes_only() {
assert!(is_reload_trigger(&EventKind::Modify(ModifyKind::Any)));
assert!(is_reload_trigger(&EventKind::Create(CreateKind::File)));
assert!(is_reload_trigger(&EventKind::Any));
assert!(!is_reload_trigger(&EventKind::Access(
notify::event::AccessKind::Read
)));
assert!(!is_reload_trigger(&EventKind::Remove(
notify::event::RemoveKind::File
)));
}
#[test]
fn watch_records_canonical_path_and_dedupes_dirs() {
let dir = std::env::temp_dir().join(format!("noesis_hot_reload_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.xaml");
let b = dir.join("b.xaml");
std::fs::write(&a, b"<Grid/>").unwrap();
std::fs::write(&b, b"<Grid/>").unwrap();
let hot = NoesisHotReload::new().expect("watcher");
hot.watch("a.xaml", &a);
hot.watch("b.xaml", &b);
let inner = hot.inner.lock().unwrap();
assert_eq!(inner.files.len(), 2);
assert_eq!(
inner.files.get(&std::fs::canonicalize(&a).unwrap()),
Some(&"a.xaml".to_string())
);
assert_eq!(inner.dirs.len(), 1);
drop(inner);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_file_is_skipped_not_watched() {
let hot = NoesisHotReload::new().expect("watcher");
hot.watch("ghost.xaml", "/definitely/not/here/ghost.xaml");
let inner = hot.inner.lock().unwrap();
assert!(inner.files.is_empty());
assert!(inner.dirs.is_empty());
}
#[test]
fn edit_on_disk_updates_registry() {
let dir = std::env::temp_dir().join(format!("noesis_hot_e2e_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("live.xaml");
std::fs::write(&file, b"<Grid Background=\"Red\"/>").unwrap();
let mut app = App::new();
app.init_resource::<XamlRegistry>();
let hot = NoesisHotReload::new().expect("watcher");
app.world_mut().resource_mut::<XamlRegistry>().insert(
"live.xaml",
std::sync::Arc::new(b"<Grid Background=\"Red\"/>".to_vec()),
);
hot.watch("live.xaml", &file);
app.insert_resource(hot);
app.add_systems(Update, poll_hot_reload);
std::fs::write(&file, b"<Grid Background=\"Blue\"/>").unwrap();
let mut reloaded = false;
for _ in 0..50 {
app.update();
let registry = app.world().resource::<XamlRegistry>();
if registry.get("live.xaml").map(|b| b.as_slice())
== Some(b"<Grid Background=\"Blue\"/>")
{
reloaded = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
std::fs::remove_dir_all(&dir).ok();
assert!(reloaded, "registry never picked up the on-disk edit");
}
}