vtcode_config/loader/
watch.rs1use anyhow::{Context, Result, anyhow};
2use hashbrown::HashMap;
3use notify::{RecommendedWatcher, RecursiveMode, Watcher};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant, SystemTime};
7
8use super::{ConfigManager, VTCodeConfig};
9
10pub struct ConfigWatcher {
13 workspace_path: PathBuf,
14 last_load_time: Arc<Mutex<Instant>>,
15 current_config: Arc<Mutex<Option<VTCodeConfig>>>,
16 watcher: Option<RecommendedWatcher>,
17 debounce_duration: Duration,
18 last_event_time: Arc<Mutex<Instant>>,
19}
20
21impl ConfigWatcher {
22 #[must_use]
24 pub fn new(workspace_path: PathBuf) -> Self {
25 Self {
26 workspace_path,
27 last_load_time: Arc::new(Mutex::new(Instant::now())),
28 current_config: Arc::new(Mutex::new(None)),
29 watcher: None,
30 debounce_duration: Duration::from_millis(500),
31 last_event_time: Arc::new(Mutex::new(Instant::now())),
32 }
33 }
34
35 pub async fn initialize(&mut self) -> Result<()> {
42 self.load_config().await?;
43
44 let last_event_time = Arc::clone(&self.last_event_time);
45 let debounce_duration = self.debounce_duration;
46
47 let mut watcher = RecommendedWatcher::new(
48 move |res: Result<notify::Event, notify::Error>| {
49 if let Ok(event) = res {
50 let now = Instant::now();
51 if let Ok(mut last_time) = last_event_time.lock()
52 && now.duration_since(*last_time) >= debounce_duration
53 {
54 *last_time = now;
55 if is_relevant_config_event(&event) {
56 tracing::debug!("Config file changed: {:?}", event);
57 }
58 }
59 }
60 },
61 notify::Config::default(),
62 )?;
63
64 for path in get_config_file_paths(&self.workspace_path) {
65 if let Some(parent) = path.parent() {
66 watcher
67 .watch(parent, RecursiveMode::NonRecursive)
68 .with_context(|| format!("Failed to watch config directory: {parent:?}"))?;
69 }
70 }
71
72 self.watcher = Some(watcher);
73 Ok(())
74 }
75
76 pub async fn load_config(&mut self) -> Result<()> {
82 let config = ConfigManager::load_from_workspace(&self.workspace_path)
83 .ok()
84 .map(|manager| manager.config().clone());
85
86 let mut current = self
87 .current_config
88 .lock()
89 .map_err(|_| anyhow!("config watcher state lock poisoned"))?;
90 *current = config;
91 drop(current);
92
93 let mut last_load = self
94 .last_load_time
95 .lock()
96 .map_err(|_| anyhow!("config watcher timestamp lock poisoned"))?;
97 *last_load = Instant::now();
98
99 Ok(())
100 }
101
102 pub async fn get_config(&mut self) -> Option<VTCodeConfig> {
104 if self.should_reload().await
105 && let Err(err) = self.load_config().await
106 {
107 tracing::warn!("Failed to reload config: {}", err);
108 }
109
110 self.current_config
111 .lock()
112 .ok()
113 .and_then(|current| current.clone())
114 }
115
116 async fn should_reload(&self) -> bool {
117 let Ok(last_event) = self.last_event_time.lock() else {
118 return false;
119 };
120 let Ok(last_load) = self.last_load_time.lock() else {
121 return false;
122 };
123
124 *last_event > *last_load
125 }
126
127 #[must_use]
129 pub async fn last_load_time(&self) -> Instant {
130 self.last_load_time
131 .lock()
132 .map(|instant| *instant)
133 .unwrap_or_else(|_| Instant::now())
134 }
135}
136
137pub struct SimpleConfigWatcher {
139 workspace_path: PathBuf,
140 last_load_time: Instant,
141 last_check_time: Instant,
142 check_interval: Duration,
143 last_modified_times: HashMap<PathBuf, SystemTime>,
144 debounce_duration: Duration,
145 last_reload_attempt: Option<Instant>,
146}
147
148impl SimpleConfigWatcher {
149 #[must_use]
150 pub fn new(workspace_path: PathBuf) -> Self {
151 Self {
152 workspace_path,
153 last_load_time: Instant::now(),
154 last_check_time: Instant::now(),
155 check_interval: Duration::from_secs(10),
156 last_modified_times: HashMap::new(),
157 debounce_duration: Duration::from_millis(1000),
158 last_reload_attempt: None,
159 }
160 }
161
162 pub fn should_reload(&mut self) -> bool {
163 let now = Instant::now();
164
165 if now.duration_since(self.last_check_time) < self.check_interval {
166 return false;
167 }
168 self.last_check_time = now;
169
170 let mut saw_change = false;
171 for target in get_config_file_paths(&self.workspace_path) {
172 if !target.exists() {
173 continue;
174 }
175
176 if let Some(current_modified) = latest_modified(&target) {
177 let previous = self.last_modified_times.get(&target).copied();
178 self.last_modified_times.insert(target, current_modified);
179 if let Some(last_modified) = previous
180 && current_modified > last_modified
181 {
182 saw_change = true;
183 }
184 }
185 }
186
187 if !saw_change {
188 return false;
189 }
190
191 if let Some(last_attempt) = self.last_reload_attempt
192 && now.duration_since(last_attempt) < self.debounce_duration
193 {
194 return false;
195 }
196 self.last_reload_attempt = Some(now);
197 true
198 }
199
200 pub fn load_config(&mut self) -> Option<VTCodeConfig> {
201 let config = ConfigManager::load_from_workspace(&self.workspace_path)
202 .ok()
203 .map(|manager| manager.config().clone());
204
205 self.last_load_time = Instant::now();
206 self.last_modified_times.clear();
207 for target in get_config_file_paths(&self.workspace_path) {
208 if let Some(modified) = latest_modified(&target) {
209 self.last_modified_times.insert(target, modified);
210 }
211 }
212
213 config
214 }
215
216 pub fn set_check_interval(&mut self, seconds: u64) {
217 self.check_interval = Duration::from_secs(seconds);
218 }
219
220 pub fn set_debounce_duration(&mut self, millis: u64) {
221 self.debounce_duration = Duration::from_millis(millis);
222 }
223}
224
225fn is_relevant_config_event(event: ¬ify::Event) -> bool {
226 let relevant_files = ["vtcode.toml", "theme.toml"];
227
228 match &event.kind {
229 notify::EventKind::Create(_)
230 | notify::EventKind::Modify(_)
231 | notify::EventKind::Remove(_) => event.paths.iter().any(|path| {
232 path.file_name()
233 .and_then(|file_name| file_name.to_str())
234 .is_some_and(|file_name| relevant_files.contains(&file_name))
235 }),
236 _ => false,
237 }
238}
239
240fn get_config_file_paths(workspace_path: &Path) -> Vec<PathBuf> {
241 vec![
242 workspace_path.join("vtcode.toml"),
243 workspace_path.join(".vtcode").join("theme.toml"),
244 ]
245}
246
247fn latest_modified(path: &Path) -> Option<SystemTime> {
248 std::fs::metadata(path).ok()?.modified().ok()
249}