1use anyhow::Result;
6use tabled::{settings::Style, Table, Tabled};
7
8use crate::models::Workspace;
9use crate::storage::read_empty_window_sessions;
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
36pub fn list_workspaces() -> Result<()> {
38 let workspaces = discover_workspaces()?;
39
40 if workspaces.is_empty() {
41 println!("No workspaces found.");
42 return Ok(());
43 }
44
45 let rows: Vec<WorkspaceRow> = workspaces
46 .iter()
47 .map(|ws| WorkspaceRow {
48 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
49 project_path: ws
50 .project_path
51 .clone()
52 .unwrap_or_else(|| "(none)".to_string()),
53 sessions: ws.chat_session_count,
54 has_chats: if ws.has_chat_sessions {
55 "Yes".to_string()
56 } else {
57 "No".to_string()
58 },
59 })
60 .collect();
61
62 let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
63
64 println!("{}", table);
65 println!("\nTotal workspaces: {}", workspaces.len());
66
67 if let Ok(empty_count) = crate::storage::count_empty_window_sessions() {
69 if empty_count > 0 {
70 println!("Empty window sessions (ALL SESSIONS): {}", empty_count);
71 }
72 }
73
74 Ok(())
75}
76
77pub fn list_sessions(project_path: Option<&str>) -> Result<()> {
79 let workspaces = discover_workspaces()?;
80
81 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
82 let normalized = crate::workspace::normalize_path(path);
83 workspaces
84 .iter()
85 .filter(|ws| {
86 ws.project_path
87 .as_ref()
88 .map(|p| crate::workspace::normalize_path(p) == normalized)
89 .unwrap_or(false)
90 })
91 .collect()
92 } else {
93 workspaces.iter().collect()
94 };
95
96 let mut rows: Vec<SessionRow> = Vec::new();
97
98 if project_path.is_none() {
100 if let Ok(empty_sessions) = read_empty_window_sessions() {
101 for session in empty_sessions {
102 let modified = chrono::DateTime::from_timestamp_millis(session.last_message_date)
103 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
104 .unwrap_or_else(|| "unknown".to_string());
105
106 let session_id = session.session_id.as_deref().unwrap_or("unknown");
107 rows.push(SessionRow {
108 project_path: "(ALL SESSIONS)".to_string(),
109 session_file: format!("{}.json", session_id),
110 last_modified: modified,
111 messages: session.request_count(),
112 });
113 }
114 }
115 }
116
117 for ws in filtered_workspaces {
118 if !ws.has_chat_sessions {
119 continue;
120 }
121
122 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
123
124 for session_with_path in sessions {
125 let modified = session_with_path
126 .path
127 .metadata()
128 .ok()
129 .and_then(|m| m.modified().ok())
130 .map(|t| {
131 let datetime: chrono::DateTime<chrono::Utc> = t.into();
132 datetime.format("%Y-%m-%d %H:%M").to_string()
133 })
134 .unwrap_or_else(|| "unknown".to_string());
135
136 rows.push(SessionRow {
137 project_path: ws
138 .project_path
139 .clone()
140 .unwrap_or_else(|| "(none)".to_string()),
141 session_file: session_with_path
142 .path
143 .file_name()
144 .map(|n| n.to_string_lossy().to_string())
145 .unwrap_or_else(|| "unknown".to_string()),
146 last_modified: modified,
147 messages: session_with_path.session.request_count(),
148 });
149 }
150 }
151
152 if rows.is_empty() {
153 println!("No chat sessions found.");
154 return Ok(());
155 }
156
157 let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
158
159 println!("{}", table);
160 println!("\nTotal sessions: {}", rows.len());
161
162 Ok(())
163}
164
165pub fn find_workspaces(pattern: &str) -> Result<()> {
167 let workspaces = discover_workspaces()?;
168
169 let pattern = if pattern == "." {
171 std::env::current_dir()
172 .ok()
173 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
174 .unwrap_or_else(|| pattern.to_string())
175 } else {
176 pattern.to_string()
177 };
178 let pattern_lower = pattern.to_lowercase();
179
180 let matching: Vec<&Workspace> = workspaces
181 .iter()
182 .filter(|ws| {
183 ws.project_path
184 .as_ref()
185 .map(|p| p.to_lowercase().contains(&pattern_lower))
186 .unwrap_or(false)
187 || ws.hash.to_lowercase().contains(&pattern_lower)
188 })
189 .collect();
190
191 if matching.is_empty() {
192 println!("No workspaces found matching '{}'", pattern);
193 return Ok(());
194 }
195
196 let rows: Vec<WorkspaceRow> = matching
197 .iter()
198 .map(|ws| WorkspaceRow {
199 hash: format!("{}...", &ws.hash[..12.min(ws.hash.len())]),
200 project_path: ws
201 .project_path
202 .clone()
203 .unwrap_or_else(|| "(none)".to_string()),
204 sessions: ws.chat_session_count,
205 has_chats: if ws.has_chat_sessions {
206 "Yes".to_string()
207 } else {
208 "No".to_string()
209 },
210 })
211 .collect();
212
213 let table = Table::new(rows).with(Style::ascii_rounded()).to_string();
214
215 println!("{}", table);
216 println!("\nFound {} matching workspace(s)", matching.len());
217
218 for ws in &matching {
220 if ws.has_chat_sessions {
221 let project = ws.project_path.as_deref().unwrap_or("(none)");
222 println!("\nSessions for {}:", project);
223
224 if let Ok(sessions) =
225 crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)
226 {
227 for session_with_path in sessions {
228 println!(" {}", session_with_path.path.display());
229 }
230 }
231 }
232 }
233
234 Ok(())
235}
236
237pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
239 let workspaces = discover_workspaces()?;
240 let pattern_lower = pattern.to_lowercase();
241
242 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
243 let normalized = crate::workspace::normalize_path(path);
244 workspaces
245 .iter()
246 .filter(|ws| {
247 ws.project_path
248 .as_ref()
249 .map(|p| crate::workspace::normalize_path(p) == normalized)
250 .unwrap_or(false)
251 })
252 .collect()
253 } else {
254 workspaces.iter().collect()
255 };
256
257 let mut rows: Vec<SessionRow> = Vec::new();
258
259 for ws in filtered_workspaces {
260 if !ws.has_chat_sessions {
261 continue;
262 }
263
264 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
265
266 for session_with_path in sessions {
267 let session_id_matches = session_with_path
269 .session
270 .session_id
271 .as_ref()
272 .map(|id| id.to_lowercase().contains(&pattern_lower))
273 .unwrap_or(false);
274 let title_matches = session_with_path
275 .session
276 .title()
277 .to_lowercase()
278 .contains(&pattern_lower);
279 let content_matches = session_with_path.session.requests.iter().any(|r| {
280 r.message
281 .as_ref()
282 .map(|m| {
283 m.text
284 .as_ref()
285 .map(|t| t.to_lowercase().contains(&pattern_lower))
286 .unwrap_or(false)
287 })
288 .unwrap_or(false)
289 });
290
291 if !session_id_matches && !title_matches && !content_matches {
292 continue;
293 }
294
295 let modified = session_with_path
296 .path
297 .metadata()
298 .ok()
299 .and_then(|m| m.modified().ok())
300 .map(|t| {
301 let datetime: chrono::DateTime<chrono::Utc> = t.into();
302 datetime.format("%Y-%m-%d %H:%M").to_string()
303 })
304 .unwrap_or_else(|| "unknown".to_string());
305
306 rows.push(SessionRow {
307 project_path: ws
308 .project_path
309 .clone()
310 .unwrap_or_else(|| "(none)".to_string()),
311 session_file: session_with_path
312 .path
313 .file_name()
314 .map(|n| n.to_string_lossy().to_string())
315 .unwrap_or_else(|| "unknown".to_string()),
316 last_modified: modified,
317 messages: session_with_path.session.request_count(),
318 });
319 }
320 }
321
322 if rows.is_empty() {
323 println!("No sessions found matching '{}'", pattern);
324 return Ok(());
325 }
326
327 let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
328
329 println!("{}", table);
330 println!("\nFound {} matching session(s)", rows.len());
331
332 Ok(())
333}
334
335pub fn find_sessions_filtered(
344 pattern: &str,
345 workspace_filter: Option<&str>,
346 title_only: bool,
347 search_content: bool,
348 after: Option<&str>,
349 before: Option<&str>,
350 limit: usize,
351) -> Result<()> {
352 use chrono::{NaiveDate, Utc};
353 use rayon::prelude::*;
354 use std::sync::atomic::{AtomicUsize, Ordering};
355
356 let pattern_lower = pattern.to_lowercase();
357
358 let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
360 let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
361
362 let storage_path = crate::workspace::get_workspace_storage_path()?;
364 if !storage_path.exists() {
365 println!("No workspaces found");
366 return Ok(());
367 }
368
369 let ws_filter_lower = workspace_filter.map(|s| s.to_lowercase());
371
372 let workspace_dirs: Vec<_> = std::fs::read_dir(&storage_path)?
373 .filter_map(|e| e.ok())
374 .filter(|e| e.path().is_dir())
375 .filter_map(|entry| {
376 let workspace_dir = entry.path();
377 let workspace_json_path = workspace_dir.join("workspace.json");
378
379 let chat_sessions_dir = workspace_dir.join("chatSessions");
381 if !chat_sessions_dir.exists() {
382 return None;
383 }
384
385 let project_path =
387 std::fs::read_to_string(&workspace_json_path)
388 .ok()
389 .and_then(|content| {
390 serde_json::from_str::<crate::models::WorkspaceJson>(&content)
391 .ok()
392 .and_then(|ws| {
393 ws.folder
394 .map(|f| crate::workspace::decode_workspace_folder(&f))
395 })
396 });
397
398 if let Some(ref filter) = ws_filter_lower {
400 let hash = entry.file_name().to_string_lossy().to_lowercase();
401 let path_matches = project_path
402 .as_ref()
403 .map(|p| p.to_lowercase().contains(filter))
404 .unwrap_or(false);
405 if !hash.contains(filter) && !path_matches {
406 return None;
407 }
408 }
409
410 let ws_name = project_path
411 .as_ref()
412 .and_then(|p| std::path::Path::new(p).file_name())
413 .map(|n| n.to_string_lossy().to_string())
414 .unwrap_or_else(|| {
415 entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
416 .to_string()
417 });
418
419 Some((chat_sessions_dir, ws_name))
420 })
421 .collect();
422
423 if workspace_dirs.is_empty() {
424 if let Some(ws) = workspace_filter {
425 println!("No workspaces found matching '{}'", ws);
426 } else {
427 println!("No workspaces with chat sessions found");
428 }
429 return Ok(());
430 }
431
432 let session_files: Vec<_> = workspace_dirs
434 .iter()
435 .flat_map(|(chat_dir, ws_name)| {
436 std::fs::read_dir(chat_dir)
437 .into_iter()
438 .flatten()
439 .filter_map(|e| e.ok())
440 .filter(|e| {
441 e.path()
442 .extension()
443 .map(|ext| ext == "json")
444 .unwrap_or(false)
445 })
446 .map(|e| (e.path(), ws_name.clone()))
447 .collect::<Vec<_>>()
448 })
449 .collect();
450
451 let total_files = session_files.len();
452 let scanned = AtomicUsize::new(0);
453 let skipped_by_date = AtomicUsize::new(0);
454
455 let mut results: Vec<_> = session_files
457 .par_iter()
458 .filter_map(|(path, ws_name)| {
459 if after_date.is_some() || before_date.is_some() {
461 if let Ok(metadata) = path.metadata() {
462 if let Ok(modified) = metadata.modified() {
463 let file_date: chrono::DateTime<Utc> = modified.into();
464 let file_naive = file_date.date_naive();
465
466 if let Some(after) = after_date {
467 if file_naive < after {
468 skipped_by_date.fetch_add(1, Ordering::Relaxed);
469 return None;
470 }
471 }
472 if let Some(before) = before_date {
473 if file_naive > before {
474 skipped_by_date.fetch_add(1, Ordering::Relaxed);
475 return None;
476 }
477 }
478 }
479 }
480 }
481
482 scanned.fetch_add(1, Ordering::Relaxed);
483
484 let content = match std::fs::read_to_string(path) {
486 Ok(c) => c,
487 Err(_) => return None,
488 };
489
490 let title =
492 extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
493 let title_lower = title.to_lowercase();
494
495 let session_id = path
497 .file_stem()
498 .map(|n| n.to_string_lossy().to_string())
499 .unwrap_or_default();
500 let id_matches =
501 !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
502
503 let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
505
506 let content_matches = if search_content
508 && !title_only
509 && !id_matches
510 && !title_matches
511 && !pattern_lower.is_empty()
512 {
513 content.to_lowercase().contains(&pattern_lower)
514 } else {
515 false
516 };
517
518 let matches =
520 pattern_lower.is_empty() || id_matches || title_matches || content_matches;
521 if !matches {
522 return None;
523 }
524
525 let match_type = if pattern_lower.is_empty() {
526 ""
527 } else if id_matches {
528 "ID"
529 } else if title_matches {
530 "title"
531 } else {
532 "content"
533 };
534
535 let message_count = content.matches("\"message\":").count();
537
538 let modified = path
540 .metadata()
541 .ok()
542 .and_then(|m| m.modified().ok())
543 .map(|t| {
544 let datetime: chrono::DateTime<chrono::Utc> = t.into();
545 datetime.format("%Y-%m-%d %H:%M").to_string()
546 })
547 .unwrap_or_else(|| "unknown".to_string());
548
549 Some((
550 title,
551 ws_name.clone(),
552 modified,
553 message_count,
554 match_type.to_string(),
555 ))
556 })
557 .collect();
558
559 let scanned_count = scanned.load(Ordering::Relaxed);
560 let skipped_count = skipped_by_date.load(Ordering::Relaxed);
561
562 if results.is_empty() {
563 println!("No sessions found matching '{}'", pattern);
564 if skipped_count > 0 {
565 println!(" ({} sessions skipped due to date filter)", skipped_count);
566 }
567 return Ok(());
568 }
569
570 results.sort_by(|a, b| b.2.cmp(&a.2));
572
573 results.truncate(limit);
575
576 #[derive(Tabled)]
577 struct SearchResultRow {
578 #[tabled(rename = "Title")]
579 title: String,
580 #[tabled(rename = "Workspace")]
581 workspace: String,
582 #[tabled(rename = "Modified")]
583 modified: String,
584 #[tabled(rename = "Msgs")]
585 messages: usize,
586 #[tabled(rename = "Match")]
587 match_type: String,
588 }
589
590 let rows: Vec<SearchResultRow> = results
591 .into_iter()
592 .map(
593 |(title, workspace, modified, messages, match_type)| SearchResultRow {
594 title: truncate_string(&title, 40),
595 workspace: truncate_string(&workspace, 20),
596 modified,
597 messages,
598 match_type,
599 },
600 )
601 .collect();
602
603 let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
604
605 println!("{}", table);
606 println!(
607 "\nFound {} session(s) (scanned {} of {} files{})",
608 rows.len(),
609 scanned_count,
610 total_files,
611 if skipped_count > 0 {
612 format!(", {} skipped by date", skipped_count)
613 } else {
614 String::new()
615 }
616 );
617 if rows.len() >= limit {
618 println!(" (results limited to {}; use --limit to show more)", limit);
619 }
620
621 Ok(())
622}
623
624fn extract_title_from_content(content: &str) -> Option<String> {
626 if let Some(start) = content.find("\"customTitle\"") {
628 if let Some(colon) = content[start..].find(':') {
629 let after_colon = &content[start + colon + 1..];
630 let trimmed = after_colon.trim_start();
631 if trimmed.starts_with('"') {
632 if let Some(end) = trimmed[1..].find('"') {
633 let title = &trimmed[1..end + 1];
634 if !title.is_empty() && title != "null" {
635 return Some(title.to_string());
636 }
637 }
638 }
639 }
640 }
641
642 if let Some(start) = content.find("\"text\"") {
644 if let Some(colon) = content[start..].find(':') {
645 let after_colon = &content[start + colon + 1..];
646 let trimmed = after_colon.trim_start();
647 if trimmed.starts_with('"') {
648 if let Some(end) = trimmed[1..].find('"') {
649 let title = &trimmed[1..end + 1];
650 if !title.is_empty() && title.len() < 100 {
651 return Some(title.to_string());
652 }
653 }
654 }
655 }
656 }
657
658 None
659}
660
661fn extract_title_fast(header: &str) -> Option<String> {
663 extract_title_from_content(header)
664}
665
666fn truncate_string(s: &str, max_len: usize) -> String {
668 if s.len() <= max_len {
669 s.to_string()
670 } else {
671 format!("{}...", &s[..max_len.saturating_sub(3)])
672 }
673}
674
675pub fn show_workspace(workspace: &str) -> Result<()> {
677 use colored::Colorize;
678
679 let workspaces = discover_workspaces()?;
680 let workspace_lower = workspace.to_lowercase();
681
682 let matching: Vec<&Workspace> = workspaces
684 .iter()
685 .filter(|ws| {
686 ws.hash.to_lowercase().contains(&workspace_lower)
687 || ws
688 .project_path
689 .as_ref()
690 .map(|p| p.to_lowercase().contains(&workspace_lower))
691 .unwrap_or(false)
692 })
693 .collect();
694
695 if matching.is_empty() {
696 println!(
697 "{} No workspace found matching '{}'",
698 "!".yellow(),
699 workspace
700 );
701 return Ok(());
702 }
703
704 for ws in matching {
705 println!("\n{}", "=".repeat(60).bright_blue());
706 println!("{}", "Workspace Details".bright_blue().bold());
707 println!("{}", "=".repeat(60).bright_blue());
708
709 println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
710 println!(
711 "{}: {}",
712 "Path".bright_white().bold(),
713 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
714 );
715 println!(
716 "{}: {}",
717 "Has Sessions".bright_white().bold(),
718 if ws.has_chat_sessions {
719 "Yes".green()
720 } else {
721 "No".red()
722 }
723 );
724 println!(
725 "{}: {}",
726 "Workspace Path".bright_white().bold(),
727 ws.workspace_path.display()
728 );
729
730 if ws.has_chat_sessions {
731 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
732 println!(
733 "{}: {}",
734 "Session Count".bright_white().bold(),
735 sessions.len()
736 );
737
738 if !sessions.is_empty() {
739 println!("\n{}", "Sessions:".bright_yellow());
740 for (i, s) in sessions.iter().enumerate() {
741 let title = s.session.title();
742 let msg_count = s.session.request_count();
743 println!(
744 " {}. {} ({} messages)",
745 i + 1,
746 title.bright_cyan(),
747 msg_count
748 );
749 }
750 }
751 }
752 }
753
754 Ok(())
755}
756
757pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
759 use colored::Colorize;
760
761 let workspaces = discover_workspaces()?;
762 let session_id_lower = session_id.to_lowercase();
763
764 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
765 let normalized = crate::workspace::normalize_path(path);
766 workspaces
767 .iter()
768 .filter(|ws| {
769 ws.project_path
770 .as_ref()
771 .map(|p| crate::workspace::normalize_path(p) == normalized)
772 .unwrap_or(false)
773 })
774 .collect()
775 } else {
776 workspaces.iter().collect()
777 };
778
779 for ws in filtered_workspaces {
780 if !ws.has_chat_sessions {
781 continue;
782 }
783
784 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
785
786 for s in sessions {
787 let filename = s
788 .path
789 .file_name()
790 .map(|n| n.to_string_lossy().to_string())
791 .unwrap_or_default();
792
793 let matches = s
794 .session
795 .session_id
796 .as_ref()
797 .map(|id| id.to_lowercase().contains(&session_id_lower))
798 .unwrap_or(false)
799 || filename.to_lowercase().contains(&session_id_lower);
800
801 if matches {
802 println!("\n{}", "=".repeat(60).bright_blue());
803 println!("{}", "Session Details".bright_blue().bold());
804 println!("{}", "=".repeat(60).bright_blue());
805
806 println!(
807 "{}: {}",
808 "Title".bright_white().bold(),
809 s.session.title().bright_cyan()
810 );
811 println!("{}: {}", "File".bright_white().bold(), filename);
812 println!(
813 "{}: {}",
814 "Session ID".bright_white().bold(),
815 s.session
816 .session_id
817 .as_ref()
818 .unwrap_or(&"(none)".to_string())
819 );
820 println!(
821 "{}: {}",
822 "Messages".bright_white().bold(),
823 s.session.request_count()
824 );
825 println!(
826 "{}: {}",
827 "Workspace".bright_white().bold(),
828 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
829 );
830
831 println!("\n{}", "Preview:".bright_yellow());
833 for (i, req) in s.session.requests.iter().take(3).enumerate() {
834 if let Some(msg) = &req.message {
835 if let Some(text) = &msg.text {
836 let preview: String = text.chars().take(100).collect();
837 let truncated = if text.len() > 100 { "..." } else { "" };
838 println!(" {}. {}{}", i + 1, preview.dimmed(), truncated);
839 }
840 }
841 }
842
843 return Ok(());
844 }
845 }
846 }
847
848 println!(
849 "{} No session found matching '{}'",
850 "!".yellow(),
851 session_id
852 );
853 Ok(())
854}