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 workspace_path = self.workspace_path.clone();
45 let last_event_time = Arc::clone(&self.last_event_time);
46 let debounce_duration = self.debounce_duration;
47
48 let mut watcher = RecommendedWatcher::new(
49 move |res: Result<notify::Event, notify::Error>| {
50 if let Ok(event) = res {
51 let now = Instant::now();
52 if let Ok(mut last_time) = last_event_time.lock()
53 && now.duration_since(*last_time) >= debounce_duration
54 {
55 *last_time = now;
56 if is_relevant_config_event(&event, &workspace_path) {
57 tracing::debug!("Config file changed: {:?}", event);
58 }
59 }
60 }
61 },
62 notify::Config::default(),
63 )?;
64
65 for path in get_config_file_paths(&self.workspace_path) {
66 if let Some(parent) = path.parent() {
67 watcher
68 .watch(parent, RecursiveMode::NonRecursive)
69 .with_context(|| format!("Failed to watch config directory: {:?}", parent))?;
70 }
71 }
72
73 self.watcher = Some(watcher);
74 Ok(())
75 }
76
77 pub async fn load_config(&mut self) -> Result<()> {
83 let config = ConfigManager::load_from_workspace(&self.workspace_path)
84 .ok()
85 .map(|manager| manager.config().clone());
86
87 let mut current = self
88 .current_config
89 .lock()
90 .map_err(|_| anyhow!("config watcher state lock poisoned"))?;
91 *current = config;
92 drop(current);
93
94 let mut last_load = self
95 .last_load_time
96 .lock()
97 .map_err(|_| anyhow!("config watcher timestamp lock poisoned"))?;
98 *last_load = Instant::now();
99
100 Ok(())
101 }
102
103 pub async fn get_config(&mut self) -> Option<VTCodeConfig> {
105 if self.should_reload().await
106 && let Err(err) = self.load_config().await
107 {
108 tracing::warn!("Failed to reload config: {}", err);
109 }
110
111 self.current_config
112 .lock()
113 .ok()
114 .and_then(|current| current.clone())
115 }
116
117 async fn should_reload(&self) -> bool {
118 let Ok(last_event) = self.last_event_time.lock() else {
119 return false;
120 };
121 let Ok(last_load) = self.last_load_time.lock() else {
122 return false;
123 };
124
125 *last_event > *last_load
126 }
127
128 #[must_use]
130 pub async fn last_load_time(&self) -> Instant {
131 self.last_load_time
132 .lock()
133 .map(|instant| *instant)
134 .unwrap_or_else(|_| Instant::now())
135 }
136}
137
138pub struct SimpleConfigWatcher {
140 workspace_path: PathBuf,
141 last_load_time: Instant,
142 last_check_time: Instant,
143 check_interval: Duration,
144 last_modified_times: HashMap<PathBuf, SystemTime>,
145 debounce_duration: Duration,
146 last_reload_attempt: Option<Instant>,
147}
148
149impl SimpleConfigWatcher {
150 #[must_use]
151 pub fn new(workspace_path: PathBuf) -> Self {
152 Self {
153 workspace_path,
154 last_load_time: Instant::now(),
155 last_check_time: Instant::now(),
156 check_interval: Duration::from_secs(10),
157 last_modified_times: HashMap::new(),
158 debounce_duration: Duration::from_millis(1000),
159 last_reload_attempt: None,
160 }
161 }
162
163 pub fn should_reload(&mut self) -> bool {
164 let now = Instant::now();
165
166 if now.duration_since(self.last_check_time) < self.check_interval {
167 return false;
168 }
169 self.last_check_time = now;
170
171 let mut saw_change = false;
172 for target in get_config_file_paths(&self.workspace_path) {
173 if !target.exists() {
174 continue;
175 }
176
177 if let Some(current_modified) = latest_modified(&target) {
178 let previous = self.last_modified_times.get(&target).copied();
179 self.last_modified_times.insert(target, current_modified);
180 if let Some(last_modified) = previous
181 && current_modified > last_modified
182 {
183 saw_change = true;
184 }
185 }
186 }
187
188 if !saw_change {
189 return false;
190 }
191
192 if let Some(last_attempt) = self.last_reload_attempt
193 && now.duration_since(last_attempt) < self.debounce_duration
194 {
195 return false;
196 }
197 self.last_reload_attempt = Some(now);
198 true
199 }
200
201 pub fn load_config(&mut self) -> Option<VTCodeConfig> {
202 let config = ConfigManager::load_from_workspace(&self.workspace_path)
203 .ok()
204 .map(|manager| manager.config().clone());
205
206 self.last_load_time = Instant::now();
207 self.last_modified_times.clear();
208 for target in get_config_file_paths(&self.workspace_path) {
209 if let Some(modified) = latest_modified(&target) {
210 self.last_modified_times.insert(target, modified);
211 }
212 }
213
214 config
215 }
216
217 pub fn set_check_interval(&mut self, seconds: u64) {
218 self.check_interval = Duration::from_secs(seconds);
219 }
220
221 pub fn set_debounce_duration(&mut self, millis: u64) {
222 self.debounce_duration = Duration::from_millis(millis);
223 }
224}
225
226fn is_relevant_config_event(event: ¬ify::Event, _workspace_path: &Path) -> bool {
227 let relevant_files = ["vtcode.toml", ".vtcode.toml", "config.toml", "theme.toml"];
228 let relevant_dirs = ["config", "theme"];
229
230 match &event.kind {
231 notify::EventKind::Create(_)
232 | notify::EventKind::Modify(_)
233 | notify::EventKind::Remove(_) => event.paths.iter().any(|path| {
234 path.file_name()
235 .and_then(|file_name| file_name.to_str())
236 .is_some_and(|file_name| {
237 relevant_files.contains(&file_name) || relevant_dirs.contains(&file_name)
238 })
239 }),
240 _ => false,
241 }
242}
243
244fn get_config_file_paths(workspace_path: &Path) -> Vec<PathBuf> {
245 let mut paths = vec![
246 workspace_path.join("vtcode.toml"),
247 workspace_path.join(".vtcode.toml"),
248 workspace_path.join(".vtcode").join("theme.toml"),
249 workspace_path.join("config"),
250 workspace_path.join("theme"),
251 workspace_path.join(".vtcode").join("config"),
252 workspace_path.join(".vtcode").join("theme"),
253 ];
254
255 if let Some(home_dir) = dirs::home_dir() {
256 paths.push(home_dir.join(".vtcode.toml"));
257 }
258
259 paths
260}
261
262fn latest_modified(path: &Path) -> Option<SystemTime> {
263 if path.is_file() {
264 return std::fs::metadata(path).ok()?.modified().ok();
265 }
266
267 if !path.is_dir() {
268 return None;
269 }
270
271 let mut newest = None;
272 for entry in walkdir::WalkDir::new(path)
273 .into_iter()
274 .filter_map(|item| item.ok())
275 {
276 if !entry.file_type().is_file() {
277 continue;
278 }
279 let Ok(metadata) = entry.metadata() else {
280 continue;
281 };
282 let Ok(modified) = metadata.modified() else {
283 continue;
284 };
285 newest = match newest {
286 Some(current) if modified <= current => Some(current),
287 _ => Some(modified),
288 };
289 }
290 newest
291}