use axum::{
Router,
extract::{Query, State},
response::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
},
routing::get,
};
use futures_util::stream::{self, Stream};
use notify::event::CreateKind;
use notify::{Event as NotifyEvent, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Deserialize;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::Duration;
use tokio::sync::broadcast;
use walkdir::WalkDir;
use crate::AppState;
use super::files::{resolve_project_root, resolve_session_base};
const DEBOUNCE_WINDOW_MS: u64 = 100;
const MAX_PENDING: usize = 256;
const RESYNC_EVENT: &str = r#"{"kind":"resync","path":""}"#;
pub fn routes() -> Router<AppState> {
Router::new().route("/files/watch", get(watch_files))
}
#[derive(Deserialize)]
struct WatchQuery {
session: Option<String>,
workspace: Option<String>,
}
async fn watch_files(
State(state): State<AppState>,
Query(q): Query<WatchQuery>,
) -> impl IntoResponse {
let watch_path = if let Some(sid) = q.session.as_deref() {
resolve_session_base(&state, sid).await.map(|(cwd, _)| PathBuf::from(cwd))
} else {
let wid = q.workspace.as_deref().unwrap_or("default");
resolve_project_root(&state, wid).await.map(PathBuf::from)
};
let watch_path = match watch_path {
Some(p) if p.exists() => p,
_ => {
let empty: Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> =
Box::pin(stream::empty());
return Sse::new(empty).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)));
}
};
let (tx, mut rx) = broadcast::channel::<String>(64);
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
let (new_dir_tx, new_dir_rx) = std::sync::mpsc::channel::<PathBuf>();
let watch_dir = watch_path.clone();
let watch_dir_for_cb = watch_dir.clone();
tokio::task::spawn_blocking(move || {
let mut watcher = match RecommendedWatcher::new(
move |res: Result<NotifyEvent, notify::Error>| {
let event = match res {
Ok(e) => e,
Err(_) => return,
};
if let EventKind::Create(CreateKind::Folder) = event.kind {
for path in &event.paths {
if !is_ignored_dir(path, &watch_dir_for_cb) {
let _ = new_dir_tx.send(path.clone());
}
}
}
let changes = notify_event_to_changes(&event, &watch_dir_for_cb);
for change in changes {
let _ = tx.send(change);
}
},
notify::Config::default(),
) {
Ok(w) => w,
Err(_) => return,
};
if watcher.watch(&watch_dir, RecursiveMode::NonRecursive).is_err() {
return;
}
for dir in collect_watch_dirs(&watch_dir) {
if watcher.watch(&dir, RecursiveMode::NonRecursive).is_err() {
break;
}
}
loop {
while let Ok(dir) = new_dir_rx.try_recv() {
if watcher.watch(&dir, RecursiveMode::NonRecursive).is_ok() {
for sub in collect_watch_dirs(&dir) {
if watcher.watch(&sub, RecursiveMode::NonRecursive).is_err() {
break;
}
}
}
}
if let Err(std::sync::mpsc::RecvTimeoutError::Disconnected) =
shutdown_rx.recv_timeout(Duration::from_millis(250))
{
break;
}
}
});
let sse_stream = async_stream::stream! {
let _shutdown_guard = shutdown_tx;
let mut pending: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut flush_timer: Option<Pin<Box<tokio::time::Sleep>>> = None;
loop {
tokio::select! {
recv = rx.recv() => match recv {
Ok(data) => {
let over = merge_pending(&mut pending, &mut seen, data, MAX_PENDING);
if over {
yield Ok(Event::default().event("change").data(RESYNC_EVENT));
pending.clear();
seen.clear();
flush_timer = None;
} else if flush_timer.is_none() {
flush_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(DEBOUNCE_WINDOW_MS))));
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {
yield Ok(Event::default().event("change").data(RESYNC_EVENT));
pending.clear();
seen.clear();
flush_timer = None;
}
Err(broadcast::error::RecvError::Closed) => break,
},
_ = async {
if let Some(t) = &mut flush_timer {
t.await;
}
}, if flush_timer.is_some() => {
for change in pending.drain(..) {
yield Ok(Event::default().event("change").data(change));
}
seen.clear();
flush_timer = None;
}
}
}
};
let boxed: Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> = Box::pin(sse_stream);
Sse::new(boxed).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("ping"))
}
fn merge_pending(
pending: &mut Vec<String>,
seen: &mut std::collections::HashSet<String>,
change: String,
max_pending: usize,
) -> bool {
if seen.insert(change.clone()) {
pending.push(change);
}
pending.len() >= max_pending
}
fn notify_event_to_changes(event: &NotifyEvent, base_dir: &std::path::Path) -> Vec<String> {
let mut changes = Vec::new();
for path in &event.paths {
let rel_path = path.strip_prefix(base_dir).unwrap_or(path).to_string_lossy().to_string();
if should_ignore(&rel_path) {
continue;
}
let kind_str = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "delete",
_ => continue, };
let json = format!(r#"{{"kind":"{}","path":"{}"}}"#, kind_str, escape_json(&rel_path));
changes.push(json);
}
if let EventKind::Modify(notify::event::ModifyKind::Name(_)) = event.kind
&& event.paths.len() == 2
{
let from = event.paths[0]
.strip_prefix(base_dir)
.unwrap_or(&event.paths[0])
.to_string_lossy()
.to_string();
let to = event.paths[1]
.strip_prefix(base_dir)
.unwrap_or(&event.paths[1])
.to_string_lossy()
.to_string();
if !should_ignore(&from) && !should_ignore(&to) {
changes.clear(); changes.push(format!(
r#"{{"kind":"rename","path":"{}","newPath":"{}"}}"#,
escape_json(&from),
escape_json(&to)
));
}
}
changes
}
fn should_ignore(rel_path: &str) -> bool {
for component in rel_path.split('/') {
if component.starts_with('.') && !component.is_empty() {
return true;
}
if component == "node_modules" || component == "target" || component == "__pycache__" {
return true;
}
}
false
}
fn is_ignored_dir(path: &Path, base: &Path) -> bool {
path.strip_prefix(base).ok().is_some_and(|rel| should_ignore(&rel.to_string_lossy()))
}
fn collect_watch_dirs(base: &Path) -> Vec<PathBuf> {
WalkDir::new(base)
.into_iter()
.filter_entry(|e| !is_ignored_dir(e.path(), base))
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect()
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!(
"files_watch_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn mkdir(p: &Path) {
std::fs::create_dir_all(p).unwrap();
}
#[test]
fn should_ignore_skips_hidden_and_build_dirs() {
assert!(should_ignore(".git"));
assert!(should_ignore(".git/config"));
assert!(should_ignore("src/.hidden/x"));
assert!(should_ignore("node_modules"));
assert!(should_ignore("a/node_modules/b"));
assert!(should_ignore("target"));
assert!(should_ignore("__pycache__/x.py"));
assert!(!should_ignore(""));
assert!(!should_ignore("src"));
assert!(!should_ignore("src/main.rs"));
}
#[test]
fn is_ignored_dir_base_itself_is_not_ignored() {
let base = PathBuf::from("/tmp/x");
assert!(!is_ignored_dir(&base, &base));
assert!(!is_ignored_dir(&base.join("src"), &base));
assert!(is_ignored_dir(&base.join("node_modules").join("pkg"), &base));
assert!(is_ignored_dir(&base.join(".git"), &base));
}
#[test]
fn merge_pending_dedups_same_kind_path() {
let mut pending: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let change = r#"{"kind":"modify","path":"a.txt"}"#.to_string();
assert!(!merge_pending(&mut pending, &mut seen, change.clone(), 10));
assert!(!merge_pending(&mut pending, &mut seen, change, 10));
assert_eq!(pending.len(), 1);
}
#[test]
fn merge_pending_keeps_distinct_kind_or_path() {
let mut pending: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let a = r#"{"kind":"create","path":"b.txt"}"#.to_string();
let b = r#"{"kind":"modify","path":"b.txt"}"#.to_string();
let c = r#"{"kind":"modify","path":"c.txt"}"#.to_string();
assert!(!merge_pending(&mut pending, &mut seen, a, 10));
assert!(!merge_pending(&mut pending, &mut seen, b, 10));
assert!(!merge_pending(&mut pending, &mut seen, c, 10));
assert_eq!(pending.len(), 3);
}
#[test]
fn merge_pending_flags_when_over_limit() {
let mut pending: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut over = false;
for i in 0..4 {
over = merge_pending(
&mut pending,
&mut seen,
format!(r#"{{"kind":"modify","path":"f{}.txt"}}"#, i),
3,
);
}
assert!(over);
assert_eq!(pending.len(), 4);
}
#[test]
fn collect_watch_dirs_skips_ignore_subtrees() {
let tmp = TempDir::new();
let base = tmp.0.clone();
mkdir(&base.join("src").join("components"));
mkdir(&base.join("node_modules").join("pkg"));
mkdir(&base.join(".git"));
mkdir(&base.join("target").join("debug"));
mkdir(&base.join("public"));
let dirs: Vec<String> = collect_watch_dirs(&base)
.iter()
.map(|p| p.strip_prefix(&base).unwrap().to_string_lossy().into_owned())
.collect();
assert!(dirs.contains(&"".into()), "根目录应注册: {dirs:?}");
assert!(dirs.contains(&"src".into()), "src 应注册: {dirs:?}");
assert!(dirs.contains(&"src/components".into()), "src/components 应注册: {dirs:?}");
assert!(dirs.contains(&"public".into()), "public 应注册: {dirs:?}");
assert!(
!dirs.iter().any(|d| d.starts_with("node_modules")),
"不应含 node_modules: {dirs:?}"
);
assert!(!dirs.iter().any(|d| d.starts_with(".git")), "不应含 .git: {dirs:?}");
assert!(!dirs.iter().any(|d| d.starts_with("target")), "不应含 target: {dirs:?}");
}
}