git_worktree_manager/operations/
claude_session.rs1use std::fs::File;
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Utc};
13
14pub fn encode_project_dir(path: &Path) -> String {
18 let s = path.to_string_lossy();
19 let trimmed = s.trim_end_matches('/');
20 trimmed.replace(['/', '.'], "-")
21}
22
23pub fn newest_event_timestamp(path: &Path) -> Option<DateTime<Utc>> {
28 const TAIL_BYTES: u64 = 64 * 1024;
29
30 let mut f = File::open(path).ok()?;
31 let len = f.metadata().ok()?.len();
32 let start = len.saturating_sub(TAIL_BYTES);
33 f.seek(SeekFrom::Start(start)).ok()?;
34
35 let mut buf = Vec::new();
36 f.read_to_end(&mut buf).ok()?;
37
38 let mut slice = buf.as_slice();
40 if start != 0 {
41 if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
42 slice = &slice[nl + 1..];
43 }
44 }
45
46 let reader = BufReader::new(slice);
47 let mut latest: Option<DateTime<Utc>> = None;
48 for line in reader.lines().map_while(Result::ok) {
49 let trimmed = line.trim();
50 if trimmed.is_empty() {
51 continue;
52 }
53 let v: serde_json::Value = match serde_json::from_str(trimmed) {
54 Ok(v) => v,
55 Err(_) => continue,
56 };
57 let ts_str = match v.get("timestamp").and_then(|x| x.as_str()) {
58 Some(s) => s,
59 None => continue,
60 };
61 let ts = match DateTime::parse_from_rfc3339(ts_str) {
62 Ok(t) => t.with_timezone(&Utc),
63 Err(_) => continue,
64 };
65 match latest {
66 Some(prev) if prev >= ts => {}
67 _ => latest = Some(ts),
68 }
69 }
70 latest
71}
72
73#[derive(Debug, Clone)]
75pub struct ActiveSession {
76 pub session_id: String,
78 pub last_activity: DateTime<Utc>,
80}
81
82pub fn find_active_sessions(
87 project_dir: &Path,
88 worktree: &Path,
89 threshold: chrono::Duration,
90) -> Vec<ActiveSession> {
91 let entries = match std::fs::read_dir(project_dir) {
92 Ok(e) => e,
93 Err(_) => return Vec::new(),
94 };
95 let now = Utc::now();
96 let wt_canon = worktree
97 .canonicalize()
98 .unwrap_or_else(|_| worktree.to_path_buf());
99 let mut out = Vec::new();
100 for entry in entries.flatten() {
101 let path = entry.path();
102 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
103 continue;
104 }
105 let Some(ts) = newest_event_timestamp(&path) else {
106 continue;
107 };
108 if (now - ts) > threshold {
109 continue;
110 }
111 if let Some(reported_cwd) = newest_event_cwd(&path) {
112 let reported_canon = reported_cwd.canonicalize().unwrap_or(reported_cwd);
113 if reported_canon != wt_canon {
114 continue;
115 }
116 }
117 let id = path
118 .file_stem()
119 .and_then(|s| s.to_str())
120 .unwrap_or("")
121 .to_string();
122 out.push(ActiveSession {
123 session_id: id,
124 last_activity: ts,
125 });
126 }
127 out
128}
129
130pub fn project_dir_for(worktree: &Path) -> Option<PathBuf> {
134 let home = crate::constants::home_dir_or_fallback();
135 let canon = worktree
136 .canonicalize()
137 .unwrap_or_else(|_| worktree.to_path_buf());
138 let encoded = encode_project_dir(&canon);
139 Some(home.join(".claude").join("projects").join(encoded))
140}
141
142pub fn newest_event_cwd(path: &Path) -> Option<PathBuf> {
145 const TAIL_BYTES: u64 = 64 * 1024;
146 let mut f = File::open(path).ok()?;
147 let len = f.metadata().ok()?.len();
148 let start = len.saturating_sub(TAIL_BYTES);
149 f.seek(SeekFrom::Start(start)).ok()?;
150 let mut buf = Vec::new();
151 f.read_to_end(&mut buf).ok()?;
152 let mut slice = buf.as_slice();
153 if start != 0 {
154 if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
155 slice = &slice[nl + 1..];
156 }
157 }
158 let reader = BufReader::new(slice);
159 let mut latest: Option<(DateTime<Utc>, PathBuf)> = None;
160 for line in reader.lines().map_while(Result::ok) {
161 let v: serde_json::Value = match serde_json::from_str(line.trim()) {
162 Ok(v) => v,
163 Err(_) => continue,
164 };
165 let Some(ts_str) = v.get("timestamp").and_then(|x| x.as_str()) else {
166 continue;
167 };
168 let Some(cwd_str) = v.get("cwd").and_then(|x| x.as_str()) else {
169 continue;
170 };
171 let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) else {
172 continue;
173 };
174 let ts = ts.with_timezone(&Utc);
175 match latest {
176 Some((prev, _)) if prev >= ts => {}
177 _ => latest = Some((ts, PathBuf::from(cwd_str))),
178 }
179 }
180 latest.map(|(_, p)| p)
181}