use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
pub(crate) use crate::utils::watch::is_write_event;
use anyhow::{Context, Result};
use notify::RecursiveMode;
use crate::utils::static_files::path_has_common_skip_dir;
use crate::utils::watch::run_debounced_watch;
pub(crate) fn is_watch_candidate(
root: &Path,
preview_output: &Path,
config_path: Option<&Path>,
path: &Path,
) -> bool {
if path == preview_output
|| is_typst_temporary_output(preview_output, path)
|| is_editor_backup(path)
{
return false;
}
let rel = path.strip_prefix(root).unwrap_or(path);
if let Some(config_path) = config_path {
if path == config_path {
return true;
}
}
if rel.components().next().is_none() || path_has_common_skip_dir(rel) {
return false;
}
if rel.starts_with("editors/vscode/bin")
|| rel.starts_with("editors/vscode/dist")
|| rel.starts_with("editors/vscode/media")
|| rel.starts_with("editors/vscode/node_modules")
|| rel.starts_with("editors/vscode/out")
{
return false;
}
true
}
fn is_typst_temporary_output(preview_output: &Path, path: &Path) -> bool {
if path.parent() != preview_output.parent() {
return false;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
path.extension().is_none() && name.starts_with("XX")
}
fn is_editor_backup(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with('~'))
}
pub(crate) fn watch_root(
root: &Path,
preview_output: &Path,
config_path: Option<&Path>,
stop: Arc<AtomicBool>,
on_change: impl FnMut(&[PathBuf]),
) -> Result<()> {
let root = root
.canonicalize()
.with_context(|| format!("watch root not found: {}", root.display()))?;
let preview_output = preview_output.to_path_buf();
let config_path = config_path.map(|path| {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
path.canonicalize().unwrap_or(path)
});
let excluded = preview_output
.canonicalize()
.unwrap_or_else(|_| preview_output.clone());
run_debounced_watch(
&[(root.clone(), RecursiveMode::Recursive)],
Duration::from_millis(300),
Duration::from_millis(200),
stop,
is_write_event,
|path| is_watch_candidate(&root, &excluded, config_path.as_deref(), &path).then_some(path),
on_change,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn watcher_ignores_generated_and_repository_paths() {
let root = Path::new("/tmp/project");
let output = root.join("paper.pdf");
assert!(!is_watch_candidate(root, &output, None, &output));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("XXfo4zsx")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("paper.typ~")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join(".git/index")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("target/debug/x")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("node_modules/pkg/index.js")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join(".venv/bin/python")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("editors/vscode/out/extension.js")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("editors/vscode/media/pdfjs/build/pdf.min.mjs")
));
assert!(!is_watch_candidate(
root,
&output,
None,
&root.join("editors/vscode/dist/calepin.vsix")
));
assert!(is_watch_candidate(
root,
&output,
None,
&root.join("paper.typ")
));
assert!(is_watch_candidate(
root,
&output,
None,
&root.join("data/input.csv")
));
}
#[test]
fn watcher_accepts_write_like_events() {
assert!(is_write_event(¬ify::EventKind::Create(
notify::event::CreateKind::File,
)));
assert!(is_write_event(¬ify::EventKind::Modify(
notify::event::ModifyKind::Data(notify::event::DataChange::Content),
)));
assert!(is_write_event(¬ify::EventKind::Modify(
notify::event::ModifyKind::Name(notify::event::RenameMode::Both),
)));
assert!(!is_write_event(¬ify::EventKind::Access(
notify::event::AccessKind::Read,
)));
}
}