use std::path::Path; use std::sync::Arc; use std::time::Duration; use notify::{RecursiveMode, Watcher}; use notify_debouncer_full::{new_debouncer, DebouncedEvent}; use tokio::sync::mpsc; use crate::config; use crate::websocket::{WsMessage, WsState}; /// Start the file watcher in a background task pub async fn start_watcher(ws_state: Arc) -> Result<(), String> { let (tx, mut rx) = mpsc::channel::>(100); // Create debouncer with 500ms debounce time let debouncer = new_debouncer( Duration::from_millis(500), None, move |result: Result, Vec>| { if let Ok(events) = result { let _ = tx.blocking_send(events); } }, ) .map_err(|e| format!("Failed to create file watcher: {}", e))?; // Watch the data directory let data_path = config::data_dir(); if !data_path.exists() { return Err(format!( "Data directory does not exist: {}", data_path.display() )); } // We need to keep the debouncer alive, so we'll store it let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer)); { let mut d = debouncer.lock().await; d.watcher() .watch(data_path, RecursiveMode::Recursive) .map_err(|e| format!("Failed to watch directory: {}", e))?; } tracing::info!("File watcher started for: {}", data_path.display()); // Spawn task to process file events let ws_state_clone = ws_state.clone(); tokio::spawn(async move { // Keep debouncer alive let _debouncer = debouncer; while let Some(events) = rx.recv().await { for event in events { process_event(&event, &ws_state_clone); } } }); Ok(()) } use std::collections::HashMap; /// Track recent saves to avoid notifying about our own changes use std::sync::Mutex; use std::time::Instant; lazy_static::lazy_static! { static ref RECENT_SAVES: Mutex> = Mutex::new(HashMap::new()); } /// Mark a file as recently saved by us (call this before saving) pub fn mark_file_saved(path: &str) { if let Ok(mut saves) = RECENT_SAVES.lock() { saves.insert(path.to_string(), Instant::now()); } } /// Process a single debounced file event fn process_event(event: &DebouncedEvent, ws_state: &WsState) { use notify::EventKind; // Only process markdown files let paths: Vec<_> = event .paths .iter() .filter(|p| { p.extension() .and_then(|e| e.to_str()) .map(|e| e == "md") .unwrap_or(false) }) .collect(); if paths.is_empty() { return; } // Skip temporary files (used for atomic writes) if paths.iter().any(|p| { p.file_name() .and_then(|n| n.to_str()) .map(|n| n.starts_with('.') && n.ends_with(".tmp")) .unwrap_or(false) }) { return; } // Skip archive and .git directories if paths.iter().any(|p| { let s = p.to_string_lossy(); s.contains("archive") || s.contains(".git") }) { return; } let path_str = normalize_path(&paths[0]); // Check if this was a recent save by us (within last 2 seconds) if let Ok(mut saves) = RECENT_SAVES.lock() { // Clean up old entries saves.retain(|_, t| t.elapsed().as_secs() < 5); if let Some(saved_at) = saves.get(&path_str) { if saved_at.elapsed().as_secs() < 2 { return; // Skip - this was our own save } } } let msg = match &event.kind { EventKind::Create(_) => { tracing::info!("External file created: {}", path_str); Some(WsMessage::FileCreated { path: path_str }) } EventKind::Modify(_) => { tracing::info!("External file modified: {}", path_str); Some(WsMessage::FileModified { path: path_str }) } EventKind::Remove(_) => { tracing::info!("External file deleted: {}", path_str); Some(WsMessage::FileDeleted { path: path_str }) } _ => None, }; if let Some(msg) = msg { ws_state.broadcast(msg); } } /// Normalize path for client consumption fn normalize_path(path: &Path) -> String { let path_str = path.to_string_lossy(); // Find "data" in the path and strip everything before and including it if let Some(idx) = path_str.find("data") { let stripped = &path_str[idx + 5..]; // Skip "data" + separator return stripped .replace('\\', "/") .trim_start_matches('/') .to_string(); } path_str.replace('\\', "/") }