Skip to main content

codetether_agent/session/
listing.rs

1use super::Session;
2use anyhow::Result;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use tokio::fs;
7
8/// Summary of a session for listing
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SessionSummary {
11    pub id: String,
12    pub title: Option<String>,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub message_count: usize,
16    pub agent: String,
17    /// The working directory this session was created in
18    #[serde(default)]
19    pub directory: Option<PathBuf>,
20}
21
22/// List all sessions
23pub async fn list_sessions() -> Result<Vec<SessionSummary>> {
24    let sessions_dir = crate::config::Config::data_dir()
25        .map(|d| d.join("sessions"))
26        .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
27
28    if !sessions_dir.exists() {
29        return Ok(Vec::new());
30    }
31
32    let mut summaries = Vec::new();
33    let mut entries = fs::read_dir(&sessions_dir).await?;
34
35    while let Some(entry) = entries.next_entry().await? {
36        let path = entry.path();
37        if path.extension().map(|e| e == "json").unwrap_or(false) {
38            let content = match fs::read_to_string(&path).await {
39                Ok(c) => c,
40                Err(err) => {
41                    tracing::warn!(path = %path.display(), error = %err, "skipping unreadable session file");
42                    continue;
43                }
44            };
45            let session = match serde_json::from_str::<Session>(&content) {
46                Ok(s) => s,
47                Err(err) => {
48                    tracing::warn!(path = %path.display(), error = %err, "skipping malformed session file");
49                    continue;
50                }
51            };
52            summaries.push(SessionSummary {
53                id: session.id,
54                title: session.title,
55                created_at: session.created_at,
56                updated_at: session.updated_at,
57                message_count: session.messages.len(),
58                agent: session.agent,
59                directory: session.metadata.directory,
60            });
61        }
62    }
63
64    summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
65    Ok(summaries)
66}
67
68/// List sessions scoped to a specific directory (workspace)
69///
70/// Only returns sessions whose `metadata.directory` matches the given path.
71/// This prevents sessions from other workspaces "leaking" into the TUI.
72pub async fn list_sessions_for_directory(dir: &std::path::Path) -> Result<Vec<SessionSummary>> {
73    let all = list_sessions().await?;
74    let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
75    Ok(all
76        .into_iter()
77        .filter(|s| {
78            s.directory
79                .as_ref()
80                .map(|d| {
81                    match d.canonicalize() {
82                        Ok(c) => c == canonical,
83                        Err(_) => {
84                            // Fallback: prefix match if either side can't canonicalize
85                            d == dir || canonical.starts_with(d) || d.starts_with(&canonical)
86                        }
87                    }
88                })
89                .unwrap_or(false)
90        })
91        .collect())
92}
93
94/// List sessions for a directory with pagination.
95///
96/// - `limit`: Maximum number of sessions to return (default: 100)
97/// - `offset`: Number of sessions to skip (default: 0)
98pub async fn list_sessions_paged(
99    dir: &std::path::Path,
100    limit: usize,
101    offset: usize,
102) -> Result<Vec<SessionSummary>> {
103    let mut sessions = list_sessions_for_directory(dir).await?;
104    sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
105    Ok(sessions.into_iter().skip(offset).take(limit).collect())
106}