1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11use crate::conversation::message::{Message, Role};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct SessionId(String);
16
17impl SessionId {
18 pub fn new() -> Self {
19 Self(Uuid::new_v4().to_string())
20 }
21
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25
26 pub fn from_string(s: String) -> Self {
28 Self(s)
29 }
30}
31
32impl Default for SessionId {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl std::fmt::Display for SessionId {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "{}", self.0)
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Session {
47 pub id: SessionId,
49 pub name: String,
51 pub working_dir: PathBuf,
53 pub created_at: u64,
55 pub updated_at: u64,
57 pub messages: Vec<Message>,
59 #[serde(default)]
67 pub user_renamed: bool,
68}
69
70impl Session {
71 pub fn new(working_dir: PathBuf) -> Self {
73 let now = current_timestamp();
74 Self {
75 id: SessionId::new(),
76 name: format!("session-{}", format_timestamp(now)),
77 working_dir,
78 created_at: now,
79 updated_at: now,
80 messages: Vec::new(),
81 user_renamed: false,
82 }
83 }
84
85 pub fn default_session(working_dir: PathBuf) -> Self {
87 Self {
88 id: SessionId::new(),
89 name: "default".to_string(),
90 working_dir,
91 created_at: current_timestamp(),
92 updated_at: current_timestamp(),
93 messages: Vec::new(),
94 user_renamed: false,
95 }
96 }
97
98 pub fn rename(&mut self, name: String) {
102 self.name = name;
103 self.user_renamed = true;
104 self.touch();
105 }
106
107 pub fn auto_name_from_messages(&mut self) {
113 if !should_auto_name_session(&self.name) {
114 return;
115 }
116
117 let first_real_user = self
118 .messages
119 .iter()
120 .filter(|m| matches!(m.role, Role::User))
121 .find_map(|m| m.text().filter(|t| !is_synthetic_user_text(t)));
122
123 if let Some(text) = first_real_user {
124 let name: String = text.lines().next().unwrap_or("").chars().take(40).collect();
125 if !name.is_empty() {
126 self.name = name;
127 }
128 }
129 }
130
131 pub fn touch(&mut self) {
133 self.updated_at = current_timestamp();
134 }
135
136 pub fn short_id(&self) -> &str {
138 &self.id.0[..8]
139 }
140}
141
142fn should_auto_name_session(name: &str) -> bool {
143 name == "default" || name.starts_with("session-") || name.trim_start().starts_with('[')
144}
145
146fn is_synthetic_user_text(text: &str) -> bool {
147 text.trim_start().starts_with('[')
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SessionMeta {
154 pub id: SessionId,
155 pub name: String,
156 pub working_dir: PathBuf,
157 pub created_at: u64,
158 pub updated_at: u64,
159 pub message_count: usize,
160 #[serde(default)]
162 pub file_size: u64,
163}
164
165impl From<&Session> for SessionMeta {
166 fn from(session: &Session) -> Self {
167 Self {
168 id: session.id.clone(),
169 name: session.name.clone(),
170 working_dir: session.working_dir.clone(),
171 created_at: session.created_at,
172 updated_at: session.updated_at,
173 message_count: session.messages.len(),
174 file_size: 0, }
176 }
177}
178
179pub struct SessionManager {
181 sessions_dir: PathBuf,
183 project_hash: String,
185}
186
187impl SessionManager {
188 pub fn sessions_root_dir() -> PathBuf {
190 crate::config::Config::config_dir().join("sessions")
191 }
192
193 fn legacy_sessions_dir() -> Option<PathBuf> {
196 if cfg!(target_os = "macos") {
197 dirs::data_local_dir().map(|p| p.join("atomcode").join("sessions"))
198 } else {
199 None
200 }
201 }
202
203 pub fn migrate_from_legacy() {
209 let Some(legacy_dir) = Self::legacy_sessions_dir() else {
210 return; };
212
213 if !legacy_dir.exists() {
214 return; }
216
217 let new_dir = Self::sessions_root_dir();
218 if new_dir.exists() && std::fs::read_dir(&new_dir).map_or(false, |mut d| d.next().is_some())
219 {
220 return; }
222
223 if let Err(e) = std::fs::create_dir_all(&new_dir) {
225 eprintln!("[session] Failed to create sessions dir: {}", e);
226 return;
227 }
228
229 match std::fs::read_dir(&legacy_dir) {
230 Ok(entries) => {
231 let mut migrated = 0;
232 for entry in entries.flatten() {
233 let src = entry.path();
234 let dst = new_dir.join(entry.file_name());
235 if src.is_dir() {
236 if let Err(e) = std::fs::create_dir_all(&dst) {
237 eprintln!("[session] Failed to create {:?}: {}", dst, e);
238 continue;
239 }
240 if let Ok(files) = std::fs::read_dir(&src) {
241 for file in files.flatten() {
242 let src_file = file.path();
243 let dst_file = dst.join(file.file_name());
244 if let Err(e) = std::fs::copy(&src_file, &dst_file) {
245 eprintln!("[session] Failed to copy {:?}: {}", src_file, e);
246 } else {
247 migrated += 1;
248 }
249 }
250 }
251 }
252 }
253 if migrated > 0 {
254 eprintln!(
255 "[session] Migrated {} session(s) from legacy location",
256 migrated
257 );
258 }
259 }
260 Err(e) => {
261 eprintln!("[session] Failed to read legacy sessions dir: {}", e);
262 }
263 }
264 }
265
266 pub fn new(working_dir: &Path) -> Self {
268 Self::migrate_from_legacy();
270
271 let sessions_dir = Self::sessions_root_dir();
272 let project_hash = hash_path(working_dir);
273
274 Self {
275 sessions_dir,
276 project_hash,
277 }
278 }
279
280 fn project_dir(&self) -> PathBuf {
282 self.sessions_dir.join(&self.project_hash)
283 }
284
285 fn ensure_dir(&self) -> std::io::Result<()> {
287 std::fs::create_dir_all(self.project_dir())
288 }
289
290 pub fn save(&self, session: &Session) -> std::io::Result<()> {
292 self.ensure_dir()?;
293 let path = self.project_dir().join(format!("{}.json", session.id));
294 let json = serde_json::to_string_pretty(session)
295 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
296 std::fs::write(path, json)
297 }
298
299 pub fn load(&self, id: &SessionId) -> std::io::Result<Session> {
301 let path = self.project_dir().join(format!("{}.json", id));
302 let json = std::fs::read_to_string(path)?;
303 serde_json::from_str(&json).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
304 }
305
306 pub fn list(&self) -> std::io::Result<Vec<SessionMeta>> {
308 let project_dir = self.project_dir();
309 if !project_dir.exists() {
310 return Ok(Vec::new());
311 }
312
313 let mut sessions = Vec::new();
314 for entry in std::fs::read_dir(project_dir)? {
315 let entry = entry?;
316 let path = entry.path();
317 if path.extension().map_or(false, |ext| ext == "json") {
318 let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
319 if let Ok(json) = std::fs::read_to_string(&path) {
320 if let Ok(session) = serde_json::from_str::<Session>(&json) {
321 let mut meta = SessionMeta::from(&session);
322 meta.file_size = file_size;
323 sessions.push(meta);
324 }
325 }
326 }
327 }
328
329 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
331 Ok(sessions)
332 }
333
334 pub fn delete(&self, id: &SessionId) -> std::io::Result<()> {
336 let path = self.project_dir().join(format!("{}.json", id));
337 std::fs::remove_file(path)
338 }
339
340 pub fn has_sessions(&self) -> bool {
342 let project_dir = self.project_dir();
343 project_dir.exists()
344 && std::fs::read_dir(project_dir).map_or(false, |mut d| d.next().is_some())
345 }
346
347 pub fn latest(&self) -> std::io::Result<Option<Session>> {
349 let metas = self.list()?;
350 if let Some(latest) = metas.first() {
351 return self.load(&latest.id).map(Some);
352 }
353 Ok(None)
354 }
355}
356
357fn hash_path(path: &Path) -> String {
364 use std::collections::hash_map::DefaultHasher;
365 use std::hash::{Hash, Hasher};
366
367 let normalized = path.to_string_lossy();
373 let mut normalized = normalized.replace('\\', "/");
374
375 if normalized.len() > 1 && normalized.ends_with('/') {
376 normalized.pop();
377 }
378
379 #[cfg(windows)]
380 let normalized = normalized.to_lowercase();
381
382 let mut hasher = DefaultHasher::new();
392 let p: PathBuf = PathBuf::from(normalized);
393 p.hash(&mut hasher);
394 format!("{:016x}", hasher.finish())
395}
396
397fn current_timestamp() -> u64 {
399 SystemTime::now()
400 .duration_since(UNIX_EPOCH)
401 .unwrap_or_default()
402 .as_secs()
403}
404
405fn format_timestamp(ts: u64) -> String {
407 use chrono::{TimeZone, Utc};
408 let dt = Utc
409 .timestamp_opt(ts as i64, 0)
410 .single()
411 .unwrap_or_else(|| Utc::now());
412 dt.format("%Y%m%d-%H%M%S").to_string()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_session_id_is_unique() {
421 let id1 = SessionId::new();
422 let id2 = SessionId::new();
423 assert_ne!(id1, id2);
424 }
425
426 #[test]
427 fn test_session_new() {
428 let session = Session::new(PathBuf::from("/tmp/test"));
429 assert!(!session.id.0.is_empty());
430 assert!(session.name.starts_with("session-"));
431 }
432
433 #[test]
434 fn auto_name_uses_first_real_user_message() {
435 let mut session = Session::new(PathBuf::from("/tmp/test"));
436 session
437 .messages
438 .push(Message::new(Role::User, "[System meta · not a user message]\nignored"));
439 session
440 .messages
441 .push(Message::new(Role::User, "帮我修复 VS Code 会话标题自动命名的问题\n更多内容"));
442
443 session.auto_name_from_messages();
444
445 assert_eq!(session.name, "帮我修复 VS Code 会话标题自动命名的问题");
446 }
447
448 #[test]
449 fn auto_name_preserves_user_renamed_session() {
450 let mut session = Session::new(PathBuf::from("/tmp/test"));
451 session.rename("手动命名".to_string());
452 session.messages.push(Message::new(Role::User, "新的用户消息"));
453
454 session.auto_name_from_messages();
455
456 assert_eq!(session.name, "手动命名");
457 }
458
459 #[test]
460 fn rename_sets_user_renamed_flag() {
461 let mut session = Session::new(PathBuf::from("/tmp/test"));
462 assert!(!session.user_renamed, "fresh session must not be flagged as user-renamed");
463 session.rename("我的会话".to_string());
464 assert!(session.user_renamed, "rename() must mark the session as user-renamed");
465 }
466
467 #[test]
468 fn auto_name_does_not_set_user_renamed_flag() {
469 let mut session = Session::new(PathBuf::from("/tmp/test"));
470 session.messages.push(Message::new(Role::User, "first message body"));
471 session.auto_name_from_messages();
472 assert_eq!(session.name, "first message body");
473 assert!(
474 !session.user_renamed,
475 "auto_name_from_messages must NOT flag the session as user-renamed; only /rename should"
476 );
477 }
478
479 #[test]
480 fn test_hash_path_consistent() {
481 let path = Path::new("/Users/test/project");
482 let hash1 = hash_path(path);
483 let hash2 = hash_path(path);
484 assert_eq!(hash1, hash2);
485 assert_eq!(hash1.len(), 16);
486 }
487
488 #[test]
489 fn test_hash_path_normalized() {
490 let path1 = Path::new("/Users/test/project");
495 let path2 = Path::new("/Users/test/project/");
496 assert_eq!(
497 hash_path(path1),
498 hash_path(path2),
499 "Trailing slash should not affect hash"
500 );
501
502 let path3 = Path::new("C:\\Users\\test\\project");
504 let path4 = Path::new("C:/Users/test/project");
505 assert_eq!(
506 hash_path(path3),
507 hash_path(path4),
508 "Backslashes should be normalized to forward slashes"
509 );
510
511 let path5 = Path::new("C:\\Users\\test\\project\\");
513 assert_eq!(
514 hash_path(path4),
515 hash_path(path5),
516 "Backslashes and trailing slash should both be normalized"
517 );
518 }
519
520 #[test]
521 fn hash_path_matches_legacy_path_hash_on_unix() {
522 use std::collections::hash_map::DefaultHasher;
530 use std::hash::{Hash, Hasher};
531
532 let p = Path::new("/Users/theo/Documents/workspace/atomcode");
533 let mut expected = DefaultHasher::new();
534 p.hash(&mut expected);
535 let legacy = format!("{:016x}", expected.finish());
536 assert_eq!(hash_path(p), legacy);
537 }
538}