1use serde_json::Value;
2use std::{
3 collections::HashMap,
4 fs::{self, File},
5 io::Read,
6 path::PathBuf,
7 sync::Arc,
8 time::{Duration, Instant},
9};
10use tokio::sync::{broadcast, RwLock};
11use flow_core::SessionMeta;
12
13pub struct AppState {
15 pub tasks_dir: PathBuf,
16 pub projects_dir: PathBuf,
17 pub tx: broadcast::Sender<String>,
18 pub metadata_cache: RwLock<MetadataCache>,
19 pub db: Option<Arc<flow_db::Database>>,
20}
21
22pub struct MetadataCache {
24 pub data: HashMap<String, SessionMeta>,
25 pub last_refresh: Instant,
26}
27
28impl Default for MetadataCache {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl MetadataCache {
35 pub fn new() -> Self {
36 Self {
37 data: HashMap::new(),
38 last_refresh: Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or_else(Instant::now),
39 }
40 }
41
42 pub fn is_stale(&self) -> bool {
43 self.last_refresh.elapsed() > Duration::from_secs(10)
44 }
45}
46
47pub async fn get_metadata(state: &AppState) -> HashMap<String, SessionMeta> {
49 {
50 let cache = state.metadata_cache.read().await;
51 if !cache.is_stale() {
52 return cache.data.clone();
53 }
54 }
55
56 let mut cache = state.metadata_cache.write().await;
57 if !cache.is_stale() {
59 return cache.data.clone();
60 }
61
62 cache.data = load_session_metadata(&state.projects_dir);
63 cache.last_refresh = Instant::now();
64 cache.data.clone()
65}
66
67pub fn load_session_metadata(projects_dir: &std::path::Path) -> HashMap<String, SessionMeta> {
69 let mut metadata = HashMap::new();
70
71 let Ok(project_dirs) = fs::read_dir(projects_dir) else {
72 return metadata;
73 };
74
75 for entry in project_dirs.flatten() {
76 if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
77 continue;
78 }
79
80 let project_path = entry.path();
81
82 let Ok(files) = fs::read_dir(&project_path) else {
84 continue;
85 };
86
87 for file_entry in files.flatten() {
88 let file_name = file_entry.file_name();
89 let file_name_str = file_name.to_string_lossy();
90
91 if file_name_str.ends_with(".jsonl") {
92 let session_id = file_name_str.trim_end_matches(".jsonl").to_string();
93 let jsonl_path = file_entry.path();
94 let session_info = read_session_info_from_jsonl(&jsonl_path);
95
96 metadata.insert(session_id, session_info);
97 }
98 }
99
100 let index_path = project_path.join("sessions-index.json");
102 if index_path.exists() {
103 if let Ok(content) = fs::read_to_string(&index_path) {
104 if let Ok(index_data) = serde_json::from_str::<Value>(&content) {
105 if let Some(entries) = index_data.get("entries").and_then(|v| v.as_array()) {
106 for entry in entries {
107 if let Some(sid) = entry.get("sessionId").and_then(|v| v.as_str()) {
108 if let Some(meta) = metadata.get_mut(sid) {
109 if let Some(desc) = entry.get("description").and_then(|v| v.as_str()) {
110 meta.description = Some(desc.to_string());
111 }
112 if let Some(branch) = entry.get("gitBranch").and_then(|v| v.as_str()) {
113 meta.git_branch = Some(branch.to_string());
114 }
115 if let Some(created) = entry.get("created").and_then(|v| v.as_str()) {
116 meta.created = Some(created.to_string());
117 }
118 }
119 }
120 }
121 }
122 }
123 }
124 }
125 }
126
127 metadata
128}
129
130pub fn read_session_info_from_jsonl(path: &std::path::Path) -> SessionMeta {
132 let mut meta = SessionMeta::default();
133
134 let Ok(mut file) = File::open(path) else {
135 return meta;
136 };
137
138 let mut buffer = vec![0u8; 65536];
139 let bytes_read = file.read(&mut buffer).unwrap_or(0);
140 buffer.truncate(bytes_read);
141
142 let content = String::from_utf8_lossy(&buffer);
143
144 for line in content.lines() {
145 if line.trim().is_empty() {
146 continue;
147 }
148
149 let Ok(data) = serde_json::from_str::<Value>(line) else {
150 continue;
151 };
152
153 if data.get("type").and_then(|v| v.as_str()) == Some("custom-title") {
155 if let Some(title) = data.get("customTitle").and_then(|v| v.as_str()) {
156 meta.custom_title = Some(title.to_string());
157 }
158 }
159
160 if meta.slug.is_none() {
162 if let Some(slug) = data.get("slug").and_then(|v| v.as_str()) {
163 meta.slug = Some(slug.to_string());
164 }
165 }
166
167 if meta.project_path.is_none() {
169 if let Some(cwd) = data.get("cwd").and_then(|v| v.as_str()) {
170 meta.project_path = Some(cwd.to_string());
171 }
172 }
173
174 if meta.custom_title.is_some() && meta.slug.is_some() && meta.project_path.is_some() {
176 break;
177 }
178 }
179
180 meta
181}