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