use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use crate::session::{discover_sessions_in_dir, get_project_dir, SessionInfo};
#[derive(Debug, Clone)]
pub enum SessionEvent {
Created(SessionInfo),
Modified(SessionInfo),
}
#[derive(Debug)]
pub enum WatcherError {
NoProjectDir(PathBuf),
NotifyError(notify::Error),
DiscoveryError(String),
}
impl std::fmt::Display for WatcherError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WatcherError::NoProjectDir(p) => {
write!(f, "No Claude project directory found for {:?}", p)
}
WatcherError::NotifyError(e) => write!(f, "File watcher error: {}", e),
WatcherError::DiscoveryError(e) => write!(f, "Session discovery error: {}", e),
}
}
}
impl std::error::Error for WatcherError {}
impl From<notify::Error> for WatcherError {
fn from(e: notify::Error) -> Self {
WatcherError::NotifyError(e)
}
}
pub struct SessionWatcher {
_watcher: RecommendedWatcher,
project_dir: PathBuf,
event_rx: Receiver<SessionEvent>,
}
impl SessionWatcher {
pub fn new(project_path: &Path) -> Result<Self, WatcherError> {
let project_dir = get_project_dir(project_path)
.ok_or_else(|| WatcherError::NoProjectDir(project_path.to_path_buf()))?;
let (event_tx, event_rx) = mpsc::channel();
Self::emit_existing_sessions(&project_dir, &event_tx)?;
let watcher = Self::create_watcher(project_dir.clone(), event_tx)?;
Ok(Self {
_watcher: watcher,
project_dir,
event_rx,
})
}
fn emit_existing_sessions(
project_dir: &Path,
event_tx: &Sender<SessionEvent>,
) -> Result<(), WatcherError> {
let sessions =
discover_sessions_in_dir(project_dir).map_err(WatcherError::DiscoveryError)?;
for session in sessions {
let _ = event_tx.send(SessionEvent::Created(session));
}
Ok(())
}
fn create_watcher(
project_dir: PathBuf,
event_tx: Sender<SessionEvent>,
) -> Result<RecommendedWatcher, WatcherError> {
let dir_for_closure = project_dir.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
Self::handle_notify_event(&dir_for_closure, &event, &event_tx);
}
})?;
watcher.watch(&project_dir, RecursiveMode::NonRecursive)?;
Ok(watcher)
}
fn handle_notify_event(
project_dir: &Path,
event: &Event,
event_tx: &Sender<SessionEvent>,
) {
let is_create = matches!(event.kind, EventKind::Create(_));
let is_modify = matches!(event.kind, EventKind::Modify(_));
if !is_create && !is_modify {
return;
}
for path in &event.paths {
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Some(session_info) = Self::path_to_session_info(path, project_dir) {
let session_event = if is_create {
SessionEvent::Created(session_info)
} else {
SessionEvent::Modified(session_info)
};
let _ = event_tx.send(session_event);
}
}
}
fn path_to_session_info(path: &Path, _project_dir: &Path) -> Option<SessionInfo> {
let session_id = path.file_stem()?.to_str()?.to_string();
let metadata = std::fs::metadata(path).ok()?;
let modified = metadata.modified().ok()?;
let size_bytes = metadata.len();
let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
let modified_at =
chrono::DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos())?;
Some(SessionInfo {
session_id,
transcript_path: path.to_path_buf(),
modified_at,
size_bytes,
})
}
pub fn project_dir(&self) -> &Path {
&self.project_dir
}
pub fn recv(&self) -> Option<SessionEvent> {
self.event_rx.recv().ok()
}
pub fn try_recv(&self) -> Option<SessionEvent> {
self.event_rx.try_recv().ok()
}
pub fn iter(&self) -> impl Iterator<Item = SessionEvent> + '_ {
std::iter::from_fn(|| self.recv())
}
}