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