1use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use colored::*;
8use std::path::Path;
9use uuid::Uuid;
10
11use crate::models::{ChatRequest, ChatSession};
12use crate::storage::{
13 add_session_to_index, backup_workspace_sessions, get_workspace_storage_db, is_vscode_running,
14 register_all_sessions_from_directory,
15};
16use crate::workspace::{
17 discover_workspaces, find_all_workspaces_for_project, find_workspace_by_path,
18 get_chat_sessions_from_workspace, normalize_path,
19};
20
21pub fn history_show(project_path: Option<&str>) -> Result<()> {
23 let project_path = project_path.map(|p| p.to_string()).unwrap_or_else(|| {
24 std::env::current_dir()
25 .map(|p| p.to_string_lossy().to_string())
26 .unwrap_or_else(|_| ".".to_string())
27 });
28
29 let project_name = Path::new(&project_path)
30 .file_name()
31 .map(|n| n.to_string_lossy().to_string())
32 .unwrap_or_else(|| project_path.clone());
33
34 println!(
35 "\n{} Chat History for: {}",
36 "[*]".blue(),
37 project_name.cyan()
38 );
39 println!("{}", "=".repeat(70));
40
41 let all_workspaces = find_all_workspaces_for_project(&project_name)?;
43
44 if all_workspaces.is_empty() {
45 println!(
46 "\n{} No workspaces found matching '{}'",
47 "[!]".yellow(),
48 project_name
49 );
50 return Ok(());
51 }
52
53 let current_ws = find_workspace_by_path(&project_path)?;
55 let current_ws_id = current_ws.as_ref().map(|(id, _, _)| id.clone());
56
57 let mut total_sessions = 0;
58 let mut total_requests = 0;
59
60 for (ws_id, ws_dir, folder_path, last_mod) in &all_workspaces {
61 let is_current = current_ws_id.as_ref() == Some(ws_id);
62 let marker = if is_current { "-> " } else { " " };
63 let label = if is_current {
64 " (current)".green().to_string()
65 } else {
66 "".to_string()
67 };
68
69 let mod_date: DateTime<Utc> = (*last_mod).into();
70 let mod_str = mod_date.format("%Y-%m-%d %H:%M").to_string();
71
72 let sessions = get_chat_sessions_from_workspace(ws_dir)?;
73
74 println!(
75 "\n{}Workspace: {}...{}",
76 marker.cyan(),
77 &ws_id[..16.min(ws_id.len())],
78 label
79 );
80 println!(" Path: {}", folder_path.as_deref().unwrap_or("(none)"));
81 println!(" Modified: {}", mod_str);
82 println!(" Sessions: {}", sessions.len());
83
84 if !sessions.is_empty() {
85 for session_with_path in &sessions {
86 let session = &session_with_path.session;
87 let title = session.title();
88 let request_count = session.request_count();
89
90 let date_range = if let Some((first, last)) = session.timestamp_range() {
92 let first_date = timestamp_to_date(first);
93 let last_date = timestamp_to_date(last);
94 if first_date == last_date {
95 first_date
96 } else {
97 format!("{} -> {}", first_date, last_date)
98 }
99 } else {
100 "empty".to_string()
101 };
102
103 println!(
104 " {} {:<40} ({:3} msgs) [{}]",
105 "[-]".blue(),
106 truncate(&title, 40),
107 request_count,
108 date_range
109 );
110
111 total_requests += request_count;
112 total_sessions += 1;
113 }
114 }
115 }
116
117 println!("\n{}", "=".repeat(70));
118 println!(
119 "Total: {} sessions, {} messages across {} workspace(s)",
120 total_sessions,
121 total_requests,
122 all_workspaces.len()
123 );
124
125 Ok(())
126}
127
128pub fn history_fetch(project_path: Option<&str>, force: bool, no_register: bool) -> Result<()> {
130 let project_path = project_path.map(|p| p.to_string()).unwrap_or_else(|| {
131 std::env::current_dir()
132 .map(|p| p.to_string_lossy().to_string())
133 .unwrap_or_else(|_| ".".to_string())
134 });
135
136 let project_name = Path::new(&project_path)
137 .file_name()
138 .map(|n| n.to_string_lossy().to_string())
139 .unwrap_or_else(|| project_path.clone());
140
141 println!(
142 "\n{} Fetching Chat History for: {}",
143 "[<]".blue(),
144 project_name.cyan()
145 );
146 println!("{}", "=".repeat(70));
147
148 let current_ws = find_workspace_by_path(&project_path)?
150 .context("Current workspace not found. Make sure the project is opened in VS Code")?;
151 let (current_ws_id, current_ws_dir, _) = current_ws;
152
153 let all_workspaces = find_all_workspaces_for_project(&project_name)?;
155 let historical_workspaces: Vec<_> = all_workspaces
156 .into_iter()
157 .filter(|(id, _, _, _)| *id != current_ws_id)
158 .collect();
159
160 if historical_workspaces.is_empty() {
161 println!(
162 "{} No historical workspaces found for '{}'",
163 "[!]".yellow(),
164 project_name
165 );
166 println!(" Only the current workspace exists.");
167 return Ok(());
168 }
169
170 println!(
171 "Found {} historical workspace(s)\n",
172 historical_workspaces.len()
173 );
174
175 let chat_sessions_dir = current_ws_dir.join("chatSessions");
177 std::fs::create_dir_all(&chat_sessions_dir)?;
178
179 let mut fetched_count = 0;
180 let mut skipped_count = 0;
181
182 for (_, ws_dir, _, _) in &historical_workspaces {
183 let sessions = get_chat_sessions_from_workspace(ws_dir)?;
184
185 for session_with_path in sessions {
186 let session_id = session_with_path
188 .session
189 .session_id
190 .clone()
191 .unwrap_or_else(|| {
192 session_with_path
193 .path
194 .file_stem()
195 .map(|s| s.to_string_lossy().to_string())
196 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
197 });
198 let dest_file = chat_sessions_dir.join(format!("{}.json", session_id));
199
200 if dest_file.exists() && !force {
201 println!(
202 " {} Skipped (exists): {}...",
203 "[>]".yellow(),
204 &session_id[..16.min(session_id.len())]
205 );
206 skipped_count += 1;
207 } else {
208 std::fs::copy(&session_with_path.path, &dest_file)?;
209 let title = session_with_path.session.title();
210 println!(
211 " {} Fetched: {} ({}...)",
212 "[OK]".green(),
213 truncate(&title, 40),
214 &session_id[..16.min(session_id.len())]
215 );
216 fetched_count += 1;
217 }
218 }
219 }
220
221 println!("\n{}", "=".repeat(70));
222 println!("Fetched: {} sessions", fetched_count);
223 if skipped_count > 0 {
224 println!("Skipped: {} (use --force to overwrite)", skipped_count);
225 }
226
227 if fetched_count > 0 && !no_register {
229 println!(
230 "\n{} Registering sessions in VS Code index...",
231 "[#]".blue()
232 );
233
234 if is_vscode_running() && !force {
235 println!(
236 "{} VS Code is running. Sessions may not appear until restart.",
237 "[!]".yellow()
238 );
239 println!(" Run 'csm history fetch --force' after closing VS Code to register.");
240 } else {
241 let registered =
242 register_all_sessions_from_directory(¤t_ws_id, &chat_sessions_dir, true)?;
243 println!(
244 "{} Registered {} sessions in index",
245 "[OK]".green(),
246 registered
247 );
248 }
249 }
250
251 println!(
252 "\n{} Reload VS Code (Ctrl+R) and check Chat history dropdown",
253 "[i]".cyan()
254 );
255
256 Ok(())
257}
258
259pub fn history_merge(
261 project_path: Option<&str>,
262 title: Option<&str>,
263 force: bool,
264 no_backup: bool,
265) -> Result<()> {
266 let project_path = project_path.map(|p| p.to_string()).unwrap_or_else(|| {
267 std::env::current_dir()
268 .map(|p| p.to_string_lossy().to_string())
269 .unwrap_or_else(|_| ".".to_string())
270 });
271
272 let project_name = Path::new(&project_path)
273 .file_name()
274 .map(|n| n.to_string_lossy().to_string())
275 .unwrap_or_else(|| project_path.clone());
276
277 println!(
278 "\n{} Merging Chat History for: {}",
279 "[M]".blue(),
280 project_name.cyan()
281 );
282 println!("{}", "=".repeat(70));
283
284 let current_ws = find_workspace_by_path(&project_path)?
286 .context("Current workspace not found. Make sure the project is opened in VS Code")?;
287 let (current_ws_id, current_ws_dir, _) = current_ws;
288
289 let all_workspaces = find_all_workspaces_for_project(&project_name)?;
291
292 println!(
294 "\n{} Collecting sessions from {} workspace(s)...",
295 "[D]".blue(),
296 all_workspaces.len()
297 );
298
299 let mut all_sessions = Vec::new();
300 for (ws_id, ws_dir, _, _) in &all_workspaces {
301 let sessions = get_chat_sessions_from_workspace(ws_dir)?;
302 if !sessions.is_empty() {
303 println!(
304 " {} {}... ({} sessions)",
305 "[d]".blue(),
306 &ws_id[..16.min(ws_id.len())],
307 sessions.len()
308 );
309 all_sessions.extend(sessions);
310 }
311 }
312
313 if all_sessions.is_empty() {
314 println!("\n{} No chat sessions found in any workspace", "[X]".red());
315 return Ok(());
316 }
317
318 println!("\n Total: {} sessions collected", all_sessions.len());
319
320 println!("\n{} Extracting and sorting messages...", "[*]".blue());
322
323 let mut all_requests: Vec<ChatRequest> = Vec::new();
324 for session_with_path in &all_sessions {
325 let session = &session_with_path.session;
326 let session_title = session.title();
327
328 for req in &session.requests {
329 let mut req = req.clone();
330 req.source_session = Some(session_title.clone());
332 if req.timestamp.is_some() {
333 all_requests.push(req);
334 }
335 }
336 }
337
338 if all_requests.is_empty() {
339 println!("\n{} No messages found in any session", "[X]".red());
340 return Ok(());
341 }
342
343 all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
345
346 let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
348 let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
349
350 let first_date = timestamp_to_date(first_time);
351 let last_date = timestamp_to_date(last_time);
352 let days_span = if first_time > 0 && last_time > 0 {
353 (last_time - first_time) / (1000 * 60 * 60 * 24)
354 } else {
355 0
356 };
357
358 println!(" Messages: {}", all_requests.len());
359 println!(
360 " Timeline: {} -> {} ({} days)",
361 first_date, last_date, days_span
362 );
363
364 println!("\n{} Creating merged session...", "[+]".blue());
366
367 let merged_session_id = Uuid::new_v4().to_string();
368 let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
369 format!(
370 "Merged History ({} sessions, {} days)",
371 all_sessions.len(),
372 days_span
373 )
374 });
375
376 let merged_session = ChatSession {
377 version: 3,
378 session_id: Some(merged_session_id.clone()),
379 creation_date: first_time,
380 last_message_date: last_time,
381 is_imported: false,
382 initial_location: "panel".to_string(),
383 custom_title: Some(merged_title.clone()),
384 requester_username: Some("User".to_string()),
385 requester_avatar_icon_uri: None, responder_username: Some("GitHub Copilot".to_string()),
387 responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
388 requests: all_requests.clone(),
389 };
390
391 let chat_sessions_dir = current_ws_dir.join("chatSessions");
393
394 if !no_backup {
395 if let Some(backup_dir) = backup_workspace_sessions(¤t_ws_dir)? {
396 println!(
397 " {} Backup: {}",
398 "[B]".blue(),
399 backup_dir.file_name().unwrap().to_string_lossy()
400 );
401 }
402 }
403
404 std::fs::create_dir_all(&chat_sessions_dir)?;
406 let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
407
408 let json = serde_json::to_string_pretty(&merged_session)?;
409 std::fs::write(&merged_file, json)?;
410
411 println!(
412 " {} File: {}",
413 "[F]".blue(),
414 merged_file.file_name().unwrap().to_string_lossy()
415 );
416
417 println!("\n{} Registering in VS Code index...", "[#]".blue());
419
420 if is_vscode_running() && !force {
421 println!(
422 "{} VS Code is running. Close it and run again, or use --force",
423 "[!]".yellow()
424 );
425 } else {
426 let db_path = get_workspace_storage_db(¤t_ws_id)?;
427 add_session_to_index(
428 &db_path,
429 &merged_session_id,
430 &merged_title,
431 last_time,
432 false,
433 "panel",
434 false,
435 )?;
436 println!(" {} Registered in index", "[OK]".green());
437 }
438
439 println!("\n{}", "=".repeat(70));
440 println!("{} MERGE COMPLETE!", "[OK]".green().bold());
441 println!("\n{} Summary:", "[=]".blue());
442 println!(" - Sessions merged: {}", all_sessions.len());
443 println!(" - Total messages: {}", all_requests.len());
444 println!(" - Timeline: {} days", days_span);
445 println!(" - Title: {}", merged_title);
446
447 println!("\n{} Next Steps:", "[i]".cyan());
448 println!(" 1. Reload VS Code (Ctrl+R)");
449 println!(" 2. Open Chat history dropdown");
450 println!(" 3. Select: '{}'", merged_title);
451
452 Ok(())
453}
454
455pub fn merge_by_workspace_name(
457 workspace_name: &str,
458 title: Option<&str>,
459 target_path: Option<&str>,
460 force: bool,
461 no_backup: bool,
462) -> Result<()> {
463 println!(
464 "\n{} Merging Sessions by Workspace Name: {}",
465 "[M]".blue(),
466 workspace_name.cyan()
467 );
468 println!("{}", "=".repeat(70));
469
470 let all_workspaces = find_all_workspaces_for_project(workspace_name)?;
472
473 if all_workspaces.is_empty() {
474 println!(
475 "\n{} No workspaces found matching '{}'",
476 "[X]".red(),
477 workspace_name
478 );
479 return Ok(());
480 }
481
482 println!(
483 "\n{} Found {} workspace(s) matching pattern:",
484 "[D]".blue(),
485 all_workspaces.len()
486 );
487 for (ws_id, _, folder_path, _) in &all_workspaces {
488 println!(
489 " {} {}... -> {}",
490 "[*]".blue(),
491 &ws_id[..16.min(ws_id.len())],
492 folder_path.as_deref().unwrap_or("(unknown)")
493 );
494 }
495
496 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
498 std::env::current_dir()
499 .map(|p| p.to_string_lossy().to_string())
500 .unwrap_or_else(|_| ".".to_string())
501 });
502
503 let target_ws = find_workspace_by_path(&target_path)?
504 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
505 let (target_ws_id, target_ws_dir, _) = target_ws;
506
507 println!(
508 "\n{} Target workspace: {}...",
509 "[>]".blue(),
510 &target_ws_id[..16.min(target_ws_id.len())]
511 );
512
513 println!("\n{} Collecting sessions...", "[D]".blue());
515
516 let mut all_sessions = Vec::new();
517 for (ws_id, ws_dir, _, _) in &all_workspaces {
518 let sessions = get_chat_sessions_from_workspace(ws_dir)?;
519 if !sessions.is_empty() {
520 println!(
521 " {} {}... ({} sessions)",
522 "[d]".blue(),
523 &ws_id[..16.min(ws_id.len())],
524 sessions.len()
525 );
526 all_sessions.extend(sessions);
527 }
528 }
529
530 if all_sessions.is_empty() {
531 println!(
532 "\n{} No chat sessions found in matching workspaces",
533 "[X]".red()
534 );
535 return Ok(());
536 }
537
538 merge_sessions_internal(
540 all_sessions,
541 title,
542 &target_ws_id,
543 &target_ws_dir,
544 force,
545 no_backup,
546 &format!("Workspace: {}", workspace_name),
547 )
548}
549
550pub fn merge_sessions_by_list(
552 session_ids: &[String],
553 title: Option<&str>,
554 target_path: Option<&str>,
555 force: bool,
556 no_backup: bool,
557) -> Result<()> {
558 println!("\n{} Merging Specific Sessions", "[M]".blue());
559 println!("{}", "=".repeat(70));
560
561 println!(
562 "\n{} Looking for {} session(s):",
563 "[D]".blue(),
564 session_ids.len()
565 );
566 for id in session_ids {
567 println!(" {} {}", "[?]".blue(), id);
568 }
569
570 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
572 std::env::current_dir()
573 .map(|p| p.to_string_lossy().to_string())
574 .unwrap_or_else(|_| ".".to_string())
575 });
576
577 let target_ws = find_workspace_by_path(&target_path)?
578 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
579 let (target_ws_id, target_ws_dir, _) = target_ws;
580
581 println!("\n{} Searching all workspaces...", "[D]".blue());
583
584 let all_workspaces = crate::workspace::discover_workspaces()?;
585 let mut found_sessions = Vec::new();
586 let mut found_ids: Vec<String> = Vec::new();
587
588 let normalized_ids: Vec<String> = session_ids
590 .iter()
591 .map(|id| {
592 let id = id.trim();
593 if id.to_lowercase().ends_with(".json") {
594 id[..id.len() - 5].to_string()
595 } else {
596 id.to_string()
597 }
598 })
599 .collect();
600
601 for ws in &all_workspaces {
602 if !ws.has_chat_sessions {
603 continue;
604 }
605
606 let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
607
608 for session_with_path in sessions {
609 let session_id = session_with_path
610 .session
611 .session_id
612 .clone()
613 .unwrap_or_else(|| {
614 session_with_path
615 .path
616 .file_stem()
617 .map(|s| s.to_string_lossy().to_string())
618 .unwrap_or_default()
619 });
620
621 let matches = normalized_ids.iter().any(|req_id| {
623 session_id.starts_with(req_id)
624 || req_id.starts_with(&session_id)
625 || session_id == *req_id
626 });
627
628 if matches && !found_ids.contains(&session_id) {
629 println!(
630 " {} Found: {} in workspace {}...",
631 "[OK]".green(),
632 truncate(&session_with_path.session.title(), 40),
633 &ws.hash[..16.min(ws.hash.len())]
634 );
635 found_ids.push(session_id);
636 found_sessions.push(session_with_path);
637 }
638 }
639 }
640
641 if found_sessions.is_empty() {
642 println!("\n{} No matching sessions found", "[X]".red());
643 println!(
644 "\n{} Tip: Use 'csm list sessions' or 'csm find session <pattern>' to find session IDs",
645 "[i]".cyan()
646 );
647 return Ok(());
648 }
649
650 let not_found: Vec<_> = normalized_ids
652 .iter()
653 .filter(|id| {
654 !found_ids
655 .iter()
656 .any(|found| found.starts_with(*id) || id.starts_with(found))
657 })
658 .collect();
659
660 if !not_found.is_empty() {
661 println!("\n{} Sessions not found:", "[!]".yellow());
662 for id in not_found {
663 println!(" {} {}", "[X]".red(), id);
664 }
665 }
666
667 println!("\n Total: {} sessions found", found_sessions.len());
668
669 merge_sessions_internal(
671 found_sessions,
672 title,
673 &target_ws_id,
674 &target_ws_dir,
675 force,
676 no_backup,
677 &format!("{} selected sessions", session_ids.len()),
678 )
679}
680
681fn merge_sessions_internal(
683 sessions: Vec<crate::models::SessionWithPath>,
684 title: Option<&str>,
685 target_ws_id: &str,
686 target_ws_dir: &Path,
687 force: bool,
688 no_backup: bool,
689 source_description: &str,
690) -> Result<()> {
691 println!("\n{} Extracting and sorting messages...", "[*]".blue());
693
694 let mut all_requests: Vec<ChatRequest> = Vec::new();
695 for session_with_path in &sessions {
696 let session = &session_with_path.session;
697 let session_title = session.title();
698
699 for req in &session.requests {
700 let mut req = req.clone();
701 req.source_session = Some(session_title.clone());
702 if req.timestamp.is_some() {
703 all_requests.push(req);
704 }
705 }
706 }
707
708 if all_requests.is_empty() {
709 println!("\n{} No messages found in selected sessions", "[X]".red());
710 return Ok(());
711 }
712
713 all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
715
716 let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
718 let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
719
720 let first_date = timestamp_to_date(first_time);
721 let last_date = timestamp_to_date(last_time);
722 let days_span = if first_time > 0 && last_time > 0 {
723 (last_time - first_time) / (1000 * 60 * 60 * 24)
724 } else {
725 0
726 };
727
728 println!(" Messages: {}", all_requests.len());
729 println!(
730 " Timeline: {} -> {} ({} days)",
731 first_date, last_date, days_span
732 );
733
734 println!("\n{} Creating merged session...", "[+]".blue());
736
737 let merged_session_id = Uuid::new_v4().to_string();
738 let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
739 format!(
740 "Merged: {} ({} sessions, {} days)",
741 source_description,
742 sessions.len(),
743 days_span
744 )
745 });
746
747 let merged_session = ChatSession {
748 version: 3,
749 session_id: Some(merged_session_id.clone()),
750 creation_date: first_time,
751 last_message_date: last_time,
752 is_imported: false,
753 initial_location: "panel".to_string(),
754 custom_title: Some(merged_title.clone()),
755 requester_username: Some("User".to_string()),
756 requester_avatar_icon_uri: None,
757 responder_username: Some("GitHub Copilot".to_string()),
758 responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
759 requests: all_requests.clone(),
760 };
761
762 let chat_sessions_dir = target_ws_dir.join("chatSessions");
764
765 if !no_backup {
766 if let Some(backup_dir) = backup_workspace_sessions(target_ws_dir)? {
767 println!(
768 " {} Backup: {}",
769 "[B]".blue(),
770 backup_dir.file_name().unwrap().to_string_lossy()
771 );
772 }
773 }
774
775 std::fs::create_dir_all(&chat_sessions_dir)?;
777 let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
778
779 let json = serde_json::to_string_pretty(&merged_session)?;
780 std::fs::write(&merged_file, json)?;
781
782 println!(
783 " {} File: {}",
784 "[F]".blue(),
785 merged_file.file_name().unwrap().to_string_lossy()
786 );
787
788 println!("\n{} Registering in VS Code index...", "[#]".blue());
790
791 if is_vscode_running() && !force {
792 println!(
793 "{} VS Code is running. Close it and run again, or use --force",
794 "[!]".yellow()
795 );
796 } else {
797 let db_path = get_workspace_storage_db(target_ws_id)?;
798 add_session_to_index(
799 &db_path,
800 &merged_session_id,
801 &merged_title,
802 last_time,
803 false,
804 "panel",
805 false,
806 )?;
807 println!(" {} Registered in index", "[OK]".green());
808 }
809
810 println!("\n{}", "=".repeat(70));
811 println!("{} MERGE COMPLETE!", "[OK]".green().bold());
812 println!("\n{} Summary:", "[=]".blue());
813 println!(" - Sessions merged: {}", sessions.len());
814 println!(" - Total messages: {}", all_requests.len());
815 println!(" - Timeline: {} days", days_span);
816 println!(" - Title: {}", merged_title);
817
818 println!("\n{} Next Steps:", "[i]".cyan());
819 println!(" 1. Reload VS Code (Ctrl+R)");
820 println!(" 2. Open Chat history dropdown");
821 println!(" 3. Select: '{}'", merged_title);
822
823 Ok(())
824}
825
826fn timestamp_to_date(timestamp: i64) -> String {
828 if timestamp == 0 {
829 return "unknown".to_string();
830 }
831
832 let secs = if timestamp > 1_000_000_000_000 {
834 timestamp / 1000
835 } else {
836 timestamp
837 };
838
839 DateTime::from_timestamp(secs, 0)
840 .map(|dt| dt.format("%Y-%m-%d").to_string())
841 .unwrap_or_else(|| "unknown".to_string())
842}
843
844fn truncate(s: &str, max_len: usize) -> String {
846 if s.len() <= max_len {
847 s.to_string()
848 } else {
849 format!("{}...", &s[..max_len - 3])
850 }
851}
852
853pub fn fetch_by_workspace(
855 workspace_name: &str,
856 target_path: Option<&str>,
857 force: bool,
858 no_register: bool,
859) -> Result<()> {
860 use colored::Colorize;
861 use std::fs;
862
863 println!("\n{}", "=".repeat(70));
864 println!("{} FETCH BY WORKSPACE", "[*]".cyan().bold());
865 println!("{}", "=".repeat(70));
866
867 let target_dir = target_path
869 .map(std::path::PathBuf::from)
870 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
871 let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
872
873 println!("\n{} Target: {}", "[>]".blue(), target_normalized);
874 println!("{} Pattern: {}", "[>]".blue(), workspace_name);
875
876 let all_workspaces = discover_workspaces()?;
878 let pattern_lower = workspace_name.to_lowercase();
879
880 let source_workspaces: Vec<_> = all_workspaces
882 .iter()
883 .filter(|ws| {
884 ws.project_path
885 .as_ref()
886 .map(|p| p.to_lowercase().contains(&pattern_lower))
887 .unwrap_or(false)
888 })
889 .filter(|ws| ws.has_chat_sessions)
890 .collect();
891
892 if source_workspaces.is_empty() {
893 println!(
894 "\n{} No workspaces found matching '{}'",
895 "[X]".red(),
896 workspace_name
897 );
898 return Ok(());
899 }
900
901 println!(
902 "\n{} Found {} matching workspace(s)",
903 "[OK]".green(),
904 source_workspaces.len()
905 );
906
907 let target_ws = all_workspaces.iter().find(|ws| {
909 ws.project_path
910 .as_ref()
911 .map(|p| normalize_path(p) == target_normalized)
912 .unwrap_or(false)
913 });
914
915 let target_ws_dir = match target_ws {
916 Some(ws) => ws.workspace_path.join("workspaceState"),
917 None => {
918 println!(
919 "{} Target workspace not found, creating new...",
920 "[!]".yellow()
921 );
922 anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
924 }
925 };
926
927 let mut fetched_count = 0;
929
930 for ws in source_workspaces {
931 let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
932
933 for session_with_path in sessions {
934 let src_file = &session_with_path.path;
935 let filename = src_file
936 .file_name()
937 .map(|n| n.to_string_lossy().to_string())
938 .unwrap_or_default();
939
940 let dest_file = target_ws_dir.join(&filename);
941
942 if dest_file.exists() && !force {
943 println!(" {} Skipping (exists): {}", "[!]".yellow(), filename);
944 continue;
945 }
946
947 fs::copy(src_file, &dest_file)?;
948 fetched_count += 1;
949 println!(
950 " {} Fetched: {}",
951 "[OK]".green(),
952 session_with_path.session.title()
953 );
954 }
955 }
956
957 println!(
958 "\n{} Fetched {} session(s)",
959 "[OK]".green().bold(),
960 fetched_count
961 );
962
963 if !no_register {
964 println!(
965 "{} Sessions will appear in VS Code after reload",
966 "[i]".cyan()
967 );
968 }
969
970 Ok(())
971}
972
973pub fn fetch_sessions(
975 session_ids: &[String],
976 target_path: Option<&str>,
977 force: bool,
978 no_register: bool,
979) -> Result<()> {
980 use colored::Colorize;
981 use std::fs;
982
983 println!("\n{}", "=".repeat(70));
984 println!("{} FETCH SESSIONS BY ID", "[*]".cyan().bold());
985 println!("{}", "=".repeat(70));
986
987 if session_ids.is_empty() {
988 println!("{} No session IDs provided", "[X]".red());
989 return Ok(());
990 }
991
992 let target_dir = target_path
994 .map(std::path::PathBuf::from)
995 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
996 let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
997
998 println!("\n{} Target: {}", "[>]".blue(), target_normalized);
999 println!("{} Sessions: {:?}", "[>]".blue(), session_ids);
1000
1001 let all_workspaces = discover_workspaces()?;
1002
1003 let target_ws = all_workspaces.iter().find(|ws| {
1005 ws.project_path
1006 .as_ref()
1007 .map(|p| normalize_path(p) == target_normalized)
1008 .unwrap_or(false)
1009 });
1010
1011 let target_ws_dir = match target_ws {
1012 Some(ws) => ws.workspace_path.join("workspaceState"),
1013 None => {
1014 anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
1015 }
1016 };
1017
1018 let normalized_ids: Vec<String> = session_ids
1020 .iter()
1021 .flat_map(|s| s.split(',').map(|p| p.trim().to_lowercase()))
1022 .filter(|s| !s.is_empty())
1023 .collect();
1024
1025 let mut fetched_count = 0;
1026 let mut found_ids = Vec::new();
1027
1028 for ws in &all_workspaces {
1029 if !ws.has_chat_sessions {
1030 continue;
1031 }
1032
1033 let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
1034
1035 for session_with_path in sessions {
1036 let session_id = session_with_path
1037 .session
1038 .session_id
1039 .clone()
1040 .unwrap_or_else(|| {
1041 session_with_path
1042 .path
1043 .file_stem()
1044 .map(|s| s.to_string_lossy().to_string())
1045 .unwrap_or_default()
1046 });
1047
1048 let matches = normalized_ids.iter().any(|req_id| {
1049 session_id.to_lowercase().contains(req_id)
1050 || req_id.contains(&session_id.to_lowercase())
1051 });
1052
1053 if matches && !found_ids.contains(&session_id) {
1054 let src_file = &session_with_path.path;
1055 let filename = src_file
1056 .file_name()
1057 .map(|n| n.to_string_lossy().to_string())
1058 .unwrap_or_default();
1059
1060 let dest_file = target_ws_dir.join(&filename);
1061
1062 if dest_file.exists() && !force {
1063 println!(" {} Skipping (exists): {}", "[!]".yellow(), filename);
1064 found_ids.push(session_id);
1065 continue;
1066 }
1067
1068 fs::copy(src_file, &dest_file)?;
1069 fetched_count += 1;
1070 found_ids.push(session_id);
1071 println!(
1072 " {} Fetched: {}",
1073 "[OK]".green(),
1074 session_with_path.session.title()
1075 );
1076 }
1077 }
1078 }
1079
1080 let not_found: Vec<_> = normalized_ids
1082 .iter()
1083 .filter(|id| {
1084 !found_ids
1085 .iter()
1086 .any(|found| found.to_lowercase().contains(*id))
1087 })
1088 .collect();
1089
1090 if !not_found.is_empty() {
1091 println!("\n{} Sessions not found:", "[!]".yellow());
1092 for id in not_found {
1093 println!(" {} {}", "[X]".red(), id);
1094 }
1095 }
1096
1097 println!(
1098 "\n{} Fetched {} session(s)",
1099 "[OK]".green().bold(),
1100 fetched_count
1101 );
1102
1103 if !no_register {
1104 println!(
1105 "{} Sessions will appear in VS Code after reload",
1106 "[i]".cyan()
1107 );
1108 }
1109
1110 Ok(())
1111}
1112
1113pub fn merge_by_workspace_names(
1115 workspace_names: &[String],
1116 title: Option<&str>,
1117 target_path: Option<&str>,
1118 force: bool,
1119 no_backup: bool,
1120) -> Result<()> {
1121 println!(
1122 "\n{} Merging Sessions from Multiple Workspaces",
1123 "[M]".blue().bold()
1124 );
1125 println!("{}", "=".repeat(70));
1126
1127 println!("\n{} Workspace patterns:", "[D]".blue());
1128 for name in workspace_names {
1129 println!(" {} {}", "[*]".blue(), name.cyan());
1130 }
1131
1132 let mut all_matching_workspaces = Vec::new();
1134 let mut seen_ws_ids = std::collections::HashSet::new();
1135
1136 for pattern in workspace_names {
1137 let workspaces = find_all_workspaces_for_project(pattern)?;
1138 for ws in workspaces {
1139 if !seen_ws_ids.contains(&ws.0) {
1140 seen_ws_ids.insert(ws.0.clone());
1141 all_matching_workspaces.push(ws);
1142 }
1143 }
1144 }
1145
1146 if all_matching_workspaces.is_empty() {
1147 println!(
1148 "\n{} No workspaces found matching any of the patterns",
1149 "[X]".red()
1150 );
1151 return Ok(());
1152 }
1153
1154 println!(
1155 "\n{} Found {} unique workspace(s):",
1156 "[D]".blue(),
1157 all_matching_workspaces.len()
1158 );
1159 for (ws_id, _, folder_path, _) in &all_matching_workspaces {
1160 println!(
1161 " {} {}... -> {}",
1162 "[*]".blue(),
1163 &ws_id[..16.min(ws_id.len())],
1164 folder_path.as_deref().unwrap_or("(unknown)")
1165 );
1166 }
1167
1168 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1170 std::env::current_dir()
1171 .map(|p| p.to_string_lossy().to_string())
1172 .unwrap_or_else(|_| ".".to_string())
1173 });
1174
1175 let target_ws = find_workspace_by_path(&target_path)?
1176 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1177 let (target_ws_id, target_ws_dir, _) = target_ws;
1178
1179 println!(
1180 "\n{} Target workspace: {}...",
1181 "[>]".blue(),
1182 &target_ws_id[..16.min(target_ws_id.len())]
1183 );
1184
1185 println!("\n{} Collecting sessions...", "[D]".blue());
1187
1188 let mut all_sessions = Vec::new();
1189 for (ws_id, ws_dir, _, _) in &all_matching_workspaces {
1190 let sessions = get_chat_sessions_from_workspace(ws_dir)?;
1191 if !sessions.is_empty() {
1192 println!(
1193 " {} {}... ({} sessions)",
1194 "[d]".blue(),
1195 &ws_id[..16.min(ws_id.len())],
1196 sessions.len()
1197 );
1198 all_sessions.extend(sessions);
1199 }
1200 }
1201
1202 if all_sessions.is_empty() {
1203 println!(
1204 "\n{} No chat sessions found in matching workspaces",
1205 "[X]".red()
1206 );
1207 return Ok(());
1208 }
1209
1210 let auto_title = format!("Merged: {}", workspace_names.join(" + "));
1212 let merge_title = title.unwrap_or(&auto_title);
1213
1214 merge_sessions_internal(
1216 all_sessions,
1217 Some(merge_title),
1218 &target_ws_id,
1219 &target_ws_dir,
1220 force,
1221 no_backup,
1222 &format!("{} workspaces", workspace_names.len()),
1223 )
1224}
1225
1226pub fn merge_from_provider(
1228 provider_name: &str,
1229 title: Option<&str>,
1230 target_path: Option<&str>,
1231 session_ids: Option<&[String]>,
1232 force: bool,
1233 no_backup: bool,
1234) -> Result<()> {
1235 use crate::providers::{ProviderRegistry, ProviderType};
1236
1237 println!(
1238 "\n{} Merging Sessions from Provider: {}",
1239 "[M]".blue().bold(),
1240 provider_name.cyan()
1241 );
1242 println!("{}", "=".repeat(70));
1243
1244 let provider_type = match provider_name.to_lowercase().as_str() {
1246 "copilot" | "github-copilot" | "vscode" => ProviderType::Copilot,
1247 "cursor" => ProviderType::Cursor,
1248 "ollama" => ProviderType::Ollama,
1249 "vllm" => ProviderType::Vllm,
1250 "foundry" | "azure" | "azure-foundry" => ProviderType::Foundry,
1251 "lm-studio" | "lmstudio" => ProviderType::LmStudio,
1252 "localai" | "local-ai" => ProviderType::LocalAI,
1253 "text-gen-webui" | "textgenwebui" | "oobabooga" => ProviderType::TextGenWebUI,
1254 "jan" | "jan-ai" => ProviderType::Jan,
1255 "gpt4all" => ProviderType::Gpt4All,
1256 "llamafile" => ProviderType::Llamafile,
1257 _ => {
1258 println!("{} Unknown provider: {}", "[X]".red(), provider_name);
1259 println!("\n{} Available providers:", "[i]".cyan());
1260 println!(" copilot, cursor, ollama, vllm, foundry, lm-studio,");
1261 println!(" localai, text-gen-webui, jan, gpt4all, llamafile");
1262 return Ok(());
1263 }
1264 };
1265
1266 let registry = ProviderRegistry::new();
1268 let provider = registry
1269 .get_provider(provider_type)
1270 .context(format!("Provider '{}' not available", provider_name))?;
1271
1272 if !provider.is_available() {
1273 println!(
1274 "{} Provider '{}' is not available or not configured",
1275 "[X]".red(),
1276 provider_name
1277 );
1278 return Ok(());
1279 }
1280
1281 println!(
1282 "{} Provider: {} ({})",
1283 "[*]".blue(),
1284 provider.name(),
1285 provider_type.display_name()
1286 );
1287
1288 let provider_sessions = provider
1290 .list_sessions()
1291 .context("Failed to list sessions from provider")?;
1292
1293 if provider_sessions.is_empty() {
1294 println!("{} No sessions found in provider", "[X]".red());
1295 return Ok(());
1296 }
1297
1298 println!(
1299 "{} Found {} session(s) in provider",
1300 "[D]".blue(),
1301 provider_sessions.len()
1302 );
1303
1304 let sessions_to_merge: Vec<_> = if let Some(ids) = session_ids {
1306 let ids_lower: Vec<String> = ids.iter().map(|s| s.to_lowercase()).collect();
1307 provider_sessions
1308 .into_iter()
1309 .filter(|s| {
1310 let session_id = s
1311 .session_id
1312 .as_ref()
1313 .unwrap_or(&String::new())
1314 .to_lowercase();
1315 let title = s.title().to_lowercase();
1316 ids_lower
1317 .iter()
1318 .any(|id| session_id.contains(id) || title.contains(id))
1319 })
1320 .collect()
1321 } else {
1322 provider_sessions
1323 };
1324
1325 if sessions_to_merge.is_empty() {
1326 println!("{} No matching sessions found", "[X]".red());
1327 return Ok(());
1328 }
1329
1330 println!(
1331 "{} Merging {} session(s):",
1332 "[D]".blue(),
1333 sessions_to_merge.len()
1334 );
1335 for s in &sessions_to_merge {
1336 println!(
1337 " {} {} ({} messages)",
1338 "[*]".blue(),
1339 truncate(&s.title(), 50),
1340 s.request_count()
1341 );
1342 }
1343
1344 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1346 std::env::current_dir()
1347 .map(|p| p.to_string_lossy().to_string())
1348 .unwrap_or_else(|_| ".".to_string())
1349 });
1350
1351 let target_ws = find_workspace_by_path(&target_path)?
1352 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1353 let (target_ws_id, target_ws_dir, _) = target_ws;
1354
1355 println!(
1356 "\n{} Target workspace: {}...",
1357 "[>]".blue(),
1358 &target_ws_id[..16.min(target_ws_id.len())]
1359 );
1360
1361 let sessions_with_path: Vec<crate::models::SessionWithPath> = sessions_to_merge
1363 .into_iter()
1364 .map(|session| crate::models::SessionWithPath {
1365 session,
1366 path: std::path::PathBuf::new(), })
1368 .collect();
1369
1370 let auto_title = format!("Imported from {}", provider.name());
1372 let merge_title = title.unwrap_or(&auto_title);
1373
1374 merge_sessions_internal(
1376 sessions_with_path,
1377 Some(merge_title),
1378 &target_ws_id,
1379 &target_ws_dir,
1380 force,
1381 no_backup,
1382 &format!("Provider: {}", provider.name()),
1383 )
1384}
1385
1386pub fn merge_cross_provider(
1388 provider_names: &[String],
1389 title: Option<&str>,
1390 target_path: Option<&str>,
1391 workspace_filter: Option<&str>,
1392 force: bool,
1393 no_backup: bool,
1394) -> Result<()> {
1395 use crate::models::ChatSession;
1396 use crate::providers::{ProviderRegistry, ProviderType};
1397
1398 println!("\n{} Cross-Provider Merge", "[M]".blue().bold());
1399 println!("{}", "=".repeat(70));
1400 println!(
1401 "{} Providers: {}",
1402 "[*]".blue(),
1403 provider_names.join(", ").cyan()
1404 );
1405
1406 if let Some(ws) = workspace_filter {
1407 println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1408 }
1409
1410 let registry = ProviderRegistry::new();
1411 let mut all_sessions: Vec<(String, ChatSession)> = Vec::new(); for provider_name in provider_names {
1415 let provider_type = match provider_name.to_lowercase().as_str() {
1416 "copilot" | "github-copilot" | "vscode" => Some(ProviderType::Copilot),
1417 "cursor" => Some(ProviderType::Cursor),
1418 "ollama" => Some(ProviderType::Ollama),
1419 "vllm" => Some(ProviderType::Vllm),
1420 "foundry" | "azure" | "azure-foundry" => Some(ProviderType::Foundry),
1421 "lm-studio" | "lmstudio" => Some(ProviderType::LmStudio),
1422 "localai" | "local-ai" => Some(ProviderType::LocalAI),
1423 "text-gen-webui" | "textgenwebui" | "oobabooga" => Some(ProviderType::TextGenWebUI),
1424 "jan" | "jan-ai" => Some(ProviderType::Jan),
1425 "gpt4all" => Some(ProviderType::Gpt4All),
1426 "llamafile" => Some(ProviderType::Llamafile),
1427 _ => {
1428 println!(
1429 "{} Unknown provider: {} (skipping)",
1430 "[!]".yellow(),
1431 provider_name
1432 );
1433 None
1434 }
1435 };
1436
1437 if let Some(pt) = provider_type {
1438 if let Some(provider) = registry.get_provider(pt) {
1439 if provider.is_available() {
1440 match provider.list_sessions() {
1441 Ok(sessions) => {
1442 let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1443 let pattern = ws_filter.to_lowercase();
1444 sessions
1445 .into_iter()
1446 .filter(|s| s.title().to_lowercase().contains(&pattern))
1447 .collect()
1448 } else {
1449 sessions
1450 };
1451
1452 println!(
1453 "{} {} ({}): {} session(s)",
1454 "[D]".blue(),
1455 provider.name(),
1456 provider_type
1457 .as_ref()
1458 .map(|p| p.display_name())
1459 .unwrap_or("?"),
1460 filtered.len()
1461 );
1462
1463 for session in filtered {
1464 all_sessions.push((provider.name().to_string(), session));
1465 }
1466 }
1467 Err(e) => {
1468 println!(
1469 "{} Failed to get sessions from {}: {}",
1470 "[!]".yellow(),
1471 provider.name(),
1472 e
1473 );
1474 }
1475 }
1476 } else {
1477 println!(
1478 "{} Provider {} not available",
1479 "[!]".yellow(),
1480 provider.name()
1481 );
1482 }
1483 }
1484 }
1485 }
1486
1487 if all_sessions.is_empty() {
1488 println!("{} No sessions found across providers", "[X]".red());
1489 return Ok(());
1490 }
1491
1492 println!(
1493 "\n{} Total: {} sessions from {} provider(s)",
1494 "[*]".green().bold(),
1495 all_sessions.len(),
1496 provider_names.len()
1497 );
1498
1499 all_sessions.sort_by(|(_, a), (_, b)| {
1501 let a_time = a
1502 .requests
1503 .first()
1504 .map(|r| r.timestamp.unwrap_or(0))
1505 .unwrap_or(0);
1506 let b_time = b
1507 .requests
1508 .first()
1509 .map(|r| r.timestamp.unwrap_or(0))
1510 .unwrap_or(0);
1511 a_time.cmp(&b_time)
1512 });
1513
1514 println!("\n{} Sessions to merge:", "[D]".blue());
1516 for (provider_name, session) in &all_sessions {
1517 println!(
1518 " {} [{}] {} ({} messages)",
1519 "[*]".blue(),
1520 provider_name.cyan(),
1521 truncate(&session.title(), 40),
1522 session.request_count()
1523 );
1524 }
1525
1526 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1528 std::env::current_dir()
1529 .map(|p| p.to_string_lossy().to_string())
1530 .unwrap_or_else(|_| ".".to_string())
1531 });
1532
1533 let target_ws = find_workspace_by_path(&target_path)?
1534 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1535 let (target_ws_id, target_ws_dir, _) = target_ws;
1536
1537 println!(
1538 "\n{} Target workspace: {}...",
1539 "[>]".blue(),
1540 &target_ws_id[..16.min(target_ws_id.len())]
1541 );
1542
1543 let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1545 .into_iter()
1546 .map(|(_, session)| crate::models::SessionWithPath {
1547 session,
1548 path: std::path::PathBuf::new(),
1549 })
1550 .collect();
1551
1552 let auto_title = format!("Cross-provider merge: {}", provider_names.join(", "));
1554 let merge_title = title.unwrap_or(&auto_title);
1555
1556 merge_sessions_internal(
1557 sessions_with_path,
1558 Some(merge_title),
1559 &target_ws_id,
1560 &target_ws_dir,
1561 force,
1562 no_backup,
1563 &format!("{} providers", provider_names.len()),
1564 )
1565}
1566
1567pub fn merge_all_providers(
1569 title: Option<&str>,
1570 target_path: Option<&str>,
1571 workspace_filter: Option<&str>,
1572 force: bool,
1573 no_backup: bool,
1574) -> Result<()> {
1575 use crate::models::ChatSession;
1576 use crate::providers::{ProviderRegistry, ProviderType};
1577
1578 println!("\n{} Merge All Providers", "[M]".blue().bold());
1579 println!("{}", "=".repeat(70));
1580
1581 if let Some(ws) = workspace_filter {
1582 println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1583 }
1584
1585 let registry = ProviderRegistry::new();
1586 let mut all_sessions: Vec<(String, ChatSession)> = Vec::new();
1587 let mut providers_found = 0;
1588
1589 let all_provider_types = vec![
1591 ProviderType::Copilot,
1592 ProviderType::Cursor,
1593 ProviderType::Ollama,
1594 ProviderType::Vllm,
1595 ProviderType::Foundry,
1596 ProviderType::LmStudio,
1597 ProviderType::LocalAI,
1598 ProviderType::TextGenWebUI,
1599 ProviderType::Jan,
1600 ProviderType::Gpt4All,
1601 ProviderType::Llamafile,
1602 ];
1603
1604 println!("{} Scanning providers...", "[*]".blue());
1605
1606 for provider_type in all_provider_types {
1607 if let Some(provider) = registry.get_provider(provider_type) {
1608 if provider.is_available() {
1609 match provider.list_sessions() {
1610 Ok(sessions) if !sessions.is_empty() => {
1611 let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1612 let pattern = ws_filter.to_lowercase();
1613 sessions
1614 .into_iter()
1615 .filter(|s| s.title().to_lowercase().contains(&pattern))
1616 .collect()
1617 } else {
1618 sessions
1619 };
1620
1621 if !filtered.is_empty() {
1622 println!(
1623 " {} {}: {} session(s)",
1624 "[+]".green(),
1625 provider.name(),
1626 filtered.len()
1627 );
1628 providers_found += 1;
1629
1630 for session in filtered {
1631 all_sessions.push((provider.name().to_string(), session));
1632 }
1633 }
1634 }
1635 Ok(_) => {
1636 }
1638 Err(_) => {
1639 }
1641 }
1642 }
1643 }
1644 }
1645
1646 if all_sessions.is_empty() {
1647 println!("{} No sessions found across any providers", "[X]".red());
1648 return Ok(());
1649 }
1650
1651 println!(
1652 "\n{} Found {} sessions across {} provider(s)",
1653 "[*]".green().bold(),
1654 all_sessions.len(),
1655 providers_found
1656 );
1657
1658 all_sessions.sort_by(|(_, a), (_, b)| {
1660 let a_time = a
1661 .requests
1662 .first()
1663 .map(|r| r.timestamp.unwrap_or(0))
1664 .unwrap_or(0);
1665 let b_time = b
1666 .requests
1667 .first()
1668 .map(|r| r.timestamp.unwrap_or(0))
1669 .unwrap_or(0);
1670 a_time.cmp(&b_time)
1671 });
1672
1673 println!("\n{} Sessions to merge:", "[D]".blue());
1675 for (i, (provider_name, session)) in all_sessions.iter().enumerate() {
1676 if i >= 20 {
1677 println!(
1678 " {} ... and {} more",
1679 "[*]".blue(),
1680 all_sessions.len() - 20
1681 );
1682 break;
1683 }
1684 println!(
1685 " {} [{}] {} ({} messages)",
1686 "[*]".blue(),
1687 provider_name.cyan(),
1688 truncate(&session.title(), 40),
1689 session.request_count()
1690 );
1691 }
1692
1693 let target_path = target_path.map(|p| p.to_string()).unwrap_or_else(|| {
1695 std::env::current_dir()
1696 .map(|p| p.to_string_lossy().to_string())
1697 .unwrap_or_else(|_| ".".to_string())
1698 });
1699
1700 let target_ws = find_workspace_by_path(&target_path)?
1701 .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1702 let (target_ws_id, target_ws_dir, _) = target_ws;
1703
1704 println!(
1705 "\n{} Target workspace: {}...",
1706 "[>]".blue(),
1707 &target_ws_id[..16.min(target_ws_id.len())]
1708 );
1709
1710 let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1712 .into_iter()
1713 .map(|(_, session)| crate::models::SessionWithPath {
1714 session,
1715 path: std::path::PathBuf::new(),
1716 })
1717 .collect();
1718
1719 let auto_title = format!("All providers merge ({})", providers_found);
1721 let merge_title = title.unwrap_or(&auto_title);
1722
1723 merge_sessions_internal(
1724 sessions_with_path,
1725 Some(merge_title),
1726 &target_ws_id,
1727 &target_ws_dir,
1728 force,
1729 no_backup,
1730 &format!("{} providers (all)", providers_found),
1731 )
1732}