use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use crate::session::{claude_projects_dir, SessionInfo};
#[derive(Debug, Clone)]
pub struct ProjectSessionEvent {
pub project_id: String,
pub session: SessionInfo,
pub is_new: bool,
}
#[derive(Debug)]
pub enum AllProjectsWatcherError {
NoProjectsDir,
NotifyError(notify::Error),
}
impl std::fmt::Display for AllProjectsWatcherError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AllProjectsWatcherError::NoProjectsDir => {
write!(f, "Could not determine Claude projects directory")
}
AllProjectsWatcherError::NotifyError(e) => write!(f, "File watcher error: {}", e),
}
}
}
impl std::error::Error for AllProjectsWatcherError {}
impl From<notify::Error> for AllProjectsWatcherError {
fn from(e: notify::Error) -> Self {
AllProjectsWatcherError::NotifyError(e)
}
}
pub struct AllProjectsWatcher {
_watcher: RecommendedWatcher,
projects_dir: PathBuf,
event_rx: Receiver<ProjectSessionEvent>,
}
impl AllProjectsWatcher {
pub fn new() -> Result<Self, AllProjectsWatcherError> {
let projects_dir =
claude_projects_dir().ok_or(AllProjectsWatcherError::NoProjectsDir)?;
if !projects_dir.exists() {
std::fs::create_dir_all(&projects_dir).ok();
}
let (event_tx, event_rx) = mpsc::channel();
let watcher = Self::create_watcher(projects_dir.clone(), event_tx)?;
Ok(Self {
_watcher: watcher,
projects_dir,
event_rx,
})
}
fn create_watcher(
projects_dir: PathBuf,
event_tx: Sender<ProjectSessionEvent>,
) -> Result<RecommendedWatcher, AllProjectsWatcherError> {
let dir_for_closure = projects_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(&projects_dir, RecursiveMode::Recursive)?;
Ok(watcher)
}
fn handle_notify_event(
projects_dir: &Path,
event: &Event,
event_tx: &Sender<ProjectSessionEvent>,
) {
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((project_id, session_info)) =
Self::parse_path(path, projects_dir)
{
if session_info.session_id.starts_with("agent-") {
continue;
}
let session_event = ProjectSessionEvent {
project_id,
session: session_info,
is_new: is_create,
};
let _ = event_tx.send(session_event);
}
}
}
fn parse_path(path: &Path, projects_dir: &Path) -> Option<(String, SessionInfo)> {
let rel_path = path.strip_prefix(projects_dir).ok()?;
let mut components = rel_path.components();
let project_id = components.next()?.as_os_str().to_str()?.to_string();
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())?;
let session_info = SessionInfo {
session_id,
transcript_path: path.to_path_buf(),
modified_at,
size_bytes,
};
Some((project_id, session_info))
}
pub fn projects_dir(&self) -> &Path {
&self.projects_dir
}
pub fn recv(&self) -> Option<ProjectSessionEvent> {
self.event_rx.recv().ok()
}
pub fn try_recv(&self) -> Option<ProjectSessionEvent> {
self.event_rx.try_recv().ok()
}
pub fn iter(&self) -> impl Iterator<Item = ProjectSessionEvent> + '_ {
std::iter::from_fn(|| self.recv())
}
}