notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

session_discovery.rs (8368B)


      1 //! Discovers resumable Claude Code sessions from the filesystem.
      2 //!
      3 //! Claude Code stores session data in ~/.claude/projects/<project-path>/
      4 //! where <project-path> is the cwd with slashes replaced by dashes and leading slash removed.
      5 
      6 use serde::Deserialize;
      7 use std::fs::{self, File};
      8 use std::io::{BufRead, BufReader};
      9 use std::path::{Path, PathBuf};
     10 
     11 /// Information about a resumable Claude session
     12 #[derive(Debug, Clone)]
     13 pub struct ResumableSession {
     14     /// The UUID session identifier used by Claude CLI
     15     pub session_id: String,
     16     /// Path to the session JSONL file
     17     pub file_path: PathBuf,
     18     /// Timestamp of the most recent message
     19     pub last_timestamp: chrono::DateTime<chrono::Utc>,
     20     /// Summary/title derived from first user message
     21     pub summary: String,
     22     /// Number of messages in the session
     23     pub message_count: usize,
     24 }
     25 
     26 /// A message entry from the JSONL file
     27 #[derive(Deserialize)]
     28 struct SessionEntry {
     29     #[serde(rename = "sessionId")]
     30     session_id: Option<String>,
     31     timestamp: Option<String>,
     32     #[serde(rename = "type")]
     33     entry_type: Option<String>,
     34     message: Option<MessageContent>,
     35 }
     36 
     37 #[derive(Deserialize)]
     38 struct MessageContent {
     39     role: Option<String>,
     40     content: Option<serde_json::Value>,
     41 }
     42 
     43 /// Converts a working directory to its Claude project path
     44 /// e.g., /home/jb55/dev/notedeck-dave -> -home-jb55-dev-notedeck-dave
     45 fn cwd_to_project_path(cwd: &Path) -> String {
     46     let path_str = cwd.to_string_lossy();
     47     // Replace path separators with dashes, keep the leading dash
     48     path_str.replace('/', "-")
     49 }
     50 
     51 /// Get the Claude projects directory
     52 fn get_claude_projects_dir() -> Option<PathBuf> {
     53     dirs::home_dir().map(|home| home.join(".claude").join("projects"))
     54 }
     55 
     56 /// Extract the first user message content as a summary
     57 fn extract_first_user_message(content: &serde_json::Value) -> Option<String> {
     58     match content {
     59         serde_json::Value::String(s) => {
     60             // Clean up the message - remove "Human: " prefix if present
     61             let cleaned = s.trim().strip_prefix("Human:").unwrap_or(s).trim();
     62             // Take first 60 chars
     63             let summary: String = cleaned.chars().take(60).collect();
     64             if cleaned.len() > 60 {
     65                 Some(format!("{}...", summary))
     66             } else {
     67                 Some(summary.to_string())
     68             }
     69         }
     70         serde_json::Value::Array(arr) => {
     71             // Content might be an array of content blocks
     72             for item in arr {
     73                 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
     74                     let summary: String = text.chars().take(60).collect();
     75                     if text.len() > 60 {
     76                         return Some(format!("{}...", summary));
     77                     } else {
     78                         return Some(summary.to_string());
     79                     }
     80                 }
     81             }
     82             None
     83         }
     84         _ => None,
     85     }
     86 }
     87 
     88 /// Parse a session JSONL file to extract session info
     89 fn parse_session_file(path: &Path) -> Option<ResumableSession> {
     90     let file = File::open(path).ok()?;
     91     let reader = BufReader::new(file);
     92 
     93     let mut session_id: Option<String> = None;
     94     let mut last_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
     95     let mut first_user_message: Option<String> = None;
     96     let mut message_count = 0;
     97 
     98     for line in reader.lines() {
     99         let line = line.ok()?;
    100         if line.trim().is_empty() {
    101             continue;
    102         }
    103 
    104         if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
    105             // Get session ID from first entry that has it
    106             if session_id.is_none() {
    107                 session_id = entry.session_id.clone();
    108             }
    109 
    110             // Track timestamp
    111             if let Some(ts_str) = &entry.timestamp {
    112                 if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() {
    113                     if last_timestamp.is_none() || ts > last_timestamp.unwrap() {
    114                         last_timestamp = Some(ts);
    115                     }
    116                 }
    117             }
    118 
    119             // Count user/assistant messages
    120             if matches!(
    121                 entry.entry_type.as_deref(),
    122                 Some("user") | Some("assistant")
    123             ) {
    124                 message_count += 1;
    125 
    126                 // Get first user message for summary
    127                 if entry.entry_type.as_deref() == Some("user") && first_user_message.is_none() {
    128                     if let Some(msg) = &entry.message {
    129                         if msg.role.as_deref() == Some("user") {
    130                             if let Some(content) = &msg.content {
    131                                 first_user_message = extract_first_user_message(content);
    132                             }
    133                         }
    134                     }
    135                 }
    136             }
    137         }
    138     }
    139 
    140     // Need at least a session_id and some messages
    141     let session_id = session_id?;
    142     if message_count == 0 {
    143         return None;
    144     }
    145 
    146     Some(ResumableSession {
    147         session_id,
    148         file_path: path.to_path_buf(),
    149         last_timestamp: last_timestamp.unwrap_or_else(chrono::Utc::now),
    150         summary: first_user_message.unwrap_or_else(|| "(no summary)".to_string()),
    151         message_count,
    152     })
    153 }
    154 
    155 /// Discover all resumable sessions for a given working directory
    156 pub fn discover_sessions(cwd: &Path) -> Vec<ResumableSession> {
    157     let projects_dir = match get_claude_projects_dir() {
    158         Some(dir) => dir,
    159         None => return Vec::new(),
    160     };
    161 
    162     let project_path = cwd_to_project_path(cwd);
    163     let session_dir = projects_dir.join(&project_path);
    164 
    165     if !session_dir.exists() || !session_dir.is_dir() {
    166         return Vec::new();
    167     }
    168 
    169     let mut sessions = Vec::new();
    170 
    171     // Read all .jsonl files in the session directory
    172     if let Ok(entries) = fs::read_dir(&session_dir) {
    173         for entry in entries.flatten() {
    174             let path = entry.path();
    175             if path.extension().is_some_and(|ext| ext == "jsonl") {
    176                 if let Some(session) = parse_session_file(&path) {
    177                     sessions.push(session);
    178                 }
    179             }
    180         }
    181     }
    182 
    183     // Sort by most recent first
    184     sessions.sort_by(|a, b| b.last_timestamp.cmp(&a.last_timestamp));
    185 
    186     sessions
    187 }
    188 
    189 /// Format a timestamp for display (relative time like "2 hours ago")
    190 pub fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
    191     let now = chrono::Utc::now();
    192     let duration = now.signed_duration_since(*timestamp);
    193 
    194     if duration.num_seconds() < 60 {
    195         "just now".to_string()
    196     } else if duration.num_minutes() < 60 {
    197         let mins = duration.num_minutes();
    198         format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
    199     } else if duration.num_hours() < 24 {
    200         let hours = duration.num_hours();
    201         format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
    202     } else if duration.num_days() < 7 {
    203         let days = duration.num_days();
    204         format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
    205     } else {
    206         timestamp.format("%Y-%m-%d").to_string()
    207     }
    208 }
    209 
    210 #[cfg(test)]
    211 mod tests {
    212     use super::*;
    213 
    214     #[test]
    215     fn test_cwd_to_project_path() {
    216         assert_eq!(
    217             cwd_to_project_path(Path::new("/home/jb55/dev/notedeck-dave")),
    218             "-home-jb55-dev-notedeck-dave"
    219         );
    220         assert_eq!(cwd_to_project_path(Path::new("/tmp/test")), "-tmp-test");
    221     }
    222 
    223     #[test]
    224     fn test_extract_first_user_message_string() {
    225         let content = serde_json::json!("Human: Hello, world!\n\n");
    226         let result = extract_first_user_message(&content);
    227         assert_eq!(result, Some("Hello, world!".to_string()));
    228     }
    229 
    230     #[test]
    231     fn test_extract_first_user_message_array() {
    232         let content = serde_json::json!([{"type": "text", "text": "Test message"}]);
    233         let result = extract_first_user_message(&content);
    234         assert_eq!(result, Some("Test message".to_string()));
    235     }
    236 
    237     #[test]
    238     fn test_extract_first_user_message_truncation() {
    239         let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length");
    240         let result = extract_first_user_message(&long_content);
    241         assert!(result.unwrap().ends_with("..."));
    242     }
    243 }