guth 0.2.38

Native Rust desktop file manager for fast, bounded local file workflows.
Documentation
//! Bounded Linux directory notifications for the two visible browser panes.
//! Watch setup and polling run off the UI thread; remote filesystems that do
//! not provide inotify notifications still support the manual Refresh action.

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();
                        // Close the scan-before-watch race: once subscriptions
                        // exist, refresh once to include any intervening edit.
                        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();
        }
        // A late event for a closed tab must never refresh another folder.
        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 {
            // Only invalidation is needed, including queue overflow. Read a
            // bounded 64 KiB per tick, ignoring ACCESS/OPEN to avoid scan loops.
            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 {
                // A watched directory may have been renamed or replaced.
                // Rebind to the displayed path, never keep following an inode
                // that has moved somewhere the user is no longer browsing.
                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());
    }
}