Skip to main content

agent_doc/
debounce.rs

1//! Typing debounce for editor plugins.
2//!
3//! Provides a centralized debounce mechanism via FFI so all editor plugins
4//! (JetBrains, VS Code, Neovim, Zed) share identical timing logic.
5//!
6//! Plugins call `document_changed()` on every document edit, and
7//! `await_idle()` before submitting to wait for typing to settle.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Mutex;
12use std::time::Instant;
13
14/// Global state: last change timestamp per file.
15static LAST_CHANGE: Mutex<Option<HashMap<PathBuf, Instant>>> = Mutex::new(None);
16
17fn with_state<R>(f: impl FnOnce(&mut HashMap<PathBuf, Instant>) -> R) -> R {
18    let mut guard = LAST_CHANGE.lock().unwrap();
19    let map = guard.get_or_insert_with(HashMap::new);
20    f(map)
21}
22
23/// Record a document change event for the given file.
24///
25/// Called by editor plugins on every document modification.
26pub fn document_changed(file: &str) {
27    let path = PathBuf::from(file);
28    with_state(|map| {
29        map.insert(path, Instant::now());
30    });
31}
32
33/// Check if the document has been idle (no changes) for at least `debounce_ms`.
34///
35/// Returns `true` if no recent changes (safe to run), `false` if still active.
36/// For untracked files (no `document_changed` ever called), returns `true` —
37/// the blocking `await_idle` relies on this to not wait forever.
38pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
39    let path = PathBuf::from(file);
40    with_state(|map| {
41        match map.get(&path) {
42            None => true, // No recorded changes — idle
43            Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
44        }
45    })
46}
47
48/// Check if the document has been tracked (at least one `document_changed` call recorded).
49///
50/// Used by non-blocking probes to distinguish "never tracked" from "tracked and idle".
51/// If a file is untracked, the probe should be conservative (assume not idle).
52pub fn is_tracked(file: &str) -> bool {
53    let path = PathBuf::from(file);
54    with_state(|map| map.contains_key(&path))
55}
56
57/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
58///
59/// Returns `true` if idle was reached, `false` if timed out.
60///
61/// Poll interval: 100ms (responsive without busy-waiting).
62pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
63    let start = Instant::now();
64    let timeout = std::time::Duration::from_millis(timeout_ms);
65    let poll_interval = std::time::Duration::from_millis(100);
66
67    loop {
68        if is_idle(file, debounce_ms) {
69            return true;
70        }
71        if start.elapsed() >= timeout {
72            return false;
73        }
74        std::thread::sleep(poll_interval);
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn idle_when_no_changes() {
84        assert!(is_idle("/tmp/test-no-changes.md", 1500));
85    }
86
87    #[test]
88    fn not_idle_after_change() {
89        document_changed("/tmp/test-just-changed.md");
90        assert!(!is_idle("/tmp/test-just-changed.md", 1500));
91    }
92
93    #[test]
94    fn idle_after_debounce_period() {
95        document_changed("/tmp/test-debounce.md");
96        // Use a very short debounce for testing
97        std::thread::sleep(std::time::Duration::from_millis(50));
98        assert!(is_idle("/tmp/test-debounce.md", 10));
99    }
100
101    #[test]
102    fn await_idle_returns_immediately_when_idle() {
103        let start = Instant::now();
104        assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
105        assert!(start.elapsed().as_millis() < 200);
106    }
107
108    #[test]
109    fn await_idle_waits_for_settle() {
110        document_changed("/tmp/test-await-settle.md");
111        let start = Instant::now();
112        assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
113        assert!(start.elapsed().as_millis() >= 200);
114    }
115}