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