use eframe::egui;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::time::{Duration, Instant};
const POLL: Duration = Duration::from_millis(120);
const QUIET: Duration = Duration::from_millis(180);
const MAX_DELAY: Duration = Duration::from_secs(1);
pub(crate) struct DirectoryMonitor {
requests: SyncSender<Vec<PathBuf>>,
events: Receiver<PathBuf>,
watching: Vec<PathBuf>,
pub dirty: BTreeSet<PathBuf>,
}
impl DirectoryMonitor {
pub fn new(ctx: egui::Context) -> Self {
let (requests, receiver) = mpsc::sync_channel::<Vec<PathBuf>>(1);
let (sender, events) = mpsc::sync_channel(2);
std::thread::spawn(move || {
let mut watches: Vec<Watch> = Vec::new();
loop {
match receiver.recv_timeout(POLL) {
Ok(paths) => {
watches = paths.into_iter().take(2).map(Watch::new).collect();
for watch in &mut watches {
watch.changed(Instant::now());
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
let now = Instant::now();
for watch in &mut watches {
if watch.poll() {
watch.changed(now);
}
if watch.ready(now) && sender.try_send(watch.path.clone()).is_ok() {
watch.pending = None;
ctx.request_repaint();
}
}
}
});
Self {
requests,
events,
watching: Vec::new(),
dirty: BTreeSet::new(),
}
}
pub fn update(&mut self, mut paths: Vec<PathBuf>) {
paths.truncate(2);
paths.sort();
paths.dedup();
if paths != self.watching && self.requests.try_send(paths.clone()).is_ok() {
self.watching = paths.clone();
}
self.dirty.retain(|path| paths.contains(path));
for path in self.events.try_iter().take(2) {
if paths.contains(&path) {
self.dirty.insert(path);
}
}
}
}
struct Watch {
path: PathBuf,
#[cfg(target_os = "linux")]
fd: Option<rustix::fd::OwnedFd>,
#[cfg(target_os = "linux")]
retry_at: Instant,
pending: Option<(Instant, Instant)>,
}
impl Watch {
fn new(path: PathBuf) -> Self {
#[cfg(target_os = "linux")]
let fd = {
use rustix::fs::inotify::{self, CreateFlags, WatchFlags as W};
inotify::init(CreateFlags::NONBLOCK | CreateFlags::CLOEXEC)
.ok()
.and_then(|fd| {
inotify::add_watch(
&fd,
&path,
W::CREATE
| W::DELETE
| W::MOVED_FROM
| W::MOVED_TO
| W::CLOSE_WRITE
| W::MODIFY
| W::ATTRIB
| W::DELETE_SELF
| W::MOVE_SELF
| W::ONLYDIR,
)
.ok()
.map(|_| fd)
})
};
Self {
path,
#[cfg(target_os = "linux")]
fd,
#[cfg(target_os = "linux")]
retry_at: Instant::now() + Duration::from_secs(5),
pending: None,
}
}
fn poll(&mut self) -> bool {
#[cfg(target_os = "linux")]
if self.fd.is_none() && Instant::now() >= self.retry_at {
self.fd = Self::new(self.path.clone()).fd;
self.retry_at = Instant::now() + Duration::from_secs(5);
return self.fd.is_some();
}
#[cfg(target_os = "linux")]
if let Some(fd) = &self.fd {
let mut buffer = [0_u8; 8192];
let mut changed = false;
for _ in 0..8 {
match rustix::io::read(fd, &mut buffer) {
Ok(0) => break,
Ok(_) => changed = true,
Err(_) => break,
}
}
if changed {
self.fd = Self::new(self.path.clone()).fd;
self.retry_at = Instant::now() + Duration::from_secs(5);
}
return changed;
}
false
}
fn changed(&mut self, now: Instant) {
let first = self.pending.map_or(now, |(first, _)| first);
self.pending = Some((first, now));
}
fn ready(&self, now: Instant) -> bool {
self.pending.is_some_and(|(first, last)| {
now.duration_since(last) >= QUIET || now.duration_since(first) >= MAX_DELAY
})
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use std::fs;
#[test]
fn actual_file_changes_invalidate_without_read_feedback() {
let root = std::env::temp_dir().join(format!("guth-watch-{}", uuid_for_test()));
fs::create_dir(&root).unwrap();
let mut watch = Watch::new(root.clone());
assert!(watch.fd.is_some(), "inotify watch could not be created");
assert!(!watch.poll());
let path = root.join("document.txt");
fs::write(&path, "created").unwrap();
assert!(watch.poll());
fs::read(&path).unwrap();
fs::read_dir(&root).unwrap().for_each(drop);
assert!(!watch.poll(), "scanning must not trigger another refresh");
fs::write(&path, "edited").unwrap();
assert!(watch.poll());
let renamed = root.join("renamed.txt");
fs::rename(&path, &renamed).unwrap();
assert!(watch.poll());
fs::remove_file(renamed).unwrap();
assert!(watch.poll());
fs::remove_dir(root).unwrap();
}
fn uuid_for_test() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}
#[test]
fn replacing_a_directory_rebinds_to_the_displayed_path() {
let root = std::env::temp_dir().join(format!("guth-watch-replace-{}", uuid_for_test()));
fs::create_dir(&root).unwrap();
let path = root.join("visible");
let moved = root.join("moved");
fs::create_dir(&path).unwrap();
let mut watch = Watch::new(path.clone());
fs::rename(&path, &moved).unwrap();
fs::create_dir(&path).unwrap();
assert!(watch.poll(), "replacement must invalidate the old listing");
let old_file = moved.join("old.txt");
fs::write(&old_file, "not visible").unwrap();
assert!(!watch.poll(), "must not follow the old directory inode");
let new_file = path.join("new.txt");
fs::write(&new_file, "visible").unwrap();
assert!(watch.poll(), "must watch the replacement directory");
fs::remove_file(old_file).unwrap();
fs::remove_file(new_file).unwrap();
fs::remove_dir(moved).unwrap();
fs::remove_dir(path).unwrap();
fs::remove_dir(root).unwrap();
}
#[test]
fn bursts_debounce_but_continuous_writes_cannot_starve_refresh() {
let mut watch = Watch::new(PathBuf::from("/nonexistent/guth-watch-test"));
let start = Instant::now();
watch.changed(start);
assert!(!watch.ready(start + Duration::from_millis(100)));
watch.changed(start + Duration::from_millis(150));
assert!(!watch.ready(start + Duration::from_millis(200)));
assert!(watch.ready(start + Duration::from_millis(400)));
watch.changed(start + Duration::from_millis(950));
assert!(watch.ready(start + Duration::from_secs(1)));
}
#[test]
fn closed_tab_notifications_are_discarded_and_two_panes_are_bounded() {
let (requests, _receiver) = mpsc::sync_channel(1);
let (sender, events) = mpsc::sync_channel(2);
let mut monitor = DirectoryMonitor {
requests,
events,
watching: Vec::new(),
dirty: BTreeSet::new(),
};
sender.send(PathBuf::from("/closed-tab")).unwrap();
sender.send(PathBuf::from("/pane-two")).unwrap();
monitor.update(vec![PathBuf::from("/pane-one"), PathBuf::from("/pane-two")]);
assert_eq!(monitor.dirty, BTreeSet::from([PathBuf::from("/pane-two")]));
monitor.update(vec![PathBuf::from("/pane-one")]);
assert!(monitor.dirty.is_empty());
}
}