use std::sync::Arc;
use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::broadcast;
use crate::designs::Designs;
use crate::document::{Document, Entry};
use crate::index::Index;
use std::path::{Path, PathBuf};
pub fn start(
entry: Arc<Entry>,
index: Arc<Index>,
designs: Designs,
changes: broadcast::Sender<String>,
) -> Result<RecommendedWatcher> {
let served = entry.document().served.clone();
let root = served.clone();
let listeners = Listeners {
served,
entry,
index,
designs,
changes,
};
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let Ok(event) = event else { return };
if event.kind.is_access() {
return;
}
listeners.changed(&event.paths);
})?;
watcher.watch(&root, RecursiveMode::Recursive)?;
Ok(watcher)
}
struct Listeners {
served: PathBuf,
entry: Arc<Entry>,
index: Arc<Index>,
designs: Designs,
changes: broadcast::Sender<String>,
}
impl Listeners {
fn changed(&self, paths: &[PathBuf]) {
let Listeners {
served,
entry,
index,
designs,
changes,
} = self;
let mut refresh = false;
for path in paths {
let path = path.canonicalize().unwrap_or_else(|_| path.clone());
let Ok(relative) = path.strip_prefix(served) else {
continue;
};
if let Ok(thread) = relative.strip_prefix(".devpage/comments") {
if thread.extension().is_some_and(|ext| ext == "yml")
&& let Some(page) = thread.parent()
{
let _ = changes.send(format!("comments /{}", page.display()));
}
continue;
}
if relative.starts_with(".devpage") {
continue;
}
designs.changed(&path);
index.changed(&path);
if path == entry.file() {
entry.reload();
refresh = true;
} else if !index.ignored(&path, path.is_dir()) && refreshes(entry, index, &path) {
refresh = true;
}
}
if refresh {
let _ = changes.send("change".into());
}
}
}
fn refreshes(entry: &Entry, index: &Index, path: &Path) -> bool {
if index.kind(path).is_some() || is_widget_file(entry, index, path) {
return true;
}
let Some(site) = index.site_for(path) else {
return true;
};
let document = entry.document();
if site == document.path() {
return document.watches(path);
}
match Document::load(&site, &document.served) {
Ok(site) => site.watches(path),
Err(_) => true,
}
}
fn is_widget_file(entry: &Entry, index: &Index, path: &Path) -> bool {
if !path
.extension()
.is_some_and(|ext| ext == "ts" || ext == "tsx")
{
return false;
}
let document = entry.document();
let in_folder = |folder: Option<&Path>| {
folder.is_some_and(|folder| crate::widgets::is_widget_file(path, folder))
};
in_folder(document.widgets.as_deref())
|| index.documents().iter().any(|file| {
Document::load(file, &document.served)
.is_ok_and(|other| in_folder(other.widgets.as_deref()))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn served(name: &str, site: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("devpage-watch-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("docs/build")).unwrap();
std::fs::write(dir.join("docs/README.md"), "# Home").unwrap();
std::fs::write(dir.join("docs/devpage.yml"), site).unwrap();
std::fs::write(dir.join("docs/.gitignore"), "build/\n").unwrap();
dir.canonicalize().unwrap()
}
async fn after(dir: &Path, touch: impl FnOnce()) -> Vec<String> {
let document = Arc::new(Document::load(&dir.join("docs"), dir).unwrap());
let index = Arc::new(Index::build(dir));
let (changes, mut received) = broadcast::channel(64);
let designs = Designs::new(dir.to_path_buf());
let _watcher = start(Arc::new(Entry::new(document)), index, designs, changes).unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
touch();
let mut messages = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_millis(1500);
while let Ok(Ok(message)) = tokio::time::timeout_at(deadline, received.recv()).await {
messages.push(message);
}
messages
}
#[test]
fn a_changed_file_reaches_the_design_store() {
let dir = served("designs", "kind: site\ntitle: T\nroot: README.md\n");
std::fs::create_dir_all(dir.join(".devpage")).unwrap();
let valid = "<Frame width={10} height={10} />\n";
let broken = "<Frame width={10} height={10} lable=\"x\" />\n";
std::fs::write(dir.join("docs/screen.design"), valid).unwrap();
std::fs::write(dir.join(".devpage/screen.design"), valid).unwrap();
let document = Arc::new(Document::load(&dir.join("docs"), &dir).unwrap());
let designs = Designs::new(dir.clone());
let listeners = Listeners {
served: dir.clone(),
entry: Arc::new(Entry::new(document)),
index: Arc::new(Index::build(&dir)),
designs: designs.clone(),
changes: broadcast::channel(64).0,
};
assert!(designs.resolve("docs/screen.design").is_ok());
assert!(designs.resolve(".devpage/screen.design").is_ok());
std::fs::write(dir.join("docs/screen.design"), broken).unwrap();
std::fs::write(dir.join(".devpage/screen.design"), broken).unwrap();
assert!(designs.resolve("docs/screen.design").is_ok());
listeners.changed(&[
dir.join("docs/screen.design"),
dir.join(".devpage/screen.design"),
]);
assert!(designs.resolve("docs/screen.design").is_err());
assert!(designs.resolve(".devpage/screen.design").is_ok());
}
#[tokio::test]
async fn a_saved_page_refreshes_open_pages() {
let dir = served("page", "kind: site\ntitle: T\nroot: README.md\n");
let messages = after(&dir, || {
std::fs::write(dir.join("docs/README.md"), "# Changed").unwrap()
})
.await;
assert!(messages.iter().any(|m| m == "change"), "{messages:?}");
}
#[tokio::test]
async fn ignored_files_and_files_outside_watch_do_not_refresh() {
let dir = served(
"quiet",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"**/*.md\"]\n",
);
let messages = after(&dir, || {
std::fs::write(dir.join("docs/build/out.md"), "x").unwrap(); std::fs::write(dir.join("docs/logo.svg"), "<svg/>").unwrap(); })
.await;
assert!(!messages.iter().any(|m| m == "change"), "{messages:?}");
}
#[tokio::test]
async fn a_comment_thread_always_reaches_its_page() {
let dir = served(
"comments",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"**/*.md\"]\n",
);
let messages = after(&dir, || {
let thread = dir.join(".devpage/comments/docs/README.md");
std::fs::create_dir_all(&thread).unwrap();
std::fs::write(thread.join("20261025T000000Z-abcd.yml"), "thread: []\n").unwrap();
})
.await;
assert!(
messages.iter().any(|m| m == "comments /docs/README.md"),
"{messages:?}"
);
assert!(!messages.iter().any(|m| m == "change"), "{messages:?}");
}
#[tokio::test]
async fn the_watch_list_follows_the_entry_file() {
let dir = served(
"relist",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"**/*.md\"]\n",
);
let document = Arc::new(Document::load(&dir.join("docs"), &dir).unwrap());
let entry = Arc::new(Entry::new(document));
let index = Arc::new(Index::build(&dir));
let (changes, mut received) = broadcast::channel(64);
let designs = Designs::new(dir.clone());
let _watcher = start(entry.clone(), index, designs, changes).unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(
dir.join("docs/devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"**/*.svg\"]\n",
)
.unwrap();
let drain = |received: &mut broadcast::Receiver<String>| {
let mut messages = Vec::new();
while let Ok(message) = received.try_recv() {
messages.push(message);
}
messages
};
tokio::time::sleep(Duration::from_millis(800)).await;
let messages = drain(&mut received);
assert!(messages.iter().any(|m| m == "change"), "{messages:?}");
std::fs::write(dir.join("docs/README.md"), "# Changed").unwrap();
tokio::time::sleep(Duration::from_millis(800)).await;
let messages = drain(&mut received);
assert!(!messages.iter().any(|m| m == "change"), "{messages:?}");
std::fs::write(dir.join("docs/logo.svg"), "<svg/>").unwrap();
tokio::time::sleep(Duration::from_millis(800)).await;
let messages = drain(&mut received);
assert!(messages.iter().any(|m| m == "change"), "{messages:?}");
}
#[tokio::test]
async fn a_sites_watch_list_narrows_only_its_own_folder() {
let dir = served("other", "kind: site\ntitle: T\nroot: README.md\n");
std::fs::create_dir_all(dir.join("other")).unwrap();
std::fs::write(dir.join("other/index.md"), "# Other").unwrap();
std::fs::write(
dir.join("other/devpage.yml"),
"kind: site\ntitle: O\nroot: index.md\nwatch: [\"*.txt\"]\n",
)
.unwrap();
let quiet = after(&dir, || {
std::fs::write(dir.join("other/index.md"), "# Changed").unwrap()
})
.await;
assert!(!quiet.iter().any(|m| m == "change"), "{quiet:?}");
let loud = after(&dir, || {
std::fs::write(dir.join("other/notes.txt"), "x").unwrap()
})
.await;
assert!(loud.iter().any(|m| m == "change"), "{loud:?}");
let entry = after(&dir, || {
std::fs::write(dir.join("docs/README.md"), "# Changed").unwrap()
})
.await;
assert!(entry.iter().any(|m| m == "change"), "{entry:?}");
std::fs::create_dir_all(dir.join("plan")).unwrap();
let loose = after(&dir, || {
std::fs::write(dir.join("plan/design.md"), "# Plan").unwrap()
})
.await;
assert!(loose.iter().any(|m| m == "change"), "{loose:?}");
}
#[tokio::test]
async fn the_entrys_watch_list_does_not_quiet_another_folder() {
let dir = served(
"entry-watch",
"kind: site\ntitle: T\nroot: README.md\nwatch: [\"*.svg\"]\n",
);
std::fs::create_dir_all(dir.join("examples")).unwrap();
let messages = after(&dir, || {
std::fs::write(dir.join("examples/design.md"), "# Design").unwrap()
})
.await;
assert!(messages.iter().any(|m| m == "change"), "{messages:?}");
}
#[tokio::test]
async fn widget_files_refresh_whatever_watch_says() {
let dir = served(
"widgets",
"kind: site\ntitle: T\nroot: README.md\nwidgets: widgets\nwatch: [\"**/*.md\"]\n",
);
std::fs::create_dir_all(dir.join("docs/widgets")).unwrap();
std::fs::create_dir_all(dir.join("docs/other")).unwrap();
let quiet = after(&dir, || {
std::fs::write(dir.join("docs/other/app.tsx"), "x").unwrap(); })
.await;
assert!(!quiet.iter().any(|m| m == "change"), "{quiet:?}");
let loud = after(&dir, || {
std::fs::write(dir.join("docs/widgets/format.ts"), "export const f = 1;").unwrap()
})
.await;
assert!(loud.iter().any(|m| m == "change"), "{loud:?}");
}
}