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