1use anyhow::Result;
6use tabled::{settings::Style as TableStyle, Table, Tabled};
7
8use crate::models::Workspace;
9use crate::storage::{read_empty_window_sessions, VsCodeSessionFormat};
10use crate::workspace::discover_workspaces;
11
12#[derive(Tabled)]
13struct WorkspaceRow {
14 #[tabled(rename = "Hash")]
15 hash: String,
16 #[tabled(rename = "Project Path")]
17 project_path: String,
18 #[tabled(rename = "Sessions")]
19 sessions: usize,
20 #[tabled(rename = "Has Chats")]
21 has_chats: String,
22}
23
24#[derive(Tabled)]
25struct SessionRow {
26 #[tabled(rename = "Project Path")]
27 project_path: String,
28 #[tabled(rename = "Session File")]
29 session_file: String,
30 #[tabled(rename = "Last Modified")]
31 last_modified: String,
32 #[tabled(rename = "Messages")]
33 messages: usize,
34}
35
36#[derive(Tabled)]
37struct SessionRowWithSize {
38 #[tabled(rename = "Project Path")]
39 project_path: String,
40 #[tabled(rename = "Session File")]
41 session_file: String,
42 #[tabled(rename = "Last Modified")]
43 last_modified: String,
44 #[tabled(rename = "Messages")]
45 messages: usize,
46 #[tabled(rename = "Size")]
47 size: String,
48}
49
50pub fn list_workspaces() -> Result<()> {
52 let workspaces = discover_workspaces()?;
53
54 if workspaces.is_empty() {
55 println!("No workspaces found.");
56 return Ok(());
57 }
58
59 let rows: Vec<WorkspaceRow> = workspaces
60 .iter()
61 .map(|ws| WorkspaceRow {
62 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
63 project_path: ws
64 .project_path
65 .clone()
66 .unwrap_or_else(|| "(none)".to_string()),
67 sessions: ws.chat_session_count,
68 has_chats: if ws.has_chat_sessions {
69 "Yes".to_string()
70 } else {
71 "No".to_string()
72 },
73 })
74 .collect();
75
76 let table = Table::new(rows)
77 .with(TableStyle::ascii_rounded())
78 .to_string();
79
80 println!("{}", table);
81 println!("\nTotal workspaces: {}", workspaces.len());
82
83 if let Ok(empty_count) = crate::storage::count_empty_window_sessions() {
85 if empty_count > 0 {
86 println!("Empty window sessions (ALL SESSIONS): {}", empty_count);
87 }
88 }
89
90 Ok(())
91}
92
93fn format_file_size(bytes: u64) -> String {
95 if bytes < 1024 {
96 format!("{} B", bytes)
97 } else if bytes < 1024 * 1024 {
98 format!("{:.1} KB", bytes as f64 / 1024.0)
99 } else if bytes < 1024 * 1024 * 1024 {
100 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
101 } else {
102 format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
103 }
104}
105
106pub fn list_sessions(
108 project_path: Option<&str>,
109 show_size: bool,
110 provider: Option<&str>,
111 all_providers: bool,
112) -> Result<()> {
113 if provider.is_some() || all_providers {
115 return list_sessions_multi_provider(project_path, show_size, provider, all_providers);
116 }
117
118 let workspaces = discover_workspaces()?;
120
121 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
122 let normalized = crate::workspace::normalize_path(path);
123 workspaces
124 .iter()
125 .filter(|ws| {
126 ws.project_path
127 .as_ref()
128 .map(|p| crate::workspace::normalize_path(p) == normalized)
129 .unwrap_or(false)
130 })
131 .collect()
132 } else {
133 workspaces.iter().collect()
134 };
135
136 if show_size {
137 let mut rows: Vec<SessionRowWithSize> = Vec::new();
138 let mut total_size: u64 = 0;
139
140 if project_path.is_none() {
142 if let Ok(empty_sessions) = read_empty_window_sessions() {
143 for session in empty_sessions {
144 let modified =
145 chrono::DateTime::from_timestamp_millis(session.last_message_date)
146 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
147 .unwrap_or_else(|| "unknown".to_string());
148
149 let session_id = session.session_id.as_deref().unwrap_or("unknown");
150 rows.push(SessionRowWithSize {
151 project_path: "(ALL SESSIONS)".to_string(),
152 session_file: format!("{}.json", session_id),
153 last_modified: modified,
154 messages: session.request_count(),
155 size: "N/A".to_string(),
156 });
157 }
158 }
159 }
160
161 for ws in &filtered_workspaces {
162 if !ws.has_chat_sessions {
163 continue;
164 }
165
166 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
167
168 for session_with_path in sessions {
169 let metadata = session_with_path.path.metadata().ok();
170 let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
171 total_size += file_size;
172
173 let modified = metadata
174 .and_then(|m| m.modified().ok())
175 .map(|t| {
176 let datetime: chrono::DateTime<chrono::Utc> = t.into();
177 datetime.format("%Y-%m-%d %H:%M").to_string()
178 })
179 .unwrap_or_else(|| "unknown".to_string());
180
181 rows.push(SessionRowWithSize {
182 project_path: ws
183 .project_path
184 .clone()
185 .unwrap_or_else(|| "(none)".to_string()),
186 session_file: session_with_path
187 .path
188 .file_name()
189 .map(|n| n.to_string_lossy().to_string())
190 .unwrap_or_else(|| "unknown".to_string()),
191 last_modified: modified,
192 messages: session_with_path.session.request_count(),
193 size: format_file_size(file_size),
194 });
195 }
196 }
197
198 if rows.is_empty() {
199 println!("No chat sessions found.");
200 return Ok(());
201 }
202
203 let table = Table::new(&rows)
204 .with(TableStyle::ascii_rounded())
205 .to_string();
206 println!("{}", table);
207 println!(
208 "\nTotal sessions: {} ({})",
209 rows.len(),
210 format_file_size(total_size)
211 );
212 } else {
213 let mut rows: Vec<SessionRow> = Vec::new();
214
215 if project_path.is_none() {
217 if let Ok(empty_sessions) = read_empty_window_sessions() {
218 for session in empty_sessions {
219 let modified =
220 chrono::DateTime::from_timestamp_millis(session.last_message_date)
221 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
222 .unwrap_or_else(|| "unknown".to_string());
223
224 let session_id = session.session_id.as_deref().unwrap_or("unknown");
225 rows.push(SessionRow {
226 project_path: "(ALL SESSIONS)".to_string(),
227 session_file: format!("{}.json", session_id),
228 last_modified: modified,
229 messages: session.request_count(),
230 });
231 }
232 }
233 }
234
235 for ws in &filtered_workspaces {
236 if !ws.has_chat_sessions {
237 continue;
238 }
239
240 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
241
242 for session_with_path in sessions {
243 let modified = session_with_path
244 .path
245 .metadata()
246 .ok()
247 .and_then(|m| m.modified().ok())
248 .map(|t| {
249 let datetime: chrono::DateTime<chrono::Utc> = t.into();
250 datetime.format("%Y-%m-%d %H:%M").to_string()
251 })
252 .unwrap_or_else(|| "unknown".to_string());
253
254 rows.push(SessionRow {
255 project_path: ws
256 .project_path
257 .clone()
258 .unwrap_or_else(|| "(none)".to_string()),
259 session_file: session_with_path
260 .path
261 .file_name()
262 .map(|n| n.to_string_lossy().to_string())
263 .unwrap_or_else(|| "unknown".to_string()),
264 last_modified: modified,
265 messages: session_with_path.session.request_count(),
266 });
267 }
268 }
269
270 if rows.is_empty() {
271 println!("No chat sessions found.");
272 return Ok(());
273 }
274
275 let table = Table::new(&rows)
276 .with(TableStyle::ascii_rounded())
277 .to_string();
278 println!("{}", table);
279 println!("\nTotal sessions: {}", rows.len());
280 }
281
282 Ok(())
283}
284
285fn list_sessions_multi_provider(
287 project_path: Option<&str>,
288 show_size: bool,
289 provider: Option<&str>,
290 all_providers: bool,
291) -> Result<()> {
292 let storage_paths = if all_providers {
294 get_agent_storage_paths(Some("all"))?
295 } else if let Some(p) = provider {
296 get_agent_storage_paths(Some(p))?
297 } else {
298 get_agent_storage_paths(None)?
299 };
300
301 if storage_paths.is_empty() {
302 if let Some(p) = provider {
303 println!("No storage found for provider: {}", p);
304 } else {
305 println!("No workspaces found");
306 }
307 return Ok(());
308 }
309
310 let target_path = project_path.map(crate::workspace::normalize_path);
311
312 #[derive(Tabled)]
313 struct SessionRowMulti {
314 #[tabled(rename = "Provider")]
315 provider: String,
316 #[tabled(rename = "Project Path")]
317 project_path: String,
318 #[tabled(rename = "Session File")]
319 session_file: String,
320 #[tabled(rename = "Modified")]
321 last_modified: String,
322 #[tabled(rename = "Msgs")]
323 messages: usize,
324 }
325
326 #[derive(Tabled)]
327 struct SessionRowMultiWithSize {
328 #[tabled(rename = "Provider")]
329 provider: String,
330 #[tabled(rename = "Project Path")]
331 project_path: String,
332 #[tabled(rename = "Session File")]
333 session_file: String,
334 #[tabled(rename = "Modified")]
335 last_modified: String,
336 #[tabled(rename = "Msgs")]
337 messages: usize,
338 #[tabled(rename = "Size")]
339 size: String,
340 }
341
342 let mut rows: Vec<SessionRowMulti> = Vec::new();
343 let mut rows_with_size: Vec<SessionRowMultiWithSize> = Vec::new();
344 let mut total_size: u64 = 0;
345
346 for (provider_name, storage_path) in &storage_paths {
347 if !storage_path.exists() {
348 continue;
349 }
350
351 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
352 let workspace_dir = entry.path();
353 if !workspace_dir.is_dir() {
354 continue;
355 }
356
357 let chat_sessions_dir = workspace_dir.join("chatSessions");
358 if !chat_sessions_dir.exists() {
359 continue;
360 }
361
362 let workspace_json = workspace_dir.join("workspace.json");
364 let project = std::fs::read_to_string(&workspace_json)
365 .ok()
366 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
367 .and_then(|ws| {
368 ws.folder
369 .map(|f| crate::workspace::decode_workspace_folder(&f))
370 });
371
372 if let Some(ref target) = target_path {
374 if project
375 .as_ref()
376 .map(|p| crate::workspace::normalize_path(p) != *target)
377 .unwrap_or(true)
378 {
379 continue;
380 }
381 }
382
383 let project_display = project.clone().unwrap_or_else(|| "(none)".to_string());
384
385 for session_entry in std::fs::read_dir(&chat_sessions_dir)?.filter_map(|e| e.ok()) {
387 let session_path = session_entry.path();
388 if !session_path.is_file() {
389 continue;
390 }
391
392 let ext = session_path.extension().and_then(|e| e.to_str());
393 if ext != Some("json") && ext != Some("jsonl") {
394 continue;
395 }
396
397 let metadata = session_path.metadata().ok();
398 let file_size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
399 total_size += file_size;
400
401 let modified = metadata
402 .and_then(|m| m.modified().ok())
403 .map(|t| {
404 let datetime: chrono::DateTime<chrono::Utc> = t.into();
405 datetime.format("%Y-%m-%d %H:%M").to_string()
406 })
407 .unwrap_or_else(|| "unknown".to_string());
408
409 let session_file = session_path
410 .file_name()
411 .map(|n| n.to_string_lossy().to_string())
412 .unwrap_or_else(|| "unknown".to_string());
413
414 let messages = std::fs::read_to_string(&session_path)
416 .ok()
417 .map(|c| c.matches("\"message\":").count())
418 .unwrap_or(0);
419
420 if show_size {
421 rows_with_size.push(SessionRowMultiWithSize {
422 provider: provider_name.clone(),
423 project_path: truncate_string(&project_display, 30),
424 session_file: truncate_string(&session_file, 20),
425 last_modified: modified,
426 messages,
427 size: format_file_size(file_size),
428 });
429 } else {
430 rows.push(SessionRowMulti {
431 provider: provider_name.clone(),
432 project_path: truncate_string(&project_display, 30),
433 session_file: truncate_string(&session_file, 20),
434 last_modified: modified,
435 messages,
436 });
437 }
438 }
439 }
440 }
441
442 if show_size {
443 if rows_with_size.is_empty() {
444 println!("No chat sessions found.");
445 return Ok(());
446 }
447 let table = Table::new(&rows_with_size)
448 .with(TableStyle::ascii_rounded())
449 .to_string();
450 println!("{}", table);
451 println!(
452 "\nTotal sessions: {} ({})",
453 rows_with_size.len(),
454 format_file_size(total_size)
455 );
456 } else {
457 if rows.is_empty() {
458 println!("No chat sessions found.");
459 return Ok(());
460 }
461 let table = Table::new(&rows)
462 .with(TableStyle::ascii_rounded())
463 .to_string();
464 println!("{}", table);
465 println!("\nTotal sessions: {}", rows.len());
466 }
467
468 Ok(())
469}
470
471pub fn find_workspaces(pattern: &str) -> Result<()> {
473 let workspaces = discover_workspaces()?;
474
475 let pattern = if pattern == "." {
477 std::env::current_dir()
478 .ok()
479 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
480 .unwrap_or_else(|| pattern.to_string())
481 } else {
482 pattern.to_string()
483 };
484 let pattern_lower = pattern.to_lowercase();
485
486 let matching: Vec<&Workspace> = workspaces
487 .iter()
488 .filter(|ws| {
489 ws.project_path
490 .as_ref()
491 .map(|p| p.to_lowercase().contains(&pattern_lower))
492 .unwrap_or(false)
493 || ws.hash.to_lowercase().contains(&pattern_lower)
494 })
495 .collect();
496
497 if matching.is_empty() {
498 println!("No workspaces found matching '{}'", pattern);
499 return Ok(());
500 }
501
502 let rows: Vec<WorkspaceRow> = matching
503 .iter()
504 .map(|ws| WorkspaceRow {
505 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
506 project_path: ws
507 .project_path
508 .clone()
509 .unwrap_or_else(|| "(none)".to_string()),
510 sessions: ws.chat_session_count,
511 has_chats: if ws.has_chat_sessions {
512 "Yes".to_string()
513 } else {
514 "No".to_string()
515 },
516 })
517 .collect();
518
519 let table = Table::new(rows)
520 .with(TableStyle::ascii_rounded())
521 .to_string();
522
523 println!("{}", table);
524 println!("\nFound {} matching workspace(s)", matching.len());
525
526 for ws in &matching {
528 if ws.has_chat_sessions {
529 let project = ws.project_path.as_deref().unwrap_or("(none)");
530 println!("\nSessions for {}:", project);
531
532 if let Ok(sessions) =
533 crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
534 {
535 for session_with_path in sessions {
536 println!(" {}", session_with_path.path.display());
537 }
538 }
539 }
540 }
541
542 Ok(())
543}
544
545#[allow(dead_code)]
547pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
548 let workspaces = discover_workspaces()?;
549 let pattern_lower = pattern.to_lowercase();
550
551 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
552 let normalized = crate::workspace::normalize_path(path);
553 workspaces
554 .iter()
555 .filter(|ws| {
556 ws.project_path
557 .as_ref()
558 .map(|p| crate::workspace::normalize_path(p) == normalized)
559 .unwrap_or(false)
560 })
561 .collect()
562 } else {
563 workspaces.iter().collect()
564 };
565
566 let mut rows: Vec<SessionRow> = Vec::new();
567
568 for ws in filtered_workspaces {
569 if !ws.has_chat_sessions {
570 continue;
571 }
572
573 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
574
575 for session_with_path in sessions {
576 let session_id_matches = session_with_path
578 .session
579 .session_id
580 .as_ref()
581 .map(|id| id.to_lowercase().contains(&pattern_lower))
582 .unwrap_or(false);
583 let title_matches = session_with_path
584 .session
585 .title()
586 .to_lowercase()
587 .contains(&pattern_lower);
588 let content_matches = session_with_path.session.requests.iter().any(|r| {
589 r.message
590 .as_ref()
591 .map(|m| {
592 m.text
593 .as_ref()
594 .map(|t| t.to_lowercase().contains(&pattern_lower))
595 .unwrap_or(false)
596 })
597 .unwrap_or(false)
598 });
599
600 if !session_id_matches && !title_matches && !content_matches {
601 continue;
602 }
603
604 let modified = session_with_path
605 .path
606 .metadata()
607 .ok()
608 .and_then(|m| m.modified().ok())
609 .map(|t| {
610 let datetime: chrono::DateTime<chrono::Utc> = t.into();
611 datetime.format("%Y-%m-%d %H:%M").to_string()
612 })
613 .unwrap_or_else(|| "unknown".to_string());
614
615 rows.push(SessionRow {
616 project_path: ws
617 .project_path
618 .clone()
619 .unwrap_or_else(|| "(none)".to_string()),
620 session_file: session_with_path
621 .path
622 .file_name()
623 .map(|n| n.to_string_lossy().to_string())
624 .unwrap_or_else(|| "unknown".to_string()),
625 last_modified: modified,
626 messages: session_with_path.session.request_count(),
627 });
628 }
629 }
630
631 if rows.is_empty() {
632 println!("No sessions found matching '{}'", pattern);
633 return Ok(());
634 }
635
636 let table = Table::new(&rows)
637 .with(TableStyle::ascii_rounded())
638 .to_string();
639
640 println!("{}", table);
641 println!("\nFound {} matching session(s)", rows.len());
642
643 Ok(())
644}
645
646fn read_file_header(path: &std::path::Path, max_bytes: usize) -> Option<String> {
649 use std::io::Read;
650 let file = std::fs::File::open(path).ok()?;
651 let mut reader = std::io::BufReader::new(file);
652 let mut buffer = vec![0u8; max_bytes];
653 let bytes_read = reader.read(&mut buffer).ok()?;
654 buffer.truncate(bytes_read);
655 String::from_utf8(buffer).ok()
656}
657
658fn contains_case_insensitive(haystack: &str, needle_lower: &str) -> bool {
661 if needle_lower.is_empty() {
662 return true;
663 }
664 let needle_bytes = needle_lower.as_bytes();
665 let haystack_bytes = haystack.as_bytes();
666 if needle_bytes.len() > haystack_bytes.len() {
667 return false;
668 }
669 'outer: for i in 0..=(haystack_bytes.len() - needle_bytes.len()) {
671 for j in 0..needle_bytes.len() {
672 if haystack_bytes[i + j].to_ascii_lowercase() != needle_bytes[j] {
673 continue 'outer;
674 }
675 }
676 return true;
677 }
678 false
679}
680
681pub fn find_sessions_filtered(
691 pattern: &str,
692 workspace_filter: Option<&str>,
693 title_only: bool,
694 search_content: bool,
695 after: Option<&str>,
696 before: Option<&str>,
697 date: Option<&str>,
698 all_workspaces: bool,
699 provider: Option<&str>,
700 all_providers: bool,
701 limit: usize,
702) -> Result<()> {
703 use chrono::{NaiveDate, Utc};
704 use rayon::prelude::*;
705 use std::sync::atomic::{AtomicUsize, Ordering};
706
707 let pattern_lower = pattern.to_lowercase();
708
709 let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
711 let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
712 let target_date = date.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
713
714 let storage_paths = if all_providers {
716 get_agent_storage_paths(Some("all"))?
717 } else if let Some(p) = provider {
718 get_agent_storage_paths(Some(p))?
719 } else {
720 let vscode_path = crate::workspace::get_workspace_storage_path()?;
722 if vscode_path.exists() {
723 vec![("vscode".to_string(), vscode_path)]
724 } else {
725 vec![]
726 }
727 };
728
729 if storage_paths.is_empty() {
730 if let Some(p) = provider {
731 println!("No storage found for provider: {}", p);
732 } else {
733 println!("No workspaces found");
734 }
735 return Ok(());
736 }
737
738 let ws_filter_lower = if all_workspaces {
741 None
742 } else {
743 workspace_filter.map(|s| s.to_lowercase())
744 };
745
746 let workspace_dirs: Vec<_> = storage_paths
747 .iter()
748 .flat_map(|(provider_name, storage_path)| {
749 if !storage_path.exists() {
750 return vec![];
751 }
752 std::fs::read_dir(storage_path)
753 .into_iter()
754 .flatten()
755 .filter_map(|e| e.ok())
756 .filter(|e| e.path().is_dir())
757 .filter_map(|entry| {
758 let workspace_dir = entry.path();
759 let workspace_json_path = workspace_dir.join("workspace.json");
760
761 let chat_sessions_dir = workspace_dir.join("chatSessions");
763 if !chat_sessions_dir.exists() {
764 return None;
765 }
766
767 let project_path =
769 std::fs::read_to_string(&workspace_json_path)
770 .ok()
771 .and_then(|content| {
772 serde_json::from_str::<crate::models::WorkspaceJson>(&content)
773 .ok()
774 .and_then(|ws| {
775 ws.folder
776 .map(|f| crate::workspace::decode_workspace_folder(&f))
777 })
778 });
779
780 if let Some(ref filter) = ws_filter_lower {
782 let hash = entry.file_name().to_string_lossy().to_lowercase();
783 let path_matches = project_path
784 .as_ref()
785 .map(|p| p.to_lowercase().contains(filter))
786 .unwrap_or(false);
787 if !hash.contains(filter) && !path_matches {
788 return None;
789 }
790 }
791
792 let ws_name = project_path
793 .as_ref()
794 .and_then(|p| std::path::Path::new(p).file_name())
795 .map(|n| n.to_string_lossy().to_string())
796 .unwrap_or_else(|| {
797 entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
798 .to_string()
799 });
800
801 Some((chat_sessions_dir, ws_name, provider_name.clone()))
802 })
803 .collect::<Vec<_>>()
804 })
805 .collect();
806
807 if workspace_dirs.is_empty() {
808 if let Some(ws) = workspace_filter {
809 println!("No workspaces found matching '{}'", ws);
810 } else {
811 println!("No workspaces with chat sessions found");
812 }
813 return Ok(());
814 }
815
816 let session_files: Vec<_> = workspace_dirs
818 .iter()
819 .flat_map(|(chat_dir, ws_name, provider_name)| {
820 std::fs::read_dir(chat_dir)
821 .into_iter()
822 .flatten()
823 .filter_map(|e| e.ok())
824 .filter(|e| {
825 e.path()
826 .extension()
827 .map(|ext| ext == "json" || ext == "jsonl")
828 .unwrap_or(false)
829 })
830 .map(|e| (e.path(), ws_name.clone(), provider_name.clone()))
831 .collect::<Vec<_>>()
832 })
833 .collect();
834
835 let total_files = session_files.len();
836 let scanned = AtomicUsize::new(0);
837 let skipped_by_date = AtomicUsize::new(0);
838
839 let mut results: Vec<_> = session_files
841 .par_iter()
842 .filter_map(|(path, ws_name, provider_name)| {
843 if after_date.is_some() || before_date.is_some() {
845 if let Ok(metadata) = path.metadata() {
846 if let Ok(modified) = metadata.modified() {
847 let file_date: chrono::DateTime<Utc> = modified.into();
848 let file_naive = file_date.date_naive();
849
850 if let Some(after) = after_date {
851 if file_naive < after {
852 skipped_by_date.fetch_add(1, Ordering::Relaxed);
853 return None;
854 }
855 }
856 if let Some(before) = before_date {
857 if file_naive > before {
858 skipped_by_date.fetch_add(1, Ordering::Relaxed);
859 return None;
860 }
861 }
862 }
863 }
864 }
865
866 scanned.fetch_add(1, Ordering::Relaxed);
867
868 let (title, content_for_search) = if title_only || !search_content {
872 let header = match read_file_header(path, 4096) {
874 Some(h) => h,
875 None => return None,
876 };
877 let title =
878 extract_title_from_content(&header).unwrap_or_else(|| "Untitled".to_string());
879 (title, None)
880 } else {
881 let content = match std::fs::read_to_string(path) {
883 Ok(c) => c,
884 Err(_) => return None,
885 };
886
887 if let Some(target) = target_date {
889 let has_matching_timestamp =
890 content.split("\"timestamp\":").skip(1).any(|part| {
891 let num_str: String = part
892 .chars()
893 .skip_while(|c| c.is_whitespace())
894 .take_while(|c| c.is_ascii_digit())
895 .collect();
896 if let Ok(ts_ms) = num_str.parse::<i64>() {
897 if let Some(dt) = chrono::DateTime::from_timestamp_millis(ts_ms) {
898 return dt.date_naive() == target;
899 }
900 }
901 false
902 });
903
904 if !has_matching_timestamp {
905 skipped_by_date.fetch_add(1, Ordering::Relaxed);
906 return None;
907 }
908 }
909
910 let title =
911 extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
912 (title, Some(content))
913 };
914
915 let title_lower = title.to_lowercase();
916
917 let session_id = path
919 .file_stem()
920 .map(|n| n.to_string_lossy().to_string())
921 .unwrap_or_default();
922 let id_matches =
923 !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
924
925 let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
927
928 let content_matches = if search_content
930 && !title_only
931 && !id_matches
932 && !title_matches
933 && !pattern_lower.is_empty()
934 {
935 if let Some(ref content) = content_for_search {
936 contains_case_insensitive(content, &pattern_lower)
938 } else {
939 match std::fs::read_to_string(path) {
941 Ok(c) => contains_case_insensitive(&c, &pattern_lower),
942 Err(_) => false,
943 }
944 }
945 } else {
946 false
947 };
948
949 let matches =
951 pattern_lower.is_empty() || id_matches || title_matches || content_matches;
952 if !matches {
953 return None;
954 }
955
956 let match_type = if pattern_lower.is_empty() {
957 ""
958 } else if id_matches {
959 "ID"
960 } else if title_matches {
961 "title"
962 } else {
963 "content"
964 };
965
966 let message_count = if let Some(ref content) = content_for_search {
968 content.matches("\"message\":").count()
969 } else {
970 path.metadata()
972 .ok()
973 .map(|m| {
974 (m.len() / 500).max(1) as usize
976 })
977 .unwrap_or(0)
978 };
979
980 let modified = path
982 .metadata()
983 .ok()
984 .and_then(|m| m.modified().ok())
985 .map(|t| {
986 let datetime: chrono::DateTime<chrono::Utc> = t.into();
987 datetime.format("%Y-%m-%d %H:%M").to_string()
988 })
989 .unwrap_or_else(|| "unknown".to_string());
990
991 Some((
992 title,
993 ws_name.clone(),
994 provider_name.clone(),
995 modified,
996 message_count,
997 match_type.to_string(),
998 ))
999 })
1000 .collect();
1001
1002 let scanned_count = scanned.load(Ordering::Relaxed);
1003 let skipped_count = skipped_by_date.load(Ordering::Relaxed);
1004
1005 if results.is_empty() {
1006 println!("No sessions found matching '{}'", pattern);
1007 if skipped_count > 0 {
1008 println!(" ({} sessions skipped due to date filter)", skipped_count);
1009 }
1010 return Ok(());
1011 }
1012
1013 results.sort_by(|a, b| b.3.cmp(&a.3));
1015
1016 results.truncate(limit);
1018
1019 let show_provider_column = all_providers || storage_paths.len() > 1;
1021
1022 #[derive(Tabled)]
1023 struct SearchResultRow {
1024 #[tabled(rename = "Title")]
1025 title: String,
1026 #[tabled(rename = "Workspace")]
1027 workspace: String,
1028 #[tabled(rename = "Modified")]
1029 modified: String,
1030 #[tabled(rename = "Msgs")]
1031 messages: usize,
1032 #[tabled(rename = "Match")]
1033 match_type: String,
1034 }
1035
1036 #[derive(Tabled)]
1037 struct SearchResultRowWithProvider {
1038 #[tabled(rename = "Provider")]
1039 provider: String,
1040 #[tabled(rename = "Title")]
1041 title: String,
1042 #[tabled(rename = "Workspace")]
1043 workspace: String,
1044 #[tabled(rename = "Modified")]
1045 modified: String,
1046 #[tabled(rename = "Msgs")]
1047 messages: usize,
1048 #[tabled(rename = "Match")]
1049 match_type: String,
1050 }
1051
1052 if show_provider_column {
1053 let rows: Vec<SearchResultRowWithProvider> = results
1054 .into_iter()
1055 .map(
1056 |(title, workspace, provider, modified, messages, match_type)| {
1057 SearchResultRowWithProvider {
1058 provider,
1059 title: truncate_string(&title, 35),
1060 workspace: truncate_string(&workspace, 15),
1061 modified,
1062 messages,
1063 match_type,
1064 }
1065 },
1066 )
1067 .collect();
1068
1069 let table = Table::new(&rows)
1070 .with(TableStyle::ascii_rounded())
1071 .to_string();
1072
1073 println!("{}", table);
1074 println!(
1075 "\nFound {} session(s) (scanned {} of {} files{})",
1076 rows.len(),
1077 scanned_count,
1078 total_files,
1079 if skipped_count > 0 {
1080 format!(", {} skipped by date", skipped_count)
1081 } else {
1082 String::new()
1083 }
1084 );
1085 if rows.len() >= limit {
1086 println!(" (results limited to {}; use --limit to show more)", limit);
1087 }
1088 } else {
1089 let rows: Vec<SearchResultRow> = results
1090 .into_iter()
1091 .map(
1092 |(title, workspace, _provider, modified, messages, match_type)| SearchResultRow {
1093 title: truncate_string(&title, 40),
1094 workspace: truncate_string(&workspace, 20),
1095 modified,
1096 messages,
1097 match_type,
1098 },
1099 )
1100 .collect();
1101
1102 let table = Table::new(&rows)
1103 .with(TableStyle::ascii_rounded())
1104 .to_string();
1105
1106 println!("{}", table);
1107 println!(
1108 "\nFound {} session(s) (scanned {} of {} files{})",
1109 rows.len(),
1110 scanned_count,
1111 total_files,
1112 if skipped_count > 0 {
1113 format!(", {} skipped by date", skipped_count)
1114 } else {
1115 String::new()
1116 }
1117 );
1118 if rows.len() >= limit {
1119 println!(" (results limited to {}; use --limit to show more)", limit);
1120 }
1121 }
1122
1123 Ok(())
1124}
1125
1126fn extract_title_from_content(content: &str) -> Option<String> {
1128 if let Some(start) = content.find("\"customTitle\"") {
1130 if let Some(colon) = content[start..].find(':') {
1131 let after_colon = &content[start + colon + 1..];
1132 let trimmed = after_colon.trim_start();
1133 if let Some(stripped) = trimmed.strip_prefix('"') {
1134 if let Some(end) = stripped.find('"') {
1135 let title = &stripped[..end];
1136 if !title.is_empty() && title != "null" {
1137 return Some(title.to_string());
1138 }
1139 }
1140 }
1141 }
1142 }
1143
1144 if let Some(start) = content.find("\"text\"") {
1146 if let Some(colon) = content[start..].find(':') {
1147 let after_colon = &content[start + colon + 1..];
1148 let trimmed = after_colon.trim_start();
1149 if let Some(stripped) = trimmed.strip_prefix('"') {
1150 if let Some(end) = stripped.find('"') {
1151 let title = &stripped[..end];
1152 if !title.is_empty() && title.len() < 100 {
1153 return Some(title.to_string());
1154 }
1155 }
1156 }
1157 }
1158 }
1159
1160 None
1161}
1162
1163#[allow(dead_code)]
1165fn extract_title_fast(header: &str) -> Option<String> {
1166 extract_title_from_content(header)
1167}
1168
1169fn truncate_string(s: &str, max_len: usize) -> String {
1171 if s.len() <= max_len {
1172 s.to_string()
1173 } else {
1174 format!("{}...", &s[..max_len.saturating_sub(3)])
1175 }
1176}
1177
1178pub fn show_workspace(workspace: &str) -> Result<()> {
1180 use colored::Colorize;
1181
1182 let workspaces = discover_workspaces()?;
1183 let workspace_lower = workspace.to_lowercase();
1184
1185 let matching: Vec<&Workspace> = workspaces
1187 .iter()
1188 .filter(|ws| {
1189 ws.hash.to_lowercase().contains(&workspace_lower)
1190 || ws
1191 .project_path
1192 .as_ref()
1193 .map(|p| p.to_lowercase().contains(&workspace_lower))
1194 .unwrap_or(false)
1195 })
1196 .collect();
1197
1198 if matching.is_empty() {
1199 println!(
1200 "{} No workspace found matching '{}'",
1201 "!".yellow(),
1202 workspace
1203 );
1204 return Ok(());
1205 }
1206
1207 for ws in matching {
1208 println!("\n{}", "=".repeat(60).bright_blue());
1209 println!("{}", "Workspace Details".bright_blue().bold());
1210 println!("{}", "=".repeat(60).bright_blue());
1211
1212 println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
1213 println!(
1214 "{}: {}",
1215 "Path".bright_white().bold(),
1216 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1217 );
1218 println!(
1219 "{}: {}",
1220 "Has Sessions".bright_white().bold(),
1221 if ws.has_chat_sessions {
1222 "Yes".green()
1223 } else {
1224 "No".red()
1225 }
1226 );
1227 println!(
1228 "{}: {}",
1229 "Workspace Path".bright_white().bold(),
1230 ws.workspace_path.display()
1231 );
1232
1233 if ws.has_chat_sessions {
1234 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1235 println!(
1236 "{}: {}",
1237 "Session Count".bright_white().bold(),
1238 sessions.len()
1239 );
1240
1241 if !sessions.is_empty() {
1242 println!("\n{}", "Sessions:".bright_yellow());
1243 for (i, s) in sessions.iter().enumerate() {
1244 let title = s.session.title();
1245 let msg_count = s.session.request_count();
1246 println!(
1247 " {}. {} ({} messages)",
1248 i + 1,
1249 title.bright_cyan(),
1250 msg_count
1251 );
1252 }
1253 }
1254 }
1255 }
1256
1257 Ok(())
1258}
1259
1260pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
1262 use colored::Colorize;
1263
1264 let workspaces = discover_workspaces()?;
1265 let session_id_lower = session_id.to_lowercase();
1266
1267 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
1268 let normalized = crate::workspace::normalize_path(path);
1269 workspaces
1270 .iter()
1271 .filter(|ws| {
1272 ws.project_path
1273 .as_ref()
1274 .map(|p| crate::workspace::normalize_path(p) == normalized)
1275 .unwrap_or(false)
1276 })
1277 .collect()
1278 } else {
1279 workspaces.iter().collect()
1280 };
1281
1282 for ws in filtered_workspaces {
1283 if !ws.has_chat_sessions {
1284 continue;
1285 }
1286
1287 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
1288
1289 for s in sessions {
1290 let filename = s
1291 .path
1292 .file_name()
1293 .map(|n| n.to_string_lossy().to_string())
1294 .unwrap_or_default();
1295
1296 let matches = s
1297 .session
1298 .session_id
1299 .as_ref()
1300 .map(|id| id.to_lowercase().contains(&session_id_lower))
1301 .unwrap_or(false)
1302 || filename.to_lowercase().contains(&session_id_lower);
1303
1304 if matches {
1305 let format = VsCodeSessionFormat::from_path(&s.path);
1307
1308 println!("\n{}", "=".repeat(60).bright_blue());
1309 println!("{}", "Session Details".bright_blue().bold());
1310 println!("{}", "=".repeat(60).bright_blue());
1311
1312 println!(
1313 "{}: {}",
1314 "Title".bright_white().bold(),
1315 s.session.title().bright_cyan()
1316 );
1317 println!("{}: {}", "File".bright_white().bold(), filename);
1318 println!(
1319 "{}: {}",
1320 "Format".bright_white().bold(),
1321 format.to_string().bright_magenta()
1322 );
1323 println!(
1324 "{}: {}",
1325 "Session ID".bright_white().bold(),
1326 s.session
1327 .session_id
1328 .as_ref()
1329 .unwrap_or(&"(none)".to_string())
1330 );
1331 println!(
1332 "{}: {}",
1333 "Messages".bright_white().bold(),
1334 s.session.request_count()
1335 );
1336 println!(
1337 "{}: {}",
1338 "Workspace".bright_white().bold(),
1339 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
1340 );
1341
1342 println!("\n{}", "Preview:".bright_yellow());
1344 for (i, req) in s.session.requests.iter().take(3).enumerate() {
1345 if let Some(msg) = &req.message {
1346 if let Some(text) = &msg.text {
1347 let preview: String = text.chars().take(100).collect();
1348 let truncated = if text.len() > 100 { "..." } else { "" };
1349 println!(" {}. {}{}", i + 1, preview.dimmed(), truncated);
1350 }
1351 }
1352 }
1353
1354 return Ok(());
1355 }
1356 }
1357 }
1358
1359 println!(
1360 "{} No session found matching '{}'",
1361 "!".yellow(),
1362 session_id
1363 );
1364 Ok(())
1365}
1366
1367fn get_agent_storage_paths(provider: Option<&str>) -> Result<Vec<(String, std::path::PathBuf)>> {
1370 let mut paths = Vec::new();
1371
1372 let vscode_path = crate::workspace::get_workspace_storage_path()?;
1374
1375 let cursor_path = get_cursor_storage_path();
1377 let claudecode_path = get_claudecode_storage_path();
1378 let opencode_path = get_opencode_storage_path();
1379 let openclaw_path = get_openclaw_storage_path();
1380 let antigravity_path = get_antigravity_storage_path();
1381 let codexcli_path = get_codexcli_storage_path();
1382 let droidcli_path = get_droidcli_storage_path();
1383 let geminicli_path = get_geminicli_storage_path();
1384
1385 match provider {
1386 None => {
1387 if vscode_path.exists() {
1389 paths.push(("vscode".to_string(), vscode_path));
1390 }
1391 }
1392 Some("all") => {
1393 if vscode_path.exists() {
1395 paths.push(("vscode".to_string(), vscode_path));
1396 }
1397 if let Some(cp) = cursor_path {
1398 if cp.exists() {
1399 paths.push(("cursor".to_string(), cp));
1400 }
1401 }
1402 if let Some(cc) = claudecode_path {
1403 if cc.exists() {
1404 paths.push(("claudecode".to_string(), cc));
1405 }
1406 }
1407 if let Some(oc) = opencode_path {
1408 if oc.exists() {
1409 paths.push(("opencode".to_string(), oc));
1410 }
1411 }
1412 if let Some(ocl) = openclaw_path {
1413 if ocl.exists() {
1414 paths.push(("openclaw".to_string(), ocl));
1415 }
1416 }
1417 if let Some(ag) = antigravity_path {
1418 if ag.exists() {
1419 paths.push(("antigravity".to_string(), ag));
1420 }
1421 }
1422 if let Some(cx) = codexcli_path {
1423 if cx.exists() {
1424 paths.push(("codexcli".to_string(), cx));
1425 }
1426 }
1427 if let Some(dr) = droidcli_path {
1428 if dr.exists() {
1429 paths.push(("droidcli".to_string(), dr));
1430 }
1431 }
1432 if let Some(gc) = geminicli_path {
1433 if gc.exists() {
1434 paths.push(("geminicli".to_string(), gc));
1435 }
1436 }
1437 }
1438 Some(p) => {
1439 let p_lower = p.to_lowercase();
1440 match p_lower.as_str() {
1441 "vscode" | "vs-code" | "copilot" => {
1442 if vscode_path.exists() {
1443 paths.push(("vscode".to_string(), vscode_path));
1444 }
1445 }
1446 "cursor" => {
1447 if let Some(cp) = cursor_path {
1448 if cp.exists() {
1449 paths.push(("cursor".to_string(), cp));
1450 }
1451 }
1452 }
1453 "claudecode" | "claude-code" | "claude" => {
1454 if let Some(cc) = claudecode_path {
1455 if cc.exists() {
1456 paths.push(("claudecode".to_string(), cc));
1457 }
1458 }
1459 }
1460 "opencode" | "open-code" => {
1461 if let Some(oc) = opencode_path {
1462 if oc.exists() {
1463 paths.push(("opencode".to_string(), oc));
1464 }
1465 }
1466 }
1467 "openclaw" | "open-claw" | "claw" => {
1468 if let Some(ocl) = openclaw_path {
1469 if ocl.exists() {
1470 paths.push(("openclaw".to_string(), ocl));
1471 }
1472 }
1473 }
1474 "antigravity" | "anti-gravity" | "ag" => {
1475 if let Some(ag) = antigravity_path {
1476 if ag.exists() {
1477 paths.push(("antigravity".to_string(), ag));
1478 }
1479 }
1480 }
1481 "codexcli" | "codex-cli" | "codex" => {
1482 if let Some(cx) = codexcli_path {
1483 if cx.exists() {
1484 paths.push(("codexcli".to_string(), cx));
1485 }
1486 }
1487 }
1488 "droidcli" | "droid-cli" | "droid" | "factory" => {
1489 if let Some(dr) = droidcli_path {
1490 if dr.exists() {
1491 paths.push(("droidcli".to_string(), dr));
1492 }
1493 }
1494 }
1495 "geminicli" | "gemini-cli" => {
1496 if let Some(gc) = geminicli_path {
1497 if gc.exists() {
1498 paths.push(("geminicli".to_string(), gc));
1499 }
1500 }
1501 }
1502 _ => {
1503 }
1505 }
1506 }
1507 }
1508
1509 Ok(paths)
1510}
1511
1512fn get_cursor_storage_path() -> Option<std::path::PathBuf> {
1514 #[cfg(target_os = "windows")]
1515 {
1516 if let Some(appdata) = dirs::data_dir() {
1517 let cursor_path = appdata.join("Cursor").join("User").join("workspaceStorage");
1518 if cursor_path.exists() {
1519 return Some(cursor_path);
1520 }
1521 }
1522 if let Ok(roaming) = std::env::var("APPDATA") {
1523 let roaming_path = std::path::PathBuf::from(roaming)
1524 .join("Cursor")
1525 .join("User")
1526 .join("workspaceStorage");
1527 if roaming_path.exists() {
1528 return Some(roaming_path);
1529 }
1530 }
1531 }
1532
1533 #[cfg(target_os = "macos")]
1534 {
1535 if let Some(home) = dirs::home_dir() {
1536 let cursor_path = home
1537 .join("Library")
1538 .join("Application Support")
1539 .join("Cursor")
1540 .join("User")
1541 .join("workspaceStorage");
1542 if cursor_path.exists() {
1543 return Some(cursor_path);
1544 }
1545 }
1546 }
1547
1548 #[cfg(target_os = "linux")]
1549 {
1550 if let Some(config) = dirs::config_dir() {
1551 let cursor_path = config.join("Cursor").join("User").join("workspaceStorage");
1552 if cursor_path.exists() {
1553 return Some(cursor_path);
1554 }
1555 }
1556 }
1557
1558 None
1559}
1560
1561fn get_claudecode_storage_path() -> Option<std::path::PathBuf> {
1563 #[cfg(target_os = "windows")]
1564 {
1565 if let Ok(appdata) = std::env::var("APPDATA") {
1567 let claude_path = std::path::PathBuf::from(&appdata)
1568 .join("claude-code")
1569 .join("sessions");
1570 if claude_path.exists() {
1571 return Some(claude_path);
1572 }
1573 let alt_path = std::path::PathBuf::from(&appdata)
1575 .join("ClaudeCode")
1576 .join("workspaceStorage");
1577 if alt_path.exists() {
1578 return Some(alt_path);
1579 }
1580 }
1581 if let Some(local) = dirs::data_local_dir() {
1582 let local_path = local.join("ClaudeCode").join("sessions");
1583 if local_path.exists() {
1584 return Some(local_path);
1585 }
1586 }
1587 }
1588
1589 #[cfg(target_os = "macos")]
1590 {
1591 if let Some(home) = dirs::home_dir() {
1592 let claude_path = home
1593 .join("Library")
1594 .join("Application Support")
1595 .join("claude-code")
1596 .join("sessions");
1597 if claude_path.exists() {
1598 return Some(claude_path);
1599 }
1600 }
1601 }
1602
1603 #[cfg(target_os = "linux")]
1604 {
1605 if let Some(config) = dirs::config_dir() {
1606 let claude_path = config.join("claude-code").join("sessions");
1607 if claude_path.exists() {
1608 return Some(claude_path);
1609 }
1610 }
1611 }
1612
1613 None
1614}
1615
1616fn get_opencode_storage_path() -> Option<std::path::PathBuf> {
1618 #[cfg(target_os = "windows")]
1619 {
1620 if let Ok(appdata) = std::env::var("APPDATA") {
1621 let opencode_path = std::path::PathBuf::from(&appdata)
1622 .join("OpenCode")
1623 .join("workspaceStorage");
1624 if opencode_path.exists() {
1625 return Some(opencode_path);
1626 }
1627 }
1628 if let Some(local) = dirs::data_local_dir() {
1629 let local_path = local.join("OpenCode").join("sessions");
1630 if local_path.exists() {
1631 return Some(local_path);
1632 }
1633 }
1634 }
1635
1636 #[cfg(target_os = "macos")]
1637 {
1638 if let Some(home) = dirs::home_dir() {
1639 let opencode_path = home
1640 .join("Library")
1641 .join("Application Support")
1642 .join("OpenCode")
1643 .join("workspaceStorage");
1644 if opencode_path.exists() {
1645 return Some(opencode_path);
1646 }
1647 }
1648 }
1649
1650 #[cfg(target_os = "linux")]
1651 {
1652 if let Some(config) = dirs::config_dir() {
1653 let opencode_path = config.join("opencode").join("workspaceStorage");
1654 if opencode_path.exists() {
1655 return Some(opencode_path);
1656 }
1657 }
1658 }
1659
1660 None
1661}
1662
1663fn get_openclaw_storage_path() -> Option<std::path::PathBuf> {
1665 #[cfg(target_os = "windows")]
1666 {
1667 if let Ok(appdata) = std::env::var("APPDATA") {
1668 let openclaw_path = std::path::PathBuf::from(&appdata)
1669 .join("OpenClaw")
1670 .join("workspaceStorage");
1671 if openclaw_path.exists() {
1672 return Some(openclaw_path);
1673 }
1674 }
1675 if let Some(local) = dirs::data_local_dir() {
1676 let local_path = local.join("OpenClaw").join("sessions");
1677 if local_path.exists() {
1678 return Some(local_path);
1679 }
1680 }
1681 }
1682
1683 #[cfg(target_os = "macos")]
1684 {
1685 if let Some(home) = dirs::home_dir() {
1686 let openclaw_path = home
1687 .join("Library")
1688 .join("Application Support")
1689 .join("OpenClaw")
1690 .join("workspaceStorage");
1691 if openclaw_path.exists() {
1692 return Some(openclaw_path);
1693 }
1694 }
1695 }
1696
1697 #[cfg(target_os = "linux")]
1698 {
1699 if let Some(config) = dirs::config_dir() {
1700 let openclaw_path = config.join("openclaw").join("workspaceStorage");
1701 if openclaw_path.exists() {
1702 return Some(openclaw_path);
1703 }
1704 }
1705 }
1706
1707 None
1708}
1709
1710fn get_antigravity_storage_path() -> Option<std::path::PathBuf> {
1712 #[cfg(target_os = "windows")]
1713 {
1714 if let Ok(appdata) = std::env::var("APPDATA") {
1715 let antigrav_path = std::path::PathBuf::from(&appdata)
1716 .join("Antigravity")
1717 .join("workspaceStorage");
1718 if antigrav_path.exists() {
1719 return Some(antigrav_path);
1720 }
1721 }
1722 if let Some(local) = dirs::data_local_dir() {
1723 let local_path = local.join("Antigravity").join("sessions");
1724 if local_path.exists() {
1725 return Some(local_path);
1726 }
1727 }
1728 }
1729
1730 #[cfg(target_os = "macos")]
1731 {
1732 if let Some(home) = dirs::home_dir() {
1733 let antigrav_path = home
1734 .join("Library")
1735 .join("Application Support")
1736 .join("Antigravity")
1737 .join("workspaceStorage");
1738 if antigrav_path.exists() {
1739 return Some(antigrav_path);
1740 }
1741 }
1742 }
1743
1744 #[cfg(target_os = "linux")]
1745 {
1746 if let Some(config) = dirs::config_dir() {
1747 let antigrav_path = config.join("antigravity").join("workspaceStorage");
1748 if antigrav_path.exists() {
1749 return Some(antigrav_path);
1750 }
1751 }
1752 }
1753
1754 None
1755}
1756
1757fn get_codexcli_storage_path() -> Option<std::path::PathBuf> {
1760 if let Some(home) = dirs::home_dir() {
1761 let codex_path = home.join(".codex").join("sessions");
1762 if codex_path.exists() {
1763 return Some(codex_path);
1764 }
1765 }
1766
1767 #[cfg(target_os = "windows")]
1768 {
1769 if let Ok(appdata) = std::env::var("APPDATA") {
1770 let codex_path = std::path::PathBuf::from(&appdata)
1771 .join("codex")
1772 .join("sessions");
1773 if codex_path.exists() {
1774 return Some(codex_path);
1775 }
1776 }
1777 if let Some(local) = dirs::data_local_dir() {
1778 let local_path = local.join("codex").join("sessions");
1779 if local_path.exists() {
1780 return Some(local_path);
1781 }
1782 }
1783 }
1784
1785 None
1786}
1787
1788fn get_droidcli_storage_path() -> Option<std::path::PathBuf> {
1791 if let Some(home) = dirs::home_dir() {
1792 let droid_path = home.join(".factory").join("sessions");
1793 if droid_path.exists() {
1794 return Some(droid_path);
1795 }
1796 }
1797
1798 #[cfg(target_os = "windows")]
1799 {
1800 if let Ok(appdata) = std::env::var("APPDATA") {
1801 let droid_path = std::path::PathBuf::from(&appdata)
1802 .join("factory")
1803 .join("sessions");
1804 if droid_path.exists() {
1805 return Some(droid_path);
1806 }
1807 }
1808 if let Some(local) = dirs::data_local_dir() {
1809 let local_path = local.join("factory").join("sessions");
1810 if local_path.exists() {
1811 return Some(local_path);
1812 }
1813 }
1814 }
1815
1816 None
1817}
1818
1819fn get_geminicli_storage_path() -> Option<std::path::PathBuf> {
1822 if let Some(home) = dirs::home_dir() {
1823 let gemini_path = home.join(".gemini").join("tmp");
1824 if gemini_path.exists() {
1825 return Some(gemini_path);
1826 }
1827 }
1828
1829 #[cfg(target_os = "windows")]
1830 {
1831 if let Ok(appdata) = std::env::var("APPDATA") {
1832 let gemini_path = std::path::PathBuf::from(&appdata)
1833 .join("gemini")
1834 .join("tmp");
1835 if gemini_path.exists() {
1836 return Some(gemini_path);
1837 }
1838 }
1839 if let Some(local) = dirs::data_local_dir() {
1840 let local_path = local.join("gemini").join("tmp");
1841 if local_path.exists() {
1842 return Some(local_path);
1843 }
1844 }
1845 }
1846
1847 None
1848}
1849
1850pub fn list_agents_sessions(
1852 project_path: Option<&str>,
1853 show_size: bool,
1854 provider: Option<&str>,
1855) -> Result<()> {
1856 let storage_paths = get_agent_storage_paths(provider)?;
1858
1859 if storage_paths.is_empty() {
1860 if let Some(p) = provider {
1861 println!("No storage found for provider: {}", p);
1862 println!("\nSupported providers: vscode, cursor, claudecode, opencode, openclaw, antigravity, codexcli, droidcli, geminicli");
1863 } else {
1864 println!("No workspaces found");
1865 }
1866 return Ok(());
1867 }
1868
1869 #[derive(Tabled)]
1870 struct AgentSessionRow {
1871 #[tabled(rename = "Provider")]
1872 provider: String,
1873 #[tabled(rename = "Project")]
1874 project: String,
1875 #[tabled(rename = "Session ID")]
1876 session_id: String,
1877 #[tabled(rename = "Last Modified")]
1878 last_modified: String,
1879 #[tabled(rename = "Files")]
1880 file_count: usize,
1881 }
1882
1883 #[derive(Tabled)]
1884 struct AgentSessionRowWithSize {
1885 #[tabled(rename = "Provider")]
1886 provider: String,
1887 #[tabled(rename = "Project")]
1888 project: String,
1889 #[tabled(rename = "Session ID")]
1890 session_id: String,
1891 #[tabled(rename = "Last Modified")]
1892 last_modified: String,
1893 #[tabled(rename = "Files")]
1894 file_count: usize,
1895 #[tabled(rename = "Size")]
1896 size: String,
1897 }
1898
1899 let target_path = project_path.map(crate::workspace::normalize_path);
1900 let mut total_size: u64 = 0;
1901 let mut rows_with_size: Vec<AgentSessionRowWithSize> = Vec::new();
1902 let mut rows: Vec<AgentSessionRow> = Vec::new();
1903
1904 for (provider_name, storage_path) in &storage_paths {
1905 if !storage_path.exists() {
1906 continue;
1907 }
1908
1909 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
1910 let workspace_dir = entry.path();
1911 if !workspace_dir.is_dir() {
1912 continue;
1913 }
1914
1915 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
1916 if !agent_sessions_dir.exists() {
1917 continue;
1918 }
1919
1920 let workspace_json = workspace_dir.join("workspace.json");
1922 let project = std::fs::read_to_string(&workspace_json)
1923 .ok()
1924 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
1925 .and_then(|ws| {
1926 ws.folder
1927 .map(|f| crate::workspace::decode_workspace_folder(&f))
1928 });
1929
1930 if let Some(ref target) = target_path {
1932 if project
1933 .as_ref()
1934 .map(|p| crate::workspace::normalize_path(p) != *target)
1935 .unwrap_or(true)
1936 {
1937 continue;
1938 }
1939 }
1940
1941 let project_name = project
1942 .as_ref()
1943 .and_then(|p| std::path::Path::new(p).file_name())
1944 .map(|n| n.to_string_lossy().to_string())
1945 .unwrap_or_else(|| entry.file_name().to_string_lossy()[..8].to_string());
1946
1947 for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
1949 let session_dir = session_entry.path();
1950 if !session_dir.is_dir() {
1951 continue;
1952 }
1953
1954 let session_id = session_entry.file_name().to_string_lossy().to_string();
1955 let short_id = if session_id.len() > 8 {
1956 format!("{}...", &session_id[..8])
1957 } else {
1958 session_id.clone()
1959 };
1960
1961 let mut last_mod = std::time::SystemTime::UNIX_EPOCH;
1963 let mut file_count = 0;
1964 let mut session_size: u64 = 0;
1965
1966 if let Ok(files) = std::fs::read_dir(&session_dir) {
1967 for file in files.filter_map(|f| f.ok()) {
1968 file_count += 1;
1969 if let Ok(meta) = file.metadata() {
1970 session_size += meta.len();
1971 if let Ok(mod_time) = meta.modified() {
1972 if mod_time > last_mod {
1973 last_mod = mod_time;
1974 }
1975 }
1976 }
1977 }
1978 }
1979
1980 total_size += session_size;
1981
1982 let modified = if last_mod != std::time::SystemTime::UNIX_EPOCH {
1983 let datetime: chrono::DateTime<chrono::Utc> = last_mod.into();
1984 datetime.format("%Y-%m-%d %H:%M").to_string()
1985 } else {
1986 "unknown".to_string()
1987 };
1988
1989 if show_size {
1990 rows_with_size.push(AgentSessionRowWithSize {
1991 provider: provider_name.clone(),
1992 project: project_name.clone(),
1993 session_id: short_id,
1994 last_modified: modified,
1995 file_count,
1996 size: format_file_size(session_size),
1997 });
1998 } else {
1999 rows.push(AgentSessionRow {
2000 provider: provider_name.clone(),
2001 project: project_name.clone(),
2002 session_id: short_id,
2003 last_modified: modified,
2004 file_count,
2005 });
2006 }
2007 }
2008 }
2009 }
2010
2011 if show_size {
2012 if rows_with_size.is_empty() {
2013 println!("No agent mode sessions found.");
2014 return Ok(());
2015 }
2016 let table = Table::new(&rows_with_size)
2017 .with(TableStyle::ascii_rounded())
2018 .to_string();
2019 println!("{}", table);
2020 println!(
2021 "\nTotal agent sessions: {} ({})",
2022 rows_with_size.len(),
2023 format_file_size(total_size)
2024 );
2025 } else {
2026 if rows.is_empty() {
2027 println!("No agent mode sessions found.");
2028 return Ok(());
2029 }
2030 let table = Table::new(&rows)
2031 .with(TableStyle::ascii_rounded())
2032 .to_string();
2033 println!("{}", table);
2034 println!("\nTotal agent sessions: {}", rows.len());
2035 }
2036
2037 Ok(())
2038}
2039
2040pub fn show_agent_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
2042 use colored::*;
2043
2044 let storage_path = crate::workspace::get_workspace_storage_path()?;
2045 let session_id_lower = session_id.to_lowercase();
2046 let target_path = project_path.map(crate::workspace::normalize_path);
2047
2048 for entry in std::fs::read_dir(&storage_path)?.filter_map(|e| e.ok()) {
2049 let workspace_dir = entry.path();
2050 if !workspace_dir.is_dir() {
2051 continue;
2052 }
2053
2054 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
2055 if !agent_sessions_dir.exists() {
2056 continue;
2057 }
2058
2059 let workspace_json = workspace_dir.join("workspace.json");
2061 let project = std::fs::read_to_string(&workspace_json)
2062 .ok()
2063 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
2064 .and_then(|ws| {
2065 ws.folder
2066 .map(|f| crate::workspace::decode_workspace_folder(&f))
2067 });
2068
2069 if let Some(ref target) = target_path {
2071 if project
2072 .as_ref()
2073 .map(|p| crate::workspace::normalize_path(p) != *target)
2074 .unwrap_or(true)
2075 {
2076 continue;
2077 }
2078 }
2079
2080 for session_entry in std::fs::read_dir(&agent_sessions_dir)?.filter_map(|e| e.ok()) {
2081 let full_id = session_entry.file_name().to_string_lossy().to_string();
2082 if !full_id.to_lowercase().contains(&session_id_lower) {
2083 continue;
2084 }
2085
2086 let session_dir = session_entry.path();
2087
2088 println!("\n{}", "=".repeat(60).bright_blue());
2089 println!("{}", "Agent Session Details".bright_blue().bold());
2090 println!("{}", "=".repeat(60).bright_blue());
2091
2092 println!(
2093 "{}: {}",
2094 "Session ID".bright_white().bold(),
2095 full_id.bright_cyan()
2096 );
2097 println!(
2098 "{}: {}",
2099 "Project".bright_white().bold(),
2100 project.as_deref().unwrap_or("(none)")
2101 );
2102 println!(
2103 "{}: {}",
2104 "Path".bright_white().bold(),
2105 session_dir.display()
2106 );
2107
2108 println!("\n{}", "Session Files:".bright_yellow());
2110 let mut total_size: u64 = 0;
2111 if let Ok(files) = std::fs::read_dir(&session_dir) {
2112 for file in files.filter_map(|f| f.ok()) {
2113 let _path = file.path();
2114 let name = file.file_name().to_string_lossy().to_string();
2115 let size = file.metadata().map(|m| m.len()).unwrap_or(0);
2116 total_size += size;
2117 println!(" {} ({})", name.dimmed(), format_file_size(size));
2118 }
2119 }
2120 println!(
2121 "\n{}: {}",
2122 "Total Size".bright_white().bold(),
2123 format_file_size(total_size)
2124 );
2125
2126 return Ok(());
2127 }
2128 }
2129
2130 println!(
2131 "{} No agent session found matching '{}'",
2132 "!".yellow(),
2133 session_id
2134 );
2135 Ok(())
2136}
2137
2138pub fn show_timeline(
2140 project_path: Option<&str>,
2141 include_agents: bool,
2142 provider: Option<&str>,
2143 all_providers: bool,
2144) -> Result<()> {
2145 use colored::*;
2146 use std::collections::BTreeMap;
2147
2148 let storage_paths = if all_providers {
2150 get_agent_storage_paths(Some("all"))?
2151 } else if let Some(p) = provider {
2152 get_agent_storage_paths(Some(p))?
2153 } else {
2154 let vscode_path = crate::workspace::get_workspace_storage_path()?;
2156 if vscode_path.exists() {
2157 vec![("vscode".to_string(), vscode_path)]
2158 } else {
2159 vec![]
2160 }
2161 };
2162
2163 if storage_paths.is_empty() {
2164 if let Some(p) = provider {
2165 println!("No storage found for provider: {}", p);
2166 } else {
2167 println!("No workspaces found");
2168 }
2169 return Ok(());
2170 }
2171
2172 let target_path = project_path.map(crate::workspace::normalize_path);
2173
2174 let mut date_activity: BTreeMap<chrono::NaiveDate, (usize, usize)> = BTreeMap::new();
2176 let mut project_name = String::new();
2177 let mut providers_scanned: Vec<String> = Vec::new();
2178
2179 for (provider_name, storage_path) in &storage_paths {
2180 if !storage_path.exists() {
2181 continue;
2182 }
2183 providers_scanned.push(provider_name.clone());
2184
2185 for entry in std::fs::read_dir(storage_path)?.filter_map(|e| e.ok()) {
2186 let workspace_dir = entry.path();
2187 if !workspace_dir.is_dir() {
2188 continue;
2189 }
2190
2191 let workspace_json = workspace_dir.join("workspace.json");
2193 let project = std::fs::read_to_string(&workspace_json)
2194 .ok()
2195 .and_then(|c| serde_json::from_str::<crate::models::WorkspaceJson>(&c).ok())
2196 .and_then(|ws| {
2197 ws.folder
2198 .map(|f| crate::workspace::decode_workspace_folder(&f))
2199 });
2200
2201 if let Some(ref target) = target_path {
2203 if project
2204 .as_ref()
2205 .map(|p| crate::workspace::normalize_path(p) != *target)
2206 .unwrap_or(true)
2207 {
2208 continue;
2209 }
2210 if project_name.is_empty() {
2211 project_name = std::path::Path::new(target)
2212 .file_name()
2213 .map(|n| n.to_string_lossy().to_string())
2214 .unwrap_or_else(|| target.clone());
2215 }
2216 }
2217
2218 let chat_sessions_dir = workspace_dir.join("chatSessions");
2220 if chat_sessions_dir.exists() {
2221 if let Ok(files) = std::fs::read_dir(&chat_sessions_dir) {
2222 for file in files.filter_map(|f| f.ok()) {
2223 if let Ok(meta) = file.metadata() {
2224 if let Ok(modified) = meta.modified() {
2225 let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2226 let date = datetime.date_naive();
2227 let entry = date_activity.entry(date).or_insert((0, 0));
2228 entry.0 += 1;
2229 }
2230 }
2231 }
2232 }
2233 }
2234
2235 if include_agents {
2237 let agent_sessions_dir = workspace_dir.join("chatEditingSessions");
2238 if agent_sessions_dir.exists() {
2239 if let Ok(dirs) = std::fs::read_dir(&agent_sessions_dir) {
2240 for dir in dirs.filter_map(|d| d.ok()) {
2241 if let Ok(meta) = dir.metadata() {
2242 if let Ok(modified) = meta.modified() {
2243 let datetime: chrono::DateTime<chrono::Utc> = modified.into();
2244 let date = datetime.date_naive();
2245 let entry = date_activity.entry(date).or_insert((0, 0));
2246 entry.1 += 1;
2247 }
2248 }
2249 }
2250 }
2251 }
2252 }
2253 }
2254 }
2255
2256 if date_activity.is_empty() {
2257 println!("No session activity found.");
2258 return Ok(());
2259 }
2260
2261 let title = if project_name.is_empty() {
2262 "All Workspaces".to_string()
2263 } else {
2264 project_name
2265 };
2266
2267 let provider_info = if providers_scanned.len() > 1 || all_providers {
2268 format!(" ({})", providers_scanned.join(", "))
2269 } else {
2270 String::new()
2271 };
2272
2273 println!(
2274 "\n{} Session Timeline: {}{}",
2275 "[*]".blue(),
2276 title.cyan(),
2277 provider_info.dimmed()
2278 );
2279 println!("{}", "=".repeat(60));
2280
2281 let dates: Vec<_> = date_activity.keys().collect();
2282 let first_date = **dates.first().unwrap();
2283 let last_date = **dates.last().unwrap();
2284
2285 println!(
2286 "Range: {} to {}",
2287 first_date.format("%Y-%m-%d"),
2288 last_date.format("%Y-%m-%d")
2289 );
2290 println!();
2291
2292 let mut gaps: Vec<(chrono::NaiveDate, chrono::NaiveDate, i64)> = Vec::new();
2294 let mut prev_date: Option<chrono::NaiveDate> = None;
2295
2296 for date in dates.iter() {
2297 if let Some(prev) = prev_date {
2298 let diff = (**date - prev).num_days();
2299 if diff > 1 {
2300 gaps.push((prev, **date, diff));
2301 }
2302 }
2303 prev_date = Some(**date);
2304 }
2305
2306 println!("{}", "Recent Activity:".bright_yellow());
2308 let recent_dates: Vec<_> = date_activity.iter().rev().take(14).collect();
2309 for (date, (chats, agents)) in recent_dates.iter().rev() {
2310 let chat_bar = "█".repeat((*chats).min(20));
2311 let agent_bar = if include_agents && *agents > 0 {
2312 format!(" {}", "▓".repeat((*agents).min(10)).bright_magenta())
2313 } else {
2314 String::new()
2315 };
2316 println!(
2317 " {} │ {}{}",
2318 date.format("%Y-%m-%d"),
2319 chat_bar.bright_green(),
2320 agent_bar
2321 );
2322 }
2323
2324 if !gaps.is_empty() {
2326 println!("\n{}", "Gaps (>1 day):".bright_red());
2327 for (start, end, days) in gaps.iter().take(10) {
2328 println!(
2329 " {} → {} ({} days)",
2330 start.format("%Y-%m-%d"),
2331 end.format("%Y-%m-%d"),
2332 days
2333 );
2334 }
2335 if gaps.len() > 10 {
2336 println!(" ... and {} more gaps", gaps.len() - 10);
2337 }
2338 }
2339
2340 let total_chats: usize = date_activity.values().map(|(c, _)| c).sum();
2342 let total_agents: usize = date_activity.values().map(|(_, a)| a).sum();
2343 let total_days = date_activity.len();
2344 let total_gap_days: i64 = gaps.iter().map(|(_, _, d)| d - 1).sum();
2345
2346 println!("\n{}", "Summary:".bright_white().bold());
2347 println!(" Active days: {}", total_days);
2348 println!(" Chat sessions: {}", total_chats);
2349 if include_agents {
2350 println!(" Agent sessions: {}", total_agents);
2351 }
2352 println!(" Total gap days: {}", total_gap_days);
2353
2354 if include_agents {
2355 println!(
2356 "\n{} {} = chat, {} = agent",
2357 "Legend:".dimmed(),
2358 "█".bright_green(),
2359 "▓".bright_magenta()
2360 );
2361 }
2362
2363 Ok(())
2364}
2365
2366pub fn show_index(project_path: Option<&str>, all: bool) -> Result<()> {
2368 if all {
2369 return show_index_all();
2370 }
2371
2372 use colored::Colorize;
2373 use tabled::{settings::Style as TableStyle, Table, Tabled};
2374
2375 let path = crate::commands::register::resolve_path(project_path);
2376 let path_str = path.to_string_lossy().to_string();
2377
2378 println!(
2379 "{} Session index for: {}",
2380 "[CSM]".cyan().bold(),
2381 path.display()
2382 );
2383
2384 let (ws_id, ws_path, _folder) = crate::workspace::find_workspace_by_path(&path_str)?
2385 .ok_or_else(|| crate::error::CsmError::WorkspaceNotFound(path.display().to_string()))?;
2386
2387 let db_path = crate::storage::get_workspace_storage_db(&ws_id)?;
2388 let index = crate::storage::read_chat_session_index(&db_path)?;
2389
2390 println!(
2391 " Workspace: {} ({})",
2392 ws_id.bright_yellow(),
2393 ws_path.display()
2394 );
2395 println!(
2396 " Index version: {}, entries: {}\n",
2397 index.version,
2398 index.entries.len()
2399 );
2400
2401 #[derive(Tabled)]
2402 struct IndexRow {
2403 #[tabled(rename = "Session ID")]
2404 session_id: String,
2405 #[tabled(rename = "Title")]
2406 title: String,
2407 #[tabled(rename = "isEmpty")]
2408 is_empty: String,
2409 #[tabled(rename = "Last Message")]
2410 last_message: String,
2411 #[tabled(rename = "ResponseState")]
2412 response_state: String,
2413 #[tabled(rename = "Location")]
2414 location: String,
2415 }
2416
2417 let mut rows: Vec<IndexRow> = Vec::new();
2418 for (_, entry) in &index.entries {
2419 let last_msg = if entry.last_message_date > 0 {
2420 let secs = entry.last_message_date / 1000;
2421 chrono::DateTime::from_timestamp(secs, 0)
2422 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
2423 .unwrap_or_else(|| entry.last_message_date.to_string())
2424 } else {
2425 "0".to_string()
2426 };
2427
2428 let state = match entry.last_response_state {
2429 0 => "Pending",
2430 1 => "Complete",
2431 2 => "Cancelled",
2432 3 => "Failed",
2433 4 => "NeedsInput",
2434 _ => "Unknown",
2435 };
2436
2437 rows.push(IndexRow {
2438 session_id: entry.session_id[..12.min(entry.session_id.len())].to_string(),
2439 title: if entry.title.len() > 40 {
2440 format!("{}...", &entry.title[..37])
2441 } else {
2442 entry.title.clone()
2443 },
2444 is_empty: if entry.is_empty {
2445 "true".red().to_string()
2446 } else {
2447 "false".green().to_string()
2448 },
2449 last_message: last_msg,
2450 response_state: state.to_string(),
2451 location: entry.initial_location.clone(),
2452 });
2453 }
2454
2455 rows.sort_by(|a, b| b.last_message.cmp(&a.last_message));
2457
2458 let table = Table::new(&rows)
2459 .with(TableStyle::ascii_rounded())
2460 .to_string();
2461 println!("{}", table);
2462
2463 let chat_dir = ws_path.join("chatSessions");
2465 if chat_dir.exists() {
2466 let mut disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2467 for entry in std::fs::read_dir(&chat_dir)? {
2468 let entry = entry?;
2469 let p = entry.path();
2470 if p.extension()
2471 .map(crate::storage::is_session_file_extension)
2472 .unwrap_or(false)
2473 {
2474 if let Some(stem) = p.file_stem() {
2475 disk_ids.insert(stem.to_string_lossy().to_string());
2476 }
2477 }
2478 }
2479 let indexed_ids: std::collections::HashSet<String> =
2480 index.entries.keys().cloned().collect();
2481 let orphaned: Vec<_> = disk_ids.difference(&indexed_ids).collect();
2482 let stale: Vec<_> = indexed_ids.difference(&disk_ids).collect();
2483
2484 if !orphaned.is_empty() {
2485 println!(
2486 "\n{} {} session(s) on disk but NOT in index (orphaned):",
2487 "[!]".yellow(),
2488 orphaned.len()
2489 );
2490 for id in &orphaned {
2491 println!(" {}", id.red());
2492 }
2493 }
2494 if !stale.is_empty() {
2495 println!(
2496 "\n{} {} index entries with NO file on disk (stale):",
2497 "[!]".yellow(),
2498 stale.len()
2499 );
2500 for id in &stale {
2501 println!(" {}", id.red());
2502 }
2503 }
2504 if orphaned.is_empty() && stale.is_empty() {
2505 println!("\n{} Index is in sync with files on disk.", "[OK]".green());
2506 }
2507 }
2508
2509 Ok(())
2510}
2511
2512fn show_index_all() -> Result<()> {
2514 use colored::Colorize;
2515
2516 println!(
2517 "{} Scanning all workspace indexes...\n",
2518 "[CSM]".cyan().bold(),
2519 );
2520
2521 let workspaces = crate::workspace::discover_workspaces()?;
2522 let ws_with_sessions: Vec<_> = workspaces
2523 .iter()
2524 .filter(|w| w.has_chat_sessions && w.chat_session_count > 0)
2525 .collect();
2526
2527 if ws_with_sessions.is_empty() {
2528 println!("{} No workspaces with chat sessions found.", "[!]".yellow());
2529 return Ok(());
2530 }
2531
2532 let mut total_entries = 0usize;
2533 let mut total_non_empty = 0usize;
2534 let mut total_orphaned = 0usize;
2535 let mut total_stale = 0usize;
2536 let mut _sync_ok = 0usize;
2537 let mut sync_issues = 0usize;
2538
2539 for (i, ws) in ws_with_sessions.iter().enumerate() {
2540 let display_name = ws
2541 .project_path
2542 .as_deref()
2543 .unwrap_or(&ws.hash);
2544
2545 let db_path = match crate::storage::get_workspace_storage_db(&ws.hash) {
2546 Ok(p) => p,
2547 Err(_) => {
2548 println!(
2549 "[{}/{}] {} {} — {} no state.vscdb",
2550 i + 1,
2551 ws_with_sessions.len(),
2552 display_name.cyan(),
2553 "".dimmed(),
2554 "[!]".yellow()
2555 );
2556 continue;
2557 }
2558 };
2559
2560 let index = match crate::storage::read_chat_session_index(&db_path) {
2561 Ok(idx) => idx,
2562 Err(_) => {
2563 println!(
2564 "[{}/{}] {} — {} no index in state.vscdb",
2565 i + 1,
2566 ws_with_sessions.len(),
2567 display_name.cyan(),
2568 "[!]".yellow()
2569 );
2570 continue;
2571 }
2572 };
2573
2574 let non_empty = index.entries.values().filter(|e| !e.is_empty).count();
2575 total_entries += index.entries.len();
2576 total_non_empty += non_empty;
2577
2578 let chat_dir = ws.workspace_path.join("chatSessions");
2580 let mut orphaned = 0usize;
2581 let mut stale = 0usize;
2582 if chat_dir.exists() {
2583 let mut disk_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
2584 if let Ok(entries) = std::fs::read_dir(&chat_dir) {
2585 for entry in entries.flatten() {
2586 let p = entry.path();
2587 if p.extension()
2588 .map(crate::storage::is_session_file_extension)
2589 .unwrap_or(false)
2590 {
2591 if let Some(stem) = p.file_stem() {
2592 disk_ids.insert(stem.to_string_lossy().to_string());
2593 }
2594 }
2595 }
2596 }
2597 let indexed_ids: std::collections::HashSet<String> =
2598 index.entries.keys().cloned().collect();
2599 orphaned = disk_ids.difference(&indexed_ids).count();
2600 stale = indexed_ids.difference(&disk_ids).count();
2601 }
2602 total_orphaned += orphaned;
2603 total_stale += stale;
2604
2605 let status = if orphaned == 0 && stale == 0 {
2606 _sync_ok += 1;
2607 "[OK]".green().to_string()
2608 } else {
2609 sync_issues += 1;
2610 format!(
2611 "{}{}",
2612 if orphaned > 0 {
2613 format!("{} orphaned ", orphaned).yellow().to_string()
2614 } else {
2615 String::new()
2616 },
2617 if stale > 0 {
2618 format!("{} stale", stale).yellow().to_string()
2619 } else {
2620 String::new()
2621 }
2622 )
2623 };
2624
2625 println!(
2626 "[{:>3}/{}] {} — {} entries ({} with content) {}",
2627 i + 1,
2628 ws_with_sessions.len(),
2629 display_name.cyan(),
2630 index.entries.len(),
2631 non_empty.to_string().green(),
2632 status
2633 );
2634 }
2635
2636 println!(
2637 "\n{} {} workspaces, {} index entries ({} with content)",
2638 "[OK]".green().bold(),
2639 ws_with_sessions.len().to_string().cyan(),
2640 total_entries.to_string().cyan(),
2641 total_non_empty.to_string().green()
2642 );
2643 if sync_issues > 0 {
2644 println!(
2645 " {} {}/{} workspaces have sync issues ({} orphaned, {} stale)",
2646 "[!]".yellow(),
2647 sync_issues,
2648 ws_with_sessions.len(),
2649 total_orphaned,
2650 total_stale
2651 );
2652 } else {
2653 println!(
2654 " {} All indexes in sync with files on disk.",
2655 "[OK]".green()
2656 );
2657 }
2658
2659 Ok(())
2660}