Rust · 5422 bytes Raw Blame History
1 //! Filesystem watcher for `~/.claude/projects/`.
2 //!
3 //! Uses `notify-debouncer-full` to coalesce bursts of events (Claude
4 //! Code writes many JSONL lines in quick succession) into per-file
5 //! `SessionChange` notifications. Consumers receive them on a tokio
6 //! unbounded channel and forward them to the Tauri event bus.
7 //!
8 //! Only `.jsonl` files at `projects/<project>/<session>.jsonl` depth
9 //! are reported — everything else (memory dirs, UUID subdirs, stray
10 //! files) is filtered out.
11
12 use std::path::{Path, PathBuf};
13 use std::time::Duration;
14
15 use notify::{EventKind, RecursiveMode};
16 use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
17 use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
18
19 use crate::core::error::CoreResult;
20
21 /// What happened to a specific session file.
22 #[derive(Debug, Clone, PartialEq, Eq)]
23 pub enum SessionChange {
24 Added(PathBuf),
25 Modified(PathBuf),
26 Removed(PathBuf),
27 }
28
29 /// Handle that keeps the watcher alive. Drop it to stop watching.
30 pub struct WatcherHandle {
31 _debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
32 }
33
34 /// Spawn a debounced recursive watcher on `projects_root` and return
35 /// the handle plus the receiver side of a tokio unbounded channel that
36 /// will surface `SessionChange` events.
37 ///
38 /// The debounce interval is 250 ms — long enough to coalesce the burst
39 /// of appends Claude Code does when a new message lands, short enough
40 /// that the UI feels live.
41 pub fn spawn_watcher(
42 projects_root: &Path,
43 ) -> CoreResult<(WatcherHandle, UnboundedReceiver<SessionChange>)> {
44 let (tx, rx) = unbounded_channel::<SessionChange>();
45
46 let debouncer = new_debouncer(
47 Duration::from_millis(250),
48 None,
49 move |result: DebounceEventResult| match result {
50 Ok(events) => {
51 for ev in events {
52 for path in ev.paths.iter() {
53 if !is_session_file(path) {
54 continue;
55 }
56 let change = match ev.kind {
57 EventKind::Create(_) => SessionChange::Added(path.clone()),
58 EventKind::Modify(_) => SessionChange::Modified(path.clone()),
59 EventKind::Remove(_) => SessionChange::Removed(path.clone()),
60 _ => continue,
61 };
62 // If the receiver has been dropped we silently
63 // stop pumping; the debouncer's own worker
64 // thread will be torn down when the handle
65 // drops.
66 let _ = tx.send(change);
67 }
68 }
69 }
70 Err(errors) => {
71 for err in errors {
72 tracing::warn!(?err, "watcher error");
73 }
74 }
75 },
76 )
77 .map_err(|e| std::io::Error::other(e.to_string()))?;
78
79 // watch() on the underlying watcher needs a mutable ref; grab one
80 // from the debouncer.
81 let mut debouncer = debouncer;
82 debouncer
83 .watch(projects_root, RecursiveMode::Recursive)
84 .map_err(|e| std::io::Error::other(e.to_string()))?;
85
86 Ok((
87 WatcherHandle {
88 _debouncer: debouncer,
89 },
90 rx,
91 ))
92 }
93
94 /// Is this a `.jsonl` session file sitting directly inside a project
95 /// directory (depth = 2 from `projects/`)? Anything else is noise.
96 fn is_session_file(path: &Path) -> bool {
97 path.extension().and_then(|e| e.to_str()) == Some("jsonl")
98 }
99
100 #[cfg(test)]
101 mod tests {
102 use super::*;
103 use std::fs::File;
104 use std::io::Write;
105 use std::time::Duration;
106 use tempfile::tempdir;
107 use tokio::time::timeout;
108
109 #[tokio::test]
110 async fn notices_new_session_file() {
111 let tmp = tempdir().unwrap();
112 let projects = tmp.path().join("projects");
113 std::fs::create_dir_all(projects.join("-Users-me-repo")).unwrap();
114
115 let (_handle, mut rx) = spawn_watcher(&projects).unwrap();
116
117 // Create a new session file.
118 let new_path = projects.join("-Users-me-repo").join("new.jsonl");
119 let mut f = File::create(&new_path).unwrap();
120 writeln!(f, "{{}}").unwrap();
121 drop(f);
122
123 // Wait up to 3 s for the event (debouncer is 250 ms).
124 let event = timeout(Duration::from_secs(3), rx.recv()).await;
125 assert!(event.is_ok(), "watcher didn't fire within 3s");
126 let change = event.unwrap().unwrap();
127 match change {
128 SessionChange::Added(p) | SessionChange::Modified(p) => {
129 assert_eq!(p.file_name().unwrap(), "new.jsonl");
130 }
131 SessionChange::Removed(p) => panic!("unexpected Removed for new file: {p:?}"),
132 }
133 }
134
135 #[tokio::test]
136 async fn ignores_non_jsonl_files() {
137 let tmp = tempdir().unwrap();
138 let projects = tmp.path().join("projects");
139 std::fs::create_dir_all(projects.join("-Users-me-repo")).unwrap();
140
141 let (_handle, mut rx) = spawn_watcher(&projects).unwrap();
142
143 // Drop a .txt and verify no event surfaces within 600 ms.
144 File::create(projects.join("-Users-me-repo").join("note.txt")).unwrap();
145 let event = timeout(Duration::from_millis(600), rx.recv()).await;
146 assert!(event.is_err(), "got unexpected event: {event:?}");
147 }
148 }
149