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