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(path: &Path) -> Option<(DateTime<Utc>, Option<PathBuf>)> {
30 const TAIL_BYTES: u64 = 64 * 1024;
31
32 let mut f = File::open(path).ok()?;
33 let len = f.metadata().ok()?.len();
34 let start = len.saturating_sub(TAIL_BYTES);
35 f.seek(SeekFrom::Start(start)).ok()?;
36
37 let mut buf = Vec::new();
38 f.read_to_end(&mut buf).ok()?;
39
40 let mut slice = buf.as_slice();
42 if start != 0 {
43 if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
44 slice = &slice[nl + 1..];
45 }
46 }
47
48 let reader = BufReader::new(slice);
49 let mut latest: Option<(DateTime<Utc>, Option<PathBuf>)> = None;
50 for line in reader.lines().map_while(Result::ok) {
51 let trimmed = line.trim();
52 if trimmed.is_empty() {
53 continue;
54 }
55 let v: serde_json::Value = match serde_json::from_str(trimmed) {
56 Ok(v) => v,
57 Err(_) => continue,
58 };
59 let ts_str = match v.get("timestamp").and_then(|x| x.as_str()) {
60 Some(s) => s,
61 None => continue,
62 };
63 let ts = match DateTime::parse_from_rfc3339(ts_str) {
64 Ok(t) => t.with_timezone(&Utc),
65 Err(_) => continue,
66 };
67 let cwd = v.get("cwd").and_then(|x| x.as_str()).map(PathBuf::from);
68 match latest {
69 Some((prev, _)) if prev >= ts => {}
70 _ => latest = Some((ts, cwd)),
71 }
72 }
73 latest
74}
75
76#[inline]
81pub fn newest_event_timestamp(path: &Path) -> Option<DateTime<Utc>> {
82 newest_event(path).map(|(ts, _)| ts)
83}
84
85#[derive(Debug, Clone)]
87pub struct ActiveSession {
88 pub session_id: String,
90 pub last_activity: DateTime<Utc>,
92}
93
94pub fn find_active_sessions(
99 project_dir: &Path,
100 worktree: &Path,
101 threshold: chrono::Duration,
102) -> Vec<ActiveSession> {
103 let entries = match std::fs::read_dir(project_dir) {
104 Ok(e) => e,
105 Err(_) => return Vec::new(),
106 };
107 let now = Utc::now();
108 let wt_canon = worktree
109 .canonicalize()
110 .unwrap_or_else(|_| worktree.to_path_buf());
111 let mut out = Vec::new();
112 for entry in entries.flatten() {
113 let path = entry.path();
114 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
115 continue;
116 }
117 let Some((ts, maybe_cwd)) = newest_event(&path) else {
119 continue;
120 };
121 if (now - ts) > threshold {
122 continue;
123 }
124 let reported_cwd = match maybe_cwd {
128 Some(p) => p,
129 None => continue,
130 };
131 let reported_canon = reported_cwd.canonicalize().unwrap_or(reported_cwd);
132 if reported_canon != wt_canon {
133 continue;
134 }
135 let id = path
136 .file_stem()
137 .and_then(|s| s.to_str())
138 .unwrap_or("")
139 .to_string();
140 out.push(ActiveSession {
141 session_id: id,
142 last_activity: ts,
143 });
144 }
145 out
146}
147
148pub fn project_dir_for(worktree: &Path) -> Option<PathBuf> {
152 let home = crate::constants::home_dir_or_fallback();
153 let canon = worktree
154 .canonicalize()
155 .unwrap_or_else(|_| worktree.to_path_buf());
156 let encoded = encode_project_dir(&canon);
157 Some(home.join(".claude").join("projects").join(encoded))
158}
159
160#[inline]
164pub fn newest_event_cwd(path: &Path) -> Option<PathBuf> {
165 newest_event(path).and_then(|(_, cwd)| cwd)
166}