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