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