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.
36pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
37    let path = PathBuf::from(file);
38    with_state(|map| {
39        match map.get(&path) {
40            None => true, // No recorded changes — idle
41            Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
42        }
43    })
44}
45
46/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
47///
48/// Returns `true` if idle was reached, `false` if timed out.
49///
50/// Poll interval: 100ms (responsive without busy-waiting).
51pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
52    let start = Instant::now();
53    let timeout = std::time::Duration::from_millis(timeout_ms);
54    let poll_interval = std::time::Duration::from_millis(100);
55
56    loop {
57        if is_idle(file, debounce_ms) {
58            return true;
59        }
60        if start.elapsed() >= timeout {
61            return false;
62        }
63        std::thread::sleep(poll_interval);
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn idle_when_no_changes() {
73        assert!(is_idle("/tmp/test-no-changes.md", 1500));
74    }
75
76    #[test]
77    fn not_idle_after_change() {
78        document_changed("/tmp/test-just-changed.md");
79        assert!(!is_idle("/tmp/test-just-changed.md", 1500));
80    }
81
82    #[test]
83    fn idle_after_debounce_period() {
84        document_changed("/tmp/test-debounce.md");
85        // Use a very short debounce for testing
86        std::thread::sleep(std::time::Duration::from_millis(50));
87        assert!(is_idle("/tmp/test-debounce.md", 10));
88    }
89
90    #[test]
91    fn await_idle_returns_immediately_when_idle() {
92        let start = Instant::now();
93        assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
94        assert!(start.elapsed().as_millis() < 200);
95    }
96
97    #[test]
98    fn await_idle_waits_for_settle() {
99        document_changed("/tmp/test-await-settle.md");
100        let start = Instant::now();
101        assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
102        assert!(start.elapsed().as_millis() >= 200);
103    }
104}