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
237#[allow(dead_code)]
239pub fn find_sessions(pattern: &str, project_path: Option<&str>) -> Result<()> {
240 let workspaces = discover_workspaces()?;
241 let pattern_lower = pattern.to_lowercase();
242
243 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
244 let normalized = crate::workspace::normalize_path(path);
245 workspaces
246 .iter()
247 .filter(|ws| {
248 ws.project_path
249 .as_ref()
250 .map(|p| crate::workspace::normalize_path(p) == normalized)
251 .unwrap_or(false)
252 })
253 .collect()
254 } else {
255 workspaces.iter().collect()
256 };
257
258 let mut rows: Vec<SessionRow> = Vec::new();
259
260 for ws in filtered_workspaces {
261 if !ws.has_chat_sessions {
262 continue;
263 }
264
265 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
266
267 for session_with_path in sessions {
268 let session_id_matches = session_with_path
270 .session
271 .session_id
272 .as_ref()
273 .map(|id| id.to_lowercase().contains(&pattern_lower))
274 .unwrap_or(false);
275 let title_matches = session_with_path
276 .session
277 .title()
278 .to_lowercase()
279 .contains(&pattern_lower);
280 let content_matches = session_with_path.session.requests.iter().any(|r| {
281 r.message
282 .as_ref()
283 .map(|m| {
284 m.text
285 .as_ref()
286 .map(|t| t.to_lowercase().contains(&pattern_lower))
287 .unwrap_or(false)
288 })
289 .unwrap_or(false)
290 });
291
292 if !session_id_matches && !title_matches && !content_matches {
293 continue;
294 }
295
296 let modified = session_with_path
297 .path
298 .metadata()
299 .ok()
300 .and_then(|m| m.modified().ok())
301 .map(|t| {
302 let datetime: chrono::DateTime<chrono::Utc> = t.into();
303 datetime.format("%Y-%m-%d %H:%M").to_string()
304 })
305 .unwrap_or_else(|| "unknown".to_string());
306
307 rows.push(SessionRow {
308 project_path: ws
309 .project_path
310 .clone()
311 .unwrap_or_else(|| "(none)".to_string()),
312 session_file: session_with_path
313 .path
314 .file_name()
315 .map(|n| n.to_string_lossy().to_string())
316 .unwrap_or_else(|| "unknown".to_string()),
317 last_modified: modified,
318 messages: session_with_path.session.request_count(),
319 });
320 }
321 }
322
323 if rows.is_empty() {
324 println!("No sessions found matching '{}'", pattern);
325 return Ok(());
326 }
327
328 let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
329
330 println!("{}", table);
331 println!("\nFound {} matching session(s)", rows.len());
332
333 Ok(())
334}
335
336pub fn find_sessions_filtered(
345 pattern: &str,
346 workspace_filter: Option<&str>,
347 title_only: bool,
348 search_content: bool,
349 after: Option<&str>,
350 before: Option<&str>,
351 limit: usize,
352) -> Result<()> {
353 use chrono::{NaiveDate, Utc};
354 use rayon::prelude::*;
355 use std::sync::atomic::{AtomicUsize, Ordering};
356
357 let pattern_lower = pattern.to_lowercase();
358
359 let after_date = after.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
361 let before_date = before.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
362
363 let storage_path = crate::workspace::get_workspace_storage_path()?;
365 if !storage_path.exists() {
366 println!("No workspaces found");
367 return Ok(());
368 }
369
370 let ws_filter_lower = workspace_filter.map(|s| s.to_lowercase());
372
373 let workspace_dirs: Vec<_> = std::fs::read_dir(&storage_path)?
374 .filter_map(|e| e.ok())
375 .filter(|e| e.path().is_dir())
376 .filter_map(|entry| {
377 let workspace_dir = entry.path();
378 let workspace_json_path = workspace_dir.join("workspace.json");
379
380 let chat_sessions_dir = workspace_dir.join("chatSessions");
382 if !chat_sessions_dir.exists() {
383 return None;
384 }
385
386 let project_path =
388 std::fs::read_to_string(&workspace_json_path)
389 .ok()
390 .and_then(|content| {
391 serde_json::from_str::<crate::models::WorkspaceJson>(&content)
392 .ok()
393 .and_then(|ws| {
394 ws.folder
395 .map(|f| crate::workspace::decode_workspace_folder(&f))
396 })
397 });
398
399 if let Some(ref filter) = ws_filter_lower {
401 let hash = entry.file_name().to_string_lossy().to_lowercase();
402 let path_matches = project_path
403 .as_ref()
404 .map(|p| p.to_lowercase().contains(filter))
405 .unwrap_or(false);
406 if !hash.contains(filter) && !path_matches {
407 return None;
408 }
409 }
410
411 let ws_name = project_path
412 .as_ref()
413 .and_then(|p| std::path::Path::new(p).file_name())
414 .map(|n| n.to_string_lossy().to_string())
415 .unwrap_or_else(|| {
416 entry.file_name().to_string_lossy()[..8.min(entry.file_name().len())]
417 .to_string()
418 });
419
420 Some((chat_sessions_dir, ws_name))
421 })
422 .collect();
423
424 if workspace_dirs.is_empty() {
425 if let Some(ws) = workspace_filter {
426 println!("No workspaces found matching '{}'", ws);
427 } else {
428 println!("No workspaces with chat sessions found");
429 }
430 return Ok(());
431 }
432
433 let session_files: Vec<_> = workspace_dirs
435 .iter()
436 .flat_map(|(chat_dir, ws_name)| {
437 std::fs::read_dir(chat_dir)
438 .into_iter()
439 .flatten()
440 .filter_map(|e| e.ok())
441 .filter(|e| {
442 e.path()
443 .extension()
444 .map(|ext| ext == "json")
445 .unwrap_or(false)
446 })
447 .map(|e| (e.path(), ws_name.clone()))
448 .collect::<Vec<_>>()
449 })
450 .collect();
451
452 let total_files = session_files.len();
453 let scanned = AtomicUsize::new(0);
454 let skipped_by_date = AtomicUsize::new(0);
455
456 let mut results: Vec<_> = session_files
458 .par_iter()
459 .filter_map(|(path, ws_name)| {
460 if after_date.is_some() || before_date.is_some() {
462 if let Ok(metadata) = path.metadata() {
463 if let Ok(modified) = metadata.modified() {
464 let file_date: chrono::DateTime<Utc> = modified.into();
465 let file_naive = file_date.date_naive();
466
467 if let Some(after) = after_date {
468 if file_naive < after {
469 skipped_by_date.fetch_add(1, Ordering::Relaxed);
470 return None;
471 }
472 }
473 if let Some(before) = before_date {
474 if file_naive > before {
475 skipped_by_date.fetch_add(1, Ordering::Relaxed);
476 return None;
477 }
478 }
479 }
480 }
481 }
482
483 scanned.fetch_add(1, Ordering::Relaxed);
484
485 let content = match std::fs::read_to_string(path) {
487 Ok(c) => c,
488 Err(_) => return None,
489 };
490
491 let title =
493 extract_title_from_content(&content).unwrap_or_else(|| "Untitled".to_string());
494 let title_lower = title.to_lowercase();
495
496 let session_id = path
498 .file_stem()
499 .map(|n| n.to_string_lossy().to_string())
500 .unwrap_or_default();
501 let id_matches =
502 !pattern_lower.is_empty() && session_id.to_lowercase().contains(&pattern_lower);
503
504 let title_matches = !pattern_lower.is_empty() && title_lower.contains(&pattern_lower);
506
507 let content_matches = if search_content
509 && !title_only
510 && !id_matches
511 && !title_matches
512 && !pattern_lower.is_empty()
513 {
514 content.to_lowercase().contains(&pattern_lower)
515 } else {
516 false
517 };
518
519 let matches =
521 pattern_lower.is_empty() || id_matches || title_matches || content_matches;
522 if !matches {
523 return None;
524 }
525
526 let match_type = if pattern_lower.is_empty() {
527 ""
528 } else if id_matches {
529 "ID"
530 } else if title_matches {
531 "title"
532 } else {
533 "content"
534 };
535
536 let message_count = content.matches("\"message\":").count();
538
539 let modified = path
541 .metadata()
542 .ok()
543 .and_then(|m| m.modified().ok())
544 .map(|t| {
545 let datetime: chrono::DateTime<chrono::Utc> = t.into();
546 datetime.format("%Y-%m-%d %H:%M").to_string()
547 })
548 .unwrap_or_else(|| "unknown".to_string());
549
550 Some((
551 title,
552 ws_name.clone(),
553 modified,
554 message_count,
555 match_type.to_string(),
556 ))
557 })
558 .collect();
559
560 let scanned_count = scanned.load(Ordering::Relaxed);
561 let skipped_count = skipped_by_date.load(Ordering::Relaxed);
562
563 if results.is_empty() {
564 println!("No sessions found matching '{}'", pattern);
565 if skipped_count > 0 {
566 println!(" ({} sessions skipped due to date filter)", skipped_count);
567 }
568 return Ok(());
569 }
570
571 results.sort_by(|a, b| b.2.cmp(&a.2));
573
574 results.truncate(limit);
576
577 #[derive(Tabled)]
578 struct SearchResultRow {
579 #[tabled(rename = "Title")]
580 title: String,
581 #[tabled(rename = "Workspace")]
582 workspace: String,
583 #[tabled(rename = "Modified")]
584 modified: String,
585 #[tabled(rename = "Msgs")]
586 messages: usize,
587 #[tabled(rename = "Match")]
588 match_type: String,
589 }
590
591 let rows: Vec<SearchResultRow> = results
592 .into_iter()
593 .map(
594 |(title, workspace, modified, messages, match_type)| SearchResultRow {
595 title: truncate_string(&title, 40),
596 workspace: truncate_string(&workspace, 20),
597 modified,
598 messages,
599 match_type,
600 },
601 )
602 .collect();
603
604 let table = Table::new(&rows).with(Style::ascii_rounded()).to_string();
605
606 println!("{}", table);
607 println!(
608 "\nFound {} session(s) (scanned {} of {} files{})",
609 rows.len(),
610 scanned_count,
611 total_files,
612 if skipped_count > 0 {
613 format!(", {} skipped by date", skipped_count)
614 } else {
615 String::new()
616 }
617 );
618 if rows.len() >= limit {
619 println!(" (results limited to {}; use --limit to show more)", limit);
620 }
621
622 Ok(())
623}
624
625fn extract_title_from_content(content: &str) -> Option<String> {
627 if let Some(start) = content.find("\"customTitle\"") {
629 if let Some(colon) = content[start..].find(':') {
630 let after_colon = &content[start + colon + 1..];
631 let trimmed = after_colon.trim_start();
632 if let Some(stripped) = trimmed.strip_prefix('"') {
633 if let Some(end) = stripped.find('"') {
634 let title = &stripped[..end];
635 if !title.is_empty() && title != "null" {
636 return Some(title.to_string());
637 }
638 }
639 }
640 }
641 }
642
643 if let Some(start) = content.find("\"text\"") {
645 if let Some(colon) = content[start..].find(':') {
646 let after_colon = &content[start + colon + 1..];
647 let trimmed = after_colon.trim_start();
648 if let Some(stripped) = trimmed.strip_prefix('"') {
649 if let Some(end) = stripped.find('"') {
650 let title = &stripped[..end];
651 if !title.is_empty() && title.len() < 100 {
652 return Some(title.to_string());
653 }
654 }
655 }
656 }
657 }
658
659 None
660}
661
662#[allow(dead_code)]
664fn extract_title_fast(header: &str) -> Option<String> {
665 extract_title_from_content(header)
666}
667
668fn truncate_string(s: &str, max_len: usize) -> String {
670 if s.len() <= max_len {
671 s.to_string()
672 } else {
673 format!("{}...", &s[..max_len.saturating_sub(3)])
674 }
675}
676
677pub fn show_workspace(workspace: &str) -> Result<()> {
679 use colored::Colorize;
680
681 let workspaces = discover_workspaces()?;
682 let workspace_lower = workspace.to_lowercase();
683
684 let matching: Vec<&Workspace> = workspaces
686 .iter()
687 .filter(|ws| {
688 ws.hash.to_lowercase().contains(&workspace_lower)
689 || ws
690 .project_path
691 .as_ref()
692 .map(|p| p.to_lowercase().contains(&workspace_lower))
693 .unwrap_or(false)
694 })
695 .collect();
696
697 if matching.is_empty() {
698 println!(
699 "{} No workspace found matching '{}'",
700 "!".yellow(),
701 workspace
702 );
703 return Ok(());
704 }
705
706 for ws in matching {
707 println!("\n{}", "=".repeat(60).bright_blue());
708 println!("{}", "Workspace Details".bright_blue().bold());
709 println!("{}", "=".repeat(60).bright_blue());
710
711 println!("{}: {}", "Hash".bright_white().bold(), ws.hash);
712 println!(
713 "{}: {}",
714 "Path".bright_white().bold(),
715 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
716 );
717 println!(
718 "{}: {}",
719 "Has Sessions".bright_white().bold(),
720 if ws.has_chat_sessions {
721 "Yes".green()
722 } else {
723 "No".red()
724 }
725 );
726 println!(
727 "{}: {}",
728 "Workspace Path".bright_white().bold(),
729 ws.workspace_path.display()
730 );
731
732 if ws.has_chat_sessions {
733 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
734 println!(
735 "{}: {}",
736 "Session Count".bright_white().bold(),
737 sessions.len()
738 );
739
740 if !sessions.is_empty() {
741 println!("\n{}", "Sessions:".bright_yellow());
742 for (i, s) in sessions.iter().enumerate() {
743 let title = s.session.title();
744 let msg_count = s.session.request_count();
745 println!(
746 " {}. {} ({} messages)",
747 i + 1,
748 title.bright_cyan(),
749 msg_count
750 );
751 }
752 }
753 }
754 }
755
756 Ok(())
757}
758
759pub fn show_session(session_id: &str, project_path: Option<&str>) -> Result<()> {
761 use colored::Colorize;
762
763 let workspaces = discover_workspaces()?;
764 let session_id_lower = session_id.to_lowercase();
765
766 let filtered_workspaces: Vec<&Workspace> = if let Some(path) = project_path {
767 let normalized = crate::workspace::normalize_path(path);
768 workspaces
769 .iter()
770 .filter(|ws| {
771 ws.project_path
772 .as_ref()
773 .map(|p| crate::workspace::normalize_path(p) == normalized)
774 .unwrap_or(false)
775 })
776 .collect()
777 } else {
778 workspaces.iter().collect()
779 };
780
781 for ws in filtered_workspaces {
782 if !ws.has_chat_sessions {
783 continue;
784 }
785
786 let sessions = crate::workspace::get_chat_sessions_from_workspace(&ws.workspace_path)?;
787
788 for s in sessions {
789 let filename = s
790 .path
791 .file_name()
792 .map(|n| n.to_string_lossy().to_string())
793 .unwrap_or_default();
794
795 let matches = s
796 .session
797 .session_id
798 .as_ref()
799 .map(|id| id.to_lowercase().contains(&session_id_lower))
800 .unwrap_or(false)
801 || filename.to_lowercase().contains(&session_id_lower);
802
803 if matches {
804 println!("\n{}", "=".repeat(60).bright_blue());
805 println!("{}", "Session Details".bright_blue().bold());
806 println!("{}", "=".repeat(60).bright_blue());
807
808 println!(
809 "{}: {}",
810 "Title".bright_white().bold(),
811 s.session.title().bright_cyan()
812 );
813 println!("{}: {}", "File".bright_white().bold(), filename);
814 println!(
815 "{}: {}",
816 "Session ID".bright_white().bold(),
817 s.session
818 .session_id
819 .as_ref()
820 .unwrap_or(&"(none)".to_string())
821 );
822 println!(
823 "{}: {}",
824 "Messages".bright_white().bold(),
825 s.session.request_count()
826 );
827 println!(
828 "{}: {}",
829 "Workspace".bright_white().bold(),
830 ws.project_path.as_ref().unwrap_or(&"(none)".to_string())
831 );
832
833 println!("\n{}", "Preview:".bright_yellow());
835 for (i, req) in s.session.requests.iter().take(3).enumerate() {
836 if let Some(msg) = &req.message {
837 if let Some(text) = &msg.text {
838 let preview: String = text.chars().take(100).collect();
839 let truncated = if text.len() > 100 { "..." } else { "" };
840 println!(" {}. {}{}", i + 1, preview.dimmed(), truncated);
841 }
842 }
843 }
844
845 return Ok(());
846 }
847 }
848 }
849
850 println!(
851 "{} No session found matching '{}'",
852 "!".yellow(),
853 session_id
854 );
855 Ok(())
856}