1use std::collections::HashMap;
10use std::fs;
11use std::io::{BufRead, Seek, SeekFrom};
12use std::path::Path;
13
14use serde::Deserialize;
15
16use crate::events;
17use crate::tmux;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum WindowState {
23 Fresh,
25 Working,
27 Asking,
29 Waiting,
31 Idle,
33 Done,
35}
36
37#[derive(Deserialize)]
38struct EventEntry {
39 state: String,
40 #[allow(dead_code)]
41 cwd: String,
42 #[serde(default)]
44 pane_id: String,
45 ts: u64,
46}
47
48pub fn read_last_line(path: &Path) -> Option<String> {
53 let file = fs::File::open(path).ok()?;
54 let len = file.metadata().ok()?.len();
55 if len == 0 {
56 return None;
57 }
58
59 let tail_start = len.saturating_sub(1024);
61 let mut reader = std::io::BufReader::new(file);
62 reader.seek(SeekFrom::Start(tail_start)).ok()?;
63
64 if tail_start > 0 {
66 let mut discard = String::new();
67 let _ = reader.read_line(&mut discard);
68 }
69
70 let mut last = None;
71 let mut line = String::new();
72 loop {
73 line.clear();
74 match reader.read_line(&mut line) {
75 Ok(0) => break,
76 Ok(_) => {
77 let trimmed = line.trim();
78 if !trimmed.is_empty() {
79 last = Some(trimmed.to_string());
80 }
81 }
82 Err(_) => break,
83 }
84 }
85
86 last
87}
88
89pub fn load_latest_events(dir: &Path) -> HashMap<String, String> {
94 let entries = match fs::read_dir(dir) {
95 Ok(e) => e,
96 Err(_) => return HashMap::new(),
97 };
98
99 let mut best: HashMap<String, (String, u64)> = HashMap::new();
101 for entry in entries.flatten() {
102 let path = entry.path();
103 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
104 continue;
105 }
106 if let Some(line) = read_last_line(&path) {
107 if let Ok(event) = serde_json::from_str::<EventEntry>(&line) {
108 if !event.pane_id.is_empty() {
109 let replace = best
110 .get(&event.pane_id)
111 .is_none_or(|(_, prev_ts)| event.ts > *prev_ts);
112 if replace {
113 best.insert(event.pane_id, (event.state, event.ts));
114 }
115 }
116 }
117 }
118 }
119
120 best.into_iter().map(|(k, (state, _))| (k, state)).collect()
121}
122
123pub fn state_from_str(s: &str) -> WindowState {
124 match s {
125 "working" => WindowState::Working,
126 "asking" => WindowState::Asking,
127 "waiting" => WindowState::Waiting,
128 "idle" => WindowState::Idle,
129 _ => WindowState::Fresh,
130 }
131}
132
133pub fn purge_events_for_pane(pane_id: &str) {
139 purge_events_for_pane_in(&events::events_dir(), pane_id);
140}
141
142pub fn purge_events_for_pane_in(dir: &Path, pane_id: &str) {
144 let entries = match fs::read_dir(dir) {
145 Ok(e) => e,
146 Err(_) => return,
147 };
148
149 for entry in entries.flatten() {
150 let path = entry.path();
151 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
152 continue;
153 }
154 if let Some(line) = read_last_line(&path) {
155 if let Ok(event) = serde_json::from_str::<EventEntry>(&line) {
156 if event.pane_id == pane_id {
157 let _ = fs::remove_file(&path);
158 }
159 }
160 }
161 }
162}
163
164pub struct StateDetector {
165 pane_ids: HashMap<u32, String>,
166}
167
168impl Default for StateDetector {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl StateDetector {
175 pub fn new() -> Self {
176 Self {
177 pane_ids: HashMap::new(),
178 }
179 }
180
181 pub fn pane_id(&self, window_index: u32) -> Option<&str> {
183 self.pane_ids.get(&window_index).map(String::as_str)
184 }
185
186 pub fn detect(&mut self, windows: &[tmux::WindowInfo]) -> HashMap<u32, WindowState> {
188 let mut states = HashMap::new();
189
190 let pane_infos: Vec<tmux::PaneInfo> = tmux::list_pane_commands().unwrap_or_default();
192
193 self.pane_ids = pane_infos
195 .iter()
196 .map(|p| (p.window_index, p.pane_id.clone()))
197 .collect();
198
199 let pane_cmds: HashMap<u32, &str> = pane_infos
200 .iter()
201 .map(|p| (p.window_index, p.command.as_str()))
202 .collect();
203 let pane_ids: HashMap<u32, &str> = pane_infos
204 .iter()
205 .map(|p| (p.window_index, p.pane_id.as_str()))
206 .collect();
207
208 let events = load_latest_events(&events::events_dir());
210
211 for win in windows {
212 let cmd = pane_cmds.get(&win.index).copied().unwrap_or("zsh");
213
214 if cmd == "zsh" || cmd == "bash" || cmd == "fish" {
216 states.insert(win.index, WindowState::Done);
217 continue;
218 }
219
220 let win_pane_id = pane_ids.get(&win.index).copied().unwrap_or("");
222 let state = match events.get(win_pane_id) {
223 Some(state_str) => state_from_str(state_str),
224 None => WindowState::Fresh,
225 };
226
227 states.insert(win.index, state);
228 }
229
230 states
231 }
232}
233
234#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::io::Write;
240
241 #[test]
242 fn test_read_last_line_single() {
243 let dir = tempfile::tempdir().unwrap();
244 let path = dir.path().join("test.jsonl");
245 let mut f = fs::File::create(&path).unwrap();
246 writeln!(f, r#"{{"state":"working","cwd":"/tmp","ts":1000}}"#).unwrap();
247
248 let line = read_last_line(&path).unwrap();
249 assert!(line.contains(r#""state":"working""#));
250 }
251
252 #[test]
253 fn test_read_last_line_multiple() {
254 let dir = tempfile::tempdir().unwrap();
255 let path = dir.path().join("test.jsonl");
256 let mut f = fs::File::create(&path).unwrap();
257 writeln!(f, r#"{{"state":"working","cwd":"/tmp","ts":1000}}"#).unwrap();
258 writeln!(f, r#"{{"state":"idle","cwd":"/tmp","ts":1001}}"#).unwrap();
259
260 let line = read_last_line(&path).unwrap();
261 assert!(line.contains(r#""state":"idle""#));
262 }
263
264 #[test]
265 fn test_read_last_line_empty() {
266 let dir = tempfile::tempdir().unwrap();
267 let path = dir.path().join("test.jsonl");
268 fs::File::create(&path).unwrap();
269
270 assert!(read_last_line(&path).is_none());
271 }
272
273 #[test]
274 fn test_read_last_line_missing() {
275 let path = Path::new("/nonexistent/test.jsonl");
276 assert!(read_last_line(path).is_none());
277 }
278
279 #[test]
280 fn test_load_latest_events() {
281 let dir = tempfile::tempdir().unwrap();
282
283 let mut f1 = fs::File::create(dir.path().join("session-a.jsonl")).unwrap();
284 writeln!(
285 f1,
286 r#"{{"state":"working","cwd":"/project-a","pane_id":"%0","ts":1000}}"#
287 )
288 .unwrap();
289 writeln!(
290 f1,
291 r#"{{"state":"idle","cwd":"/project-a","pane_id":"%0","ts":1001}}"#
292 )
293 .unwrap();
294
295 let mut f2 = fs::File::create(dir.path().join("session-b.jsonl")).unwrap();
296 writeln!(
297 f2,
298 r#"{{"state":"asking","cwd":"/project-b","pane_id":"%3","ts":2000}}"#
299 )
300 .unwrap();
301
302 let events = load_latest_events(dir.path());
303 assert_eq!(events.len(), 2);
304 assert_eq!(events["%0"], "idle");
305 assert_eq!(events["%3"], "asking");
306 }
307
308 #[test]
309 fn test_same_cwd_different_panes() {
310 let dir = tempfile::tempdir().unwrap();
311
312 let mut f1 = fs::File::create(dir.path().join("session-a.jsonl")).unwrap();
314 writeln!(
315 f1,
316 r#"{{"state":"working","cwd":"/same/dir","pane_id":"%0","ts":1000}}"#
317 )
318 .unwrap();
319
320 let mut f2 = fs::File::create(dir.path().join("session-b.jsonl")).unwrap();
321 writeln!(
322 f2,
323 r#"{{"state":"idle","cwd":"/same/dir","pane_id":"%3","ts":1000}}"#
324 )
325 .unwrap();
326
327 let events = load_latest_events(dir.path());
328 assert_eq!(events.len(), 2);
329
330 assert_eq!(events["%0"], "working");
332 assert_eq!(events["%3"], "idle");
333 }
334
335 #[test]
336 fn test_load_latest_events_deduplicates_by_timestamp() {
337 let dir = tempfile::tempdir().unwrap();
338
339 let mut f1 = fs::File::create(dir.path().join("stale-session.jsonl")).unwrap();
341 writeln!(
342 f1,
343 r#"{{"state":"idle","cwd":"/old","pane_id":"%0","ts":1000}}"#
344 )
345 .unwrap();
346
347 let mut f2 = fs::File::create(dir.path().join("current-session.jsonl")).unwrap();
349 writeln!(
350 f2,
351 r#"{{"state":"working","cwd":"/new","pane_id":"%0","ts":2000}}"#
352 )
353 .unwrap();
354
355 let events = load_latest_events(dir.path());
356 assert_eq!(events.len(), 1);
357 assert_eq!(events["%0"], "working");
359 }
360
361 #[test]
362 fn test_events_without_pane_id_ignored() {
363 let dir = tempfile::tempdir().unwrap();
364
365 let mut f = fs::File::create(dir.path().join("old-session.jsonl")).unwrap();
367 writeln!(f, r#"{{"state":"working","cwd":"/project","ts":1000}}"#).unwrap();
368
369 let events = load_latest_events(dir.path());
370 assert!(events.is_empty());
371 }
372
373 #[test]
374 fn test_load_latest_events_empty_dir() {
375 let dir = tempfile::tempdir().unwrap();
376 let events = load_latest_events(dir.path());
377 assert!(events.is_empty());
378 }
379
380 #[test]
381 fn test_load_latest_events_missing_dir() {
382 let events = load_latest_events(Path::new("/nonexistent/events"));
383 assert!(events.is_empty());
384 }
385
386 #[test]
387 fn test_state_from_str() {
388 assert_eq!(state_from_str("working"), WindowState::Working);
389 assert_eq!(state_from_str("idle"), WindowState::Idle);
390 assert_eq!(state_from_str("asking"), WindowState::Asking);
391 assert_eq!(state_from_str("waiting"), WindowState::Waiting);
392 assert_eq!(state_from_str("unknown"), WindowState::Fresh);
393 }
394
395 #[test]
396 fn test_purge_events_for_pane() {
397 let dir = tempfile::tempdir().unwrap();
398
399 let mut f1 = fs::File::create(dir.path().join("old-session.jsonl")).unwrap();
401 writeln!(
402 f1,
403 r#"{{"state":"asking","cwd":"/project","pane_id":"%3","ts":1000}}"#
404 )
405 .unwrap();
406
407 let mut f2 = fs::File::create(dir.path().join("active-session.jsonl")).unwrap();
409 writeln!(
410 f2,
411 r#"{{"state":"idle","cwd":"/project","pane_id":"%0","ts":2000}}"#
412 )
413 .unwrap();
414
415 let mut f3 = fs::File::create(dir.path().join("another-old.jsonl")).unwrap();
417 writeln!(
418 f3,
419 r#"{{"state":"idle","cwd":"/other","pane_id":"%3","ts":500}}"#
420 )
421 .unwrap();
422
423 purge_events_for_pane_in(dir.path(), "%3");
424
425 let remaining: Vec<_> = fs::read_dir(dir.path())
427 .unwrap()
428 .flatten()
429 .filter(|e| e.path().extension().and_then(|e| e.to_str()) == Some("jsonl"))
430 .collect();
431 assert_eq!(remaining.len(), 1);
432 assert!(
433 remaining[0]
434 .path()
435 .file_name()
436 .unwrap()
437 .to_str()
438 .unwrap()
439 .contains("active-session")
440 );
441 }
442}