1use crate::error::{CsmError, Result};
6use crate::models::{
7 ChatRequest, ChatSession, ChatSessionIndex, ChatSessionIndexEntry, ChatSessionTiming,
8};
9use crate::workspace::{get_empty_window_sessions_path, get_workspace_storage_path};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14use sysinfo::System;
15
16static UNICODE_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\u[0-9a-fA-F]{4}").unwrap());
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VsCodeSessionFormat {
22 LegacyJson,
25 JsonLines,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum SessionSchemaVersion {
33 V1 = 1,
35 V2 = 2,
37 V3 = 3,
39 Unknown = 0,
41}
42
43impl SessionSchemaVersion {
44 pub fn from_version(v: u32) -> Self {
46 match v {
47 1 => Self::V1,
48 2 => Self::V2,
49 3 => Self::V3,
50 _ => Self::Unknown,
51 }
52 }
53
54 pub fn version_number(&self) -> u32 {
56 match self {
57 Self::V1 => 1,
58 Self::V2 => 2,
59 Self::V3 => 3,
60 Self::Unknown => 0,
61 }
62 }
63
64 pub fn description(&self) -> &'static str {
66 match self {
67 Self::V1 => "v1 (basic)",
68 Self::V2 => "v2 (extended metadata)",
69 Self::V3 => "v3 (full structure)",
70 Self::Unknown => "unknown",
71 }
72 }
73}
74
75impl std::fmt::Display for SessionSchemaVersion {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(f, "{}", self.description())
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct SessionFormatInfo {
84 pub format: VsCodeSessionFormat,
86 pub schema_version: SessionSchemaVersion,
88 pub confidence: f32,
90 pub detection_method: &'static str,
92}
93
94impl VsCodeSessionFormat {
95 pub fn from_path(path: &Path) -> Self {
97 match path.extension().and_then(|e| e.to_str()) {
98 Some("jsonl") => Self::JsonLines,
99 _ => Self::LegacyJson,
100 }
101 }
102
103 pub fn from_content(content: &str) -> Self {
105 let trimmed = content.trim();
106
107 if trimmed.starts_with("{\"kind\":") || trimmed.starts_with("{ \"kind\":") {
109 return Self::JsonLines;
110 }
111
112 let mut json_object_lines = 0;
114 let mut total_non_empty_lines = 0;
115
116 for line in trimmed.lines().take(10) {
117 let line = line.trim();
118 if line.is_empty() {
119 continue;
120 }
121 total_non_empty_lines += 1;
122
123 if line.starts_with('{') && line.contains("\"kind\"") {
125 json_object_lines += 1;
126 }
127 }
128
129 if json_object_lines >= 2
131 || (json_object_lines == 1 && total_non_empty_lines == 1 && trimmed.contains("\n{"))
132 {
133 return Self::JsonLines;
134 }
135
136 if trimmed.starts_with('{') && trimmed.ends_with('}') {
138 if trimmed.contains("\"sessionId\"")
140 || trimmed.contains("\"creationDate\"")
141 || trimmed.contains("\"requests\"")
142 {
143 return Self::LegacyJson;
144 }
145 }
146
147 Self::LegacyJson
149 }
150
151 pub fn min_vscode_version(&self) -> &'static str {
153 match self {
154 Self::LegacyJson => "1.0.0",
155 Self::JsonLines => "1.109.0",
156 }
157 }
158
159 pub fn description(&self) -> &'static str {
161 match self {
162 Self::LegacyJson => "Legacy JSON (single object)",
163 Self::JsonLines => "JSON Lines (event-sourced, VS Code 1.109.0+)",
164 }
165 }
166
167 pub fn short_name(&self) -> &'static str {
169 match self {
170 Self::LegacyJson => "json",
171 Self::JsonLines => "jsonl",
172 }
173 }
174}
175
176impl std::fmt::Display for VsCodeSessionFormat {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 write!(f, "{}", self.description())
179 }
180}
181
182fn sanitize_json_unicode(content: &str) -> String {
185 let mut result = String::with_capacity(content.len());
187 let mut last_end = 0;
188
189 let matches: Vec<_> = UNICODE_ESCAPE_RE.find_iter(content).collect();
191
192 for (i, mat) in matches.iter().enumerate() {
193 let start = mat.start();
194 let end = mat.end();
195
196 result.push_str(&content[last_end..start]);
198
199 let hex_str = &mat.as_str()[2..]; if let Ok(code_point) = u16::from_str_radix(hex_str, 16) {
202 if (0xD800..=0xDBFF).contains(&code_point) {
204 let is_valid_pair = if let Some(next_mat) = matches.get(i + 1) {
206 if next_mat.start() == end {
208 let next_hex = &next_mat.as_str()[2..];
209 if let Ok(next_cp) = u16::from_str_radix(next_hex, 16) {
210 (0xDC00..=0xDFFF).contains(&next_cp)
211 } else {
212 false
213 }
214 } else {
215 false
216 }
217 } else {
218 false
219 };
220
221 if is_valid_pair {
222 result.push_str(mat.as_str());
224 } else {
225 result.push_str("\\uFFFD");
227 }
228 }
229 else if (0xDC00..=0xDFFF).contains(&code_point) {
231 let is_valid_pair = if i > 0 {
233 if let Some(prev_mat) = matches.get(i - 1) {
234 if prev_mat.end() == start {
236 let prev_hex = &prev_mat.as_str()[2..];
237 if let Ok(prev_cp) = u16::from_str_radix(prev_hex, 16) {
238 (0xD800..=0xDBFF).contains(&prev_cp)
239 } else {
240 false
241 }
242 } else {
243 false
244 }
245 } else {
246 false
247 }
248 } else {
249 false
250 };
251
252 if is_valid_pair {
253 result.push_str(mat.as_str());
255 } else {
256 result.push_str("\\uFFFD");
258 }
259 }
260 else {
262 result.push_str(mat.as_str());
263 }
264 } else {
265 result.push_str(mat.as_str());
267 }
268 last_end = end;
269 }
270
271 result.push_str(&content[last_end..]);
273 result
274}
275
276pub fn parse_session_json(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
278 match serde_json::from_str::<ChatSession>(content) {
279 Ok(session) => Ok(session),
280 Err(e) => {
281 if e.to_string().contains("surrogate") || e.to_string().contains("escape") {
283 let sanitized = sanitize_json_unicode(content);
284 serde_json::from_str::<ChatSession>(&sanitized)
285 } else {
286 Err(e)
287 }
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294enum JsonlKind {
295 Initial = 0,
297 Delta = 1,
299 RequestsUpdate = 2,
301}
302
303pub fn parse_session_jsonl(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
309 let content = split_concatenated_jsonl(content);
311
312 let mut session = ChatSession {
313 version: 3,
314 session_id: None,
315 creation_date: 0,
316 last_message_date: 0,
317 is_imported: false,
318 initial_location: "panel".to_string(),
319 custom_title: None,
320 requester_username: None,
321 requester_avatar_icon_uri: None,
322 responder_username: None,
323 responder_avatar_icon_uri: None,
324 requests: Vec::new(),
325 };
326
327 for line in content.lines() {
328 let line = line.trim();
329 if line.is_empty() {
330 continue;
331 }
332
333 let entry: serde_json::Value = match serde_json::from_str(line) {
335 Ok(v) => v,
336 Err(_) => {
337 let sanitized = sanitize_json_unicode(line);
339 serde_json::from_str(&sanitized)?
340 }
341 };
342
343 let kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(0);
344
345 match kind {
346 0 => {
347 if let Some(v) = entry.get("v") {
349 if let Some(version) = v.get("version").and_then(|x| x.as_u64()) {
351 session.version = version as u32;
352 }
353 if let Some(sid) = v.get("sessionId").and_then(|x| x.as_str()) {
355 session.session_id = Some(sid.to_string());
356 }
357 if let Some(cd) = v.get("creationDate").and_then(|x| x.as_i64()) {
359 session.creation_date = cd;
360 }
361 if let Some(loc) = v.get("initialLocation").and_then(|x| x.as_str()) {
363 session.initial_location = loc.to_string();
364 }
365 if let Some(ru) = v.get("responderUsername").and_then(|x| x.as_str()) {
367 session.responder_username = Some(ru.to_string());
368 }
369 if let Some(title) = v.get("customTitle").and_then(|x| x.as_str()) {
371 session.custom_title = Some(title.to_string());
372 }
373 if let Some(imported) = v.get("isImported").and_then(|x| x.as_bool()) {
375 session.is_imported = imported;
376 }
377 if let Some(requests) = v.get("requests") {
379 if let Ok(reqs) =
380 serde_json::from_value::<Vec<ChatRequest>>(requests.clone())
381 {
382 session.requests = reqs;
383 if let Some(latest_ts) =
385 session.requests.iter().filter_map(|r| r.timestamp).max()
386 {
387 session.last_message_date = latest_ts;
388 }
389 }
390 }
391 if session.last_message_date == 0 {
393 session.last_message_date = session.creation_date;
394 }
395 }
396 }
397 1 => {
398 if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
400 if let Some(keys_arr) = keys.as_array() {
401 if keys_arr.len() == 1 {
403 if let Some(key) = keys_arr[0].as_str() {
404 match key {
405 "customTitle" => {
406 if let Some(title) = value.as_str() {
407 session.custom_title = Some(title.to_string());
408 }
409 }
410 "lastMessageDate" => {
411 if let Some(date) = value.as_i64() {
412 session.last_message_date = date;
413 }
414 }
415 "hasPendingEdits" | "isImported" => {
416 }
418 _ => {} }
420 }
421 }
422 else if keys_arr.len() == 3 {
424 if let (Some("requests"), Some(idx), Some(field)) = (
425 keys_arr[0].as_str(),
426 keys_arr[1].as_u64().map(|i| i as usize),
427 keys_arr[2].as_str(),
428 ) {
429 if idx < session.requests.len() {
430 match field {
431 "response" => {
432 session.requests[idx].response = Some(value.clone());
433 }
434 "result" => {
435 session.requests[idx].result = Some(value.clone());
436 }
437 "followups" => {
438 session.requests[idx].followups =
439 serde_json::from_value(value.clone()).ok();
440 }
441 "isCanceled" => {
442 session.requests[idx].is_canceled = value.as_bool();
443 }
444 "contentReferences" => {
445 session.requests[idx].content_references =
446 serde_json::from_value(value.clone()).ok();
447 }
448 "codeCitations" => {
449 session.requests[idx].code_citations =
450 serde_json::from_value(value.clone()).ok();
451 }
452 "modelState" | "modelId" | "agent" | "variableData" => {
453 }
456 _ => {} }
458 }
459 }
460 }
461 }
462 }
463 }
464 2 => {
465 if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
467 if let Some(keys_arr) = keys.as_array() {
468 if keys_arr.len() == 1 {
470 if let Some("requests") = keys_arr[0].as_str() {
471 if let Some(items) = value.as_array() {
472 for item in items {
473 if let Ok(req) =
474 serde_json::from_value::<ChatRequest>(item.clone())
475 {
476 session.requests.push(req);
477 }
478 }
479 if let Some(last_req) = session.requests.last() {
481 if let Some(ts) = last_req.timestamp {
482 session.last_message_date = ts;
483 }
484 }
485 }
486 }
487 }
488 }
492 }
493 }
494 _ => {} }
496 }
497
498 Ok(session)
499}
500
501pub fn is_session_file_extension(ext: &std::ffi::OsStr) -> bool {
503 ext == "json" || ext == "jsonl"
504}
505
506pub fn detect_session_format(content: &str) -> SessionFormatInfo {
508 let format = VsCodeSessionFormat::from_content(content);
509 let trimmed = content.trim();
510
511 let (schema_version, confidence, method) = match format {
513 VsCodeSessionFormat::JsonLines => {
514 if let Some(first_line) = trimmed.lines().next() {
516 if let Ok(entry) = serde_json::from_str::<serde_json::Value>(first_line) {
517 if let Some(v) = entry.get("v") {
518 if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
519 (
520 SessionSchemaVersion::from_version(ver as u32),
521 0.95,
522 "jsonl-version-field",
523 )
524 } else {
525 (SessionSchemaVersion::V3, 0.7, "jsonl-default")
527 }
528 } else {
529 (SessionSchemaVersion::V3, 0.6, "jsonl-no-v-field")
530 }
531 } else {
532 (SessionSchemaVersion::Unknown, 0.3, "jsonl-parse-error")
533 }
534 } else {
535 (SessionSchemaVersion::Unknown, 0.2, "jsonl-empty")
536 }
537 }
538 VsCodeSessionFormat::LegacyJson => {
539 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
541 if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
542 (
543 SessionSchemaVersion::from_version(ver as u32),
544 0.95,
545 "json-version-field",
546 )
547 } else {
548 if json.get("requests").is_some() && json.get("sessionId").is_some() {
550 (SessionSchemaVersion::V3, 0.8, "json-structure-inference")
551 } else if json.get("messages").is_some() {
552 (SessionSchemaVersion::V1, 0.7, "json-legacy-structure")
553 } else {
554 (SessionSchemaVersion::Unknown, 0.4, "json-unknown-structure")
555 }
556 }
557 } else {
558 let sanitized = sanitize_json_unicode(trimmed);
560 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&sanitized) {
561 if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
562 (
563 SessionSchemaVersion::from_version(ver as u32),
564 0.9,
565 "json-version-after-sanitize",
566 )
567 } else {
568 (SessionSchemaVersion::V3, 0.6, "json-default-after-sanitize")
569 }
570 } else {
571 (SessionSchemaVersion::Unknown, 0.2, "json-parse-error")
572 }
573 }
574 }
575 };
576
577 SessionFormatInfo {
578 format,
579 schema_version,
580 confidence,
581 detection_method: method,
582 }
583}
584
585pub fn parse_session_auto(
587 content: &str,
588) -> std::result::Result<(ChatSession, SessionFormatInfo), serde_json::Error> {
589 let format_info = detect_session_format(content);
590
591 let session = match format_info.format {
592 VsCodeSessionFormat::JsonLines => parse_session_jsonl(content)?,
593 VsCodeSessionFormat::LegacyJson => parse_session_json(content)?,
594 };
595
596 Ok((session, format_info))
597}
598
599pub fn parse_session_file(path: &Path) -> std::result::Result<ChatSession, serde_json::Error> {
601 let content = std::fs::read_to_string(path)
602 .map_err(|e| serde_json::Error::io(std::io::Error::other(e.to_string())))?;
603
604 let (session, _format_info) = parse_session_auto(&content)?;
606 Ok(session)
607}
608
609pub fn get_workspace_storage_db(workspace_id: &str) -> Result<PathBuf> {
611 let storage_path = get_workspace_storage_path()?;
612 Ok(storage_path.join(workspace_id).join("state.vscdb"))
613}
614
615pub fn read_chat_session_index(db_path: &Path) -> Result<ChatSessionIndex> {
617 let conn = Connection::open(db_path)?;
618
619 let result: std::result::Result<String, rusqlite::Error> = conn.query_row(
620 "SELECT value FROM ItemTable WHERE key = ?",
621 ["chat.ChatSessionStore.index"],
622 |row| row.get(0),
623 );
624
625 match result {
626 Ok(json_str) => serde_json::from_str(&json_str)
627 .map_err(|e| CsmError::InvalidSessionFormat(e.to_string())),
628 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ChatSessionIndex::default()),
629 Err(e) => Err(CsmError::SqliteError(e)),
630 }
631}
632
633pub fn write_chat_session_index(db_path: &Path, index: &ChatSessionIndex) -> Result<()> {
635 let conn = Connection::open(db_path)?;
636 let json_str = serde_json::to_string(index)?;
637
638 let exists: bool = conn.query_row(
640 "SELECT COUNT(*) > 0 FROM ItemTable WHERE key = ?",
641 ["chat.ChatSessionStore.index"],
642 |row| row.get(0),
643 )?;
644
645 if exists {
646 conn.execute(
647 "UPDATE ItemTable SET value = ? WHERE key = ?",
648 [&json_str, "chat.ChatSessionStore.index"],
649 )?;
650 } else {
651 conn.execute(
652 "INSERT INTO ItemTable (key, value) VALUES (?, ?)",
653 ["chat.ChatSessionStore.index", &json_str],
654 )?;
655 }
656
657 Ok(())
658}
659
660pub fn add_session_to_index(
662 db_path: &Path,
663 session_id: &str,
664 title: &str,
665 last_message_date_ms: i64,
666 _is_imported: bool,
667 initial_location: &str,
668 is_empty: bool,
669) -> Result<()> {
670 let mut index = read_chat_session_index(db_path)?;
671
672 index.entries.insert(
673 session_id.to_string(),
674 ChatSessionIndexEntry {
675 session_id: session_id.to_string(),
676 title: title.to_string(),
677 last_message_date: last_message_date_ms,
678 timing: Some(ChatSessionTiming {
679 created: last_message_date_ms,
680 last_request_started: Some(last_message_date_ms),
681 last_request_ended: Some(last_message_date_ms),
682 }),
683 last_response_state: 1, initial_location: initial_location.to_string(),
685 is_empty,
686 },
687 );
688
689 write_chat_session_index(db_path, &index)
690}
691
692#[allow(dead_code)]
694pub fn remove_session_from_index(db_path: &Path, session_id: &str) -> Result<bool> {
695 let mut index = read_chat_session_index(db_path)?;
696 let removed = index.entries.remove(session_id).is_some();
697 if removed {
698 write_chat_session_index(db_path, &index)?;
699 }
700 Ok(removed)
701}
702
703pub fn sync_session_index(
706 workspace_id: &str,
707 chat_sessions_dir: &Path,
708 force: bool,
709) -> Result<(usize, usize)> {
710 let db_path = get_workspace_storage_db(workspace_id)?;
711
712 if !db_path.exists() {
713 return Err(CsmError::WorkspaceNotFound(format!(
714 "Database not found: {}",
715 db_path.display()
716 )));
717 }
718
719 if !force && is_vscode_running() {
721 return Err(CsmError::VSCodeRunning);
722 }
723
724 let mut index = read_chat_session_index(&db_path)?;
726
727 let mut files_on_disk: std::collections::HashSet<String> = std::collections::HashSet::new();
729 if chat_sessions_dir.exists() {
730 for entry in std::fs::read_dir(chat_sessions_dir)? {
731 let entry = entry?;
732 let path = entry.path();
733 if path
734 .extension()
735 .map(is_session_file_extension)
736 .unwrap_or(false)
737 {
738 if let Some(stem) = path.file_stem() {
739 files_on_disk.insert(stem.to_string_lossy().to_string());
740 }
741 }
742 }
743 }
744
745 let stale_ids: Vec<String> = index
747 .entries
748 .keys()
749 .filter(|id| !files_on_disk.contains(*id))
750 .cloned()
751 .collect();
752
753 let removed = stale_ids.len();
754 for id in &stale_ids {
755 index.entries.remove(id);
756 }
757
758 let mut session_files: std::collections::HashMap<String, PathBuf> =
761 std::collections::HashMap::new();
762 for entry in std::fs::read_dir(chat_sessions_dir)? {
763 let entry = entry?;
764 let path = entry.path();
765 if path
766 .extension()
767 .map(is_session_file_extension)
768 .unwrap_or(false)
769 {
770 if let Some(stem) = path.file_stem() {
771 let stem_str = stem.to_string_lossy().to_string();
772 let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
773 if !session_files.contains_key(&stem_str) || is_jsonl {
775 session_files.insert(stem_str, path);
776 }
777 }
778 }
779 }
780
781 let mut added = 0;
782 for (_, path) in &session_files {
783 if let Ok(session) = parse_session_file(path) {
784 let session_id = session.session_id.clone().unwrap_or_else(|| {
785 path.file_stem()
786 .map(|s| s.to_string_lossy().to_string())
787 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
788 });
789
790 let title = session.title();
791 let is_empty = session.is_empty();
792 let last_message_date = session.last_message_date;
793 let initial_location = session.initial_location.clone();
794
795 index.entries.insert(
796 session_id.clone(),
797 ChatSessionIndexEntry {
798 session_id,
799 title,
800 last_message_date,
801 timing: Some(ChatSessionTiming {
802 created: session.creation_date,
803 last_request_started: Some(last_message_date),
804 last_request_ended: Some(last_message_date),
805 }),
806 last_response_state: 1, initial_location,
808 is_empty,
809 },
810 );
811 added += 1;
812 }
813 }
814
815 write_chat_session_index(&db_path, &index)?;
817
818 Ok((added, removed))
819}
820
821pub fn register_all_sessions_from_directory(
823 workspace_id: &str,
824 chat_sessions_dir: &Path,
825 force: bool,
826) -> Result<usize> {
827 let db_path = get_workspace_storage_db(workspace_id)?;
828
829 if !db_path.exists() {
830 return Err(CsmError::WorkspaceNotFound(format!(
831 "Database not found: {}",
832 db_path.display()
833 )));
834 }
835
836 if !force && is_vscode_running() {
838 return Err(CsmError::VSCodeRunning);
839 }
840
841 let (added, removed) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
843
844 for entry in std::fs::read_dir(chat_sessions_dir)? {
846 let entry = entry?;
847 let path = entry.path();
848
849 if path
850 .extension()
851 .map(is_session_file_extension)
852 .unwrap_or(false)
853 {
854 if let Ok(session) = parse_session_file(&path) {
855 let session_id = session.session_id.clone().unwrap_or_else(|| {
856 path.file_stem()
857 .map(|s| s.to_string_lossy().to_string())
858 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
859 });
860
861 let title = session.title();
862
863 println!(
864 "[OK] Registered: {} ({}...)",
865 title,
866 &session_id[..12.min(session_id.len())]
867 );
868 }
869 }
870 }
871
872 if removed > 0 {
873 println!("[OK] Removed {} stale index entries", removed);
874 }
875
876 Ok(added)
877}
878
879pub fn is_vscode_running() -> bool {
881 let mut sys = System::new();
882 sys.refresh_processes();
883
884 for process in sys.processes().values() {
885 let name = process.name().to_lowercase();
886 if name.contains("code") && !name.contains("codec") {
887 return true;
888 }
889 }
890
891 false
892}
893
894pub fn close_vscode_and_wait(timeout_secs: u64) -> Result<()> {
897 use sysinfo::{ProcessRefreshKind, RefreshKind, Signal};
898
899 if !is_vscode_running() {
900 return Ok(());
901 }
902
903 let mut sys = System::new_with_specifics(
905 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
906 );
907 sys.refresh_processes();
908
909 let mut signaled = 0u32;
910 for (pid, process) in sys.processes() {
911 let name = process.name().to_lowercase();
912 if name.contains("code") && !name.contains("codec") {
913 #[cfg(windows)]
918 {
919 let _ = std::process::Command::new("taskkill")
920 .args(["/PID", &pid.as_u32().to_string()])
921 .stdout(std::process::Stdio::null())
922 .stderr(std::process::Stdio::null())
923 .status();
924 signaled += 1;
925 }
926 #[cfg(not(windows))]
927 {
928 if process.kill_with(Signal::Term).unwrap_or(false) {
929 signaled += 1;
930 }
931 }
932 }
933 }
934
935 if signaled == 0 {
936 return Ok(());
937 }
938
939 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
941 loop {
942 std::thread::sleep(std::time::Duration::from_millis(500));
943 if !is_vscode_running() {
944 std::thread::sleep(std::time::Duration::from_secs(1));
946 return Ok(());
947 }
948 if std::time::Instant::now() >= deadline {
949 let mut sys2 = System::new_with_specifics(
951 RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
952 );
953 sys2.refresh_processes();
954 for (_pid, process) in sys2.processes() {
955 let name = process.name().to_lowercase();
956 if name.contains("code") && !name.contains("codec") {
957 process.kill();
958 }
959 }
960 std::thread::sleep(std::time::Duration::from_secs(1));
961 return Ok(());
962 }
963 }
964}
965
966pub fn reopen_vscode(project_path: Option<&str>) -> Result<()> {
968 let mut cmd = std::process::Command::new("code");
969 if let Some(path) = project_path {
970 cmd.arg(path);
971 }
972 cmd.stdout(std::process::Stdio::null())
973 .stderr(std::process::Stdio::null())
974 .spawn()?;
975 Ok(())
976}
977
978pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
980 let chat_sessions_dir = workspace_dir.join("chatSessions");
981
982 if !chat_sessions_dir.exists() {
983 return Ok(None);
984 }
985
986 let timestamp = std::time::SystemTime::now()
987 .duration_since(std::time::UNIX_EPOCH)
988 .unwrap()
989 .as_secs();
990
991 let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
992
993 copy_dir_all(&chat_sessions_dir, &backup_dir)?;
995
996 Ok(Some(backup_dir))
997}
998
999fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
1001 std::fs::create_dir_all(dst)?;
1002
1003 for entry in std::fs::read_dir(src)? {
1004 let entry = entry?;
1005 let src_path = entry.path();
1006 let dst_path = dst.join(entry.file_name());
1007
1008 if src_path.is_dir() {
1009 copy_dir_all(&src_path, &dst_path)?;
1010 } else {
1011 std::fs::copy(&src_path, &dst_path)?;
1012 }
1013 }
1014
1015 Ok(())
1016}
1017
1018pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
1025 let sessions_path = get_empty_window_sessions_path()?;
1026
1027 if !sessions_path.exists() {
1028 return Ok(Vec::new());
1029 }
1030
1031 let mut sessions = Vec::new();
1032
1033 for entry in std::fs::read_dir(&sessions_path)? {
1034 let entry = entry?;
1035 let path = entry.path();
1036
1037 if path.extension().is_some_and(is_session_file_extension) {
1038 if let Ok(session) = parse_session_file(&path) {
1039 sessions.push(session);
1040 }
1041 }
1042 }
1043
1044 sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
1046
1047 Ok(sessions)
1048}
1049
1050#[allow(dead_code)]
1052pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
1053 let sessions_path = get_empty_window_sessions_path()?;
1054 let session_path = sessions_path.join(format!("{}.json", session_id));
1055
1056 if !session_path.exists() {
1057 return Ok(None);
1058 }
1059
1060 let content = std::fs::read_to_string(&session_path)?;
1061 let session: ChatSession = serde_json::from_str(&content)
1062 .map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
1063
1064 Ok(Some(session))
1065}
1066
1067#[allow(dead_code)]
1069pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
1070 let sessions_path = get_empty_window_sessions_path()?;
1071
1072 std::fs::create_dir_all(&sessions_path)?;
1074
1075 let session_id = session.session_id.as_deref().unwrap_or("unknown");
1076 let session_path = sessions_path.join(format!("{}.json", session_id));
1077 let content = serde_json::to_string_pretty(session)?;
1078 std::fs::write(&session_path, content)?;
1079
1080 Ok(session_path)
1081}
1082
1083#[allow(dead_code)]
1085pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
1086 let sessions_path = get_empty_window_sessions_path()?;
1087 let session_path = sessions_path.join(format!("{}.json", session_id));
1088
1089 if session_path.exists() {
1090 std::fs::remove_file(&session_path)?;
1091 Ok(true)
1092 } else {
1093 Ok(false)
1094 }
1095}
1096
1097pub fn count_empty_window_sessions() -> Result<usize> {
1099 let sessions_path = get_empty_window_sessions_path()?;
1100
1101 if !sessions_path.exists() {
1102 return Ok(0);
1103 }
1104
1105 let count = std::fs::read_dir(&sessions_path)?
1106 .filter_map(|e| e.ok())
1107 .filter(|e| e.path().extension().is_some_and(is_session_file_extension))
1108 .count();
1109
1110 Ok(count)
1111}
1112
1113pub fn compact_session_jsonl(path: &Path) -> Result<PathBuf> {
1120 let content = std::fs::read_to_string(path).map_err(|e| {
1121 CsmError::InvalidSessionFormat(format!("Failed to read {}: {}", path.display(), e))
1122 })?;
1123
1124 let content = split_concatenated_jsonl(&content);
1129
1130 let mut lines = content.lines();
1131
1132 let first_line = lines
1134 .next()
1135 .ok_or_else(|| CsmError::InvalidSessionFormat("Empty JSONL file".to_string()))?;
1136
1137 let first_entry: serde_json::Value = match serde_json::from_str(first_line.trim()) {
1138 Ok(v) => v,
1139 Err(_) => {
1140 let sanitized = sanitize_json_unicode(first_line.trim());
1142 serde_json::from_str(&sanitized).map_err(|e| {
1143 CsmError::InvalidSessionFormat(format!("Invalid JSON on line 1: {}", e))
1144 })?
1145 }
1146 };
1147
1148 let kind = first_entry
1149 .get("kind")
1150 .and_then(|k| k.as_u64())
1151 .unwrap_or(99);
1152 if kind != 0 {
1153 return Err(CsmError::InvalidSessionFormat(
1154 "First JSONL line must be kind:0".to_string(),
1155 ));
1156 }
1157
1158 let mut state = first_entry
1160 .get("v")
1161 .cloned()
1162 .ok_or_else(|| CsmError::InvalidSessionFormat("kind:0 missing 'v' field".to_string()))?;
1163
1164 for line in lines {
1166 let line = line.trim();
1167 if line.is_empty() {
1168 continue;
1169 }
1170
1171 let entry: serde_json::Value = match serde_json::from_str(line) {
1172 Ok(v) => v,
1173 Err(_) => continue, };
1175
1176 let op_kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(99);
1177
1178 match op_kind {
1179 1 => {
1180 if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
1182 if let Some(keys_arr) = keys.as_array() {
1183 apply_delta(&mut state, keys_arr, value.clone());
1184 }
1185 }
1186 }
1187 2 => {
1188 if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
1190 if let Some(keys_arr) = keys.as_array() {
1191 apply_append(&mut state, keys_arr, value.clone());
1192 }
1193 }
1194 }
1195 _ => {} }
1197 }
1198
1199 let session_id = path
1201 .file_stem()
1202 .and_then(|s| s.to_str())
1203 .map(|s| s.to_string());
1204 ensure_vscode_compat_fields(&mut state, session_id.as_deref());
1205
1206 let compact_entry = serde_json::json!({"kind": 0, "v": state});
1208 let compact_content = serde_json::to_string(&compact_entry)
1209 .map_err(|e| CsmError::InvalidSessionFormat(format!("Failed to serialize: {}", e)))?;
1210
1211 let backup_path = path.with_extension("jsonl.bak");
1213 std::fs::rename(path, &backup_path)?;
1214
1215 std::fs::write(path, &compact_content)?;
1217
1218 Ok(backup_path)
1219}
1220
1221pub fn split_concatenated_jsonl(content: &str) -> String {
1231 if !content.contains("}{\"kind\":") {
1233 return content.to_string();
1234 }
1235
1236 content.replace("}{\"kind\":", "}\n{\"kind\":")
1237}
1238
1239fn apply_delta(root: &mut serde_json::Value, keys: &[serde_json::Value], value: serde_json::Value) {
1241 if keys.is_empty() {
1242 return;
1243 }
1244
1245 let mut current = root;
1247 for key in &keys[..keys.len() - 1] {
1248 if let Some(k) = key.as_str() {
1249 if !current.get(k).is_some() {
1250 current[k] = serde_json::Value::Object(serde_json::Map::new());
1251 }
1252 current = &mut current[k];
1253 } else if let Some(idx) = key.as_u64() {
1254 if let Some(arr) = current.as_array_mut() {
1255 if (idx as usize) < arr.len() {
1256 current = &mut arr[idx as usize];
1257 } else {
1258 return; }
1260 } else {
1261 return;
1262 }
1263 }
1264 }
1265
1266 if let Some(last_key) = keys.last() {
1268 if let Some(k) = last_key.as_str() {
1269 current[k] = value;
1270 } else if let Some(idx) = last_key.as_u64() {
1271 if let Some(arr) = current.as_array_mut() {
1272 if (idx as usize) < arr.len() {
1273 arr[idx as usize] = value;
1274 }
1275 }
1276 }
1277 }
1278}
1279
1280fn apply_append(
1282 root: &mut serde_json::Value,
1283 keys: &[serde_json::Value],
1284 items: serde_json::Value,
1285) {
1286 if keys.is_empty() {
1287 return;
1288 }
1289
1290 let mut current = root;
1292 for key in keys {
1293 if let Some(k) = key.as_str() {
1294 if !current.get(k).is_some() {
1295 current[k] = serde_json::json!([]);
1296 }
1297 current = &mut current[k];
1298 } else if let Some(idx) = key.as_u64() {
1299 if let Some(arr) = current.as_array_mut() {
1300 if (idx as usize) < arr.len() {
1301 current = &mut arr[idx as usize];
1302 } else {
1303 return;
1304 }
1305 } else {
1306 return;
1307 }
1308 }
1309 }
1310
1311 if let (Some(target_arr), Some(new_items)) = (current.as_array_mut(), items.as_array()) {
1313 target_arr.extend(new_items.iter().cloned());
1314 }
1315}
1316
1317pub fn ensure_vscode_compat_fields(state: &mut serde_json::Value, session_id: Option<&str>) {
1330 if let Some(obj) = state.as_object_mut() {
1331 if !obj.contains_key("version") {
1333 obj.insert("version".to_string(), serde_json::json!(3));
1334 }
1335
1336 if !obj.contains_key("sessionId") {
1338 if let Some(id) = session_id {
1339 obj.insert("sessionId".to_string(), serde_json::json!(id));
1340 }
1341 }
1342
1343 if !obj.contains_key("responderUsername") {
1345 obj.insert(
1346 "responderUsername".to_string(),
1347 serde_json::json!("GitHub Copilot"),
1348 );
1349 }
1350
1351 if !obj.contains_key("hasPendingEdits") {
1353 obj.insert("hasPendingEdits".to_string(), serde_json::json!(false));
1354 }
1355
1356 if !obj.contains_key("pendingRequests") {
1358 obj.insert(
1359 "pendingRequests".to_string(),
1360 serde_json::json!([]),
1361 );
1362 }
1363
1364 if !obj.contains_key("inputState") {
1366 obj.insert(
1367 "inputState".to_string(),
1368 serde_json::json!({
1369 "attachments": [],
1370 "mode": { "id": "agent", "kind": "agent" },
1371 "inputText": "",
1372 "selections": [],
1373 "contrib": { "chatDynamicVariableModel": [] }
1374 }),
1375 );
1376 }
1377 }
1378}
1379
1380pub fn repair_workspace_sessions(
1383 workspace_id: &str,
1384 chat_sessions_dir: &Path,
1385 force: bool,
1386) -> Result<(usize, usize)> {
1387 let db_path = get_workspace_storage_db(workspace_id)?;
1388
1389 if !db_path.exists() {
1390 return Err(CsmError::WorkspaceNotFound(format!(
1391 "Database not found: {}",
1392 db_path.display()
1393 )));
1394 }
1395
1396 if !force && is_vscode_running() {
1397 return Err(CsmError::VSCodeRunning);
1398 }
1399
1400 let mut compacted = 0;
1401 let mut fields_fixed = 0;
1402
1403 if chat_sessions_dir.exists() {
1404 for entry in std::fs::read_dir(chat_sessions_dir)? {
1406 let entry = entry?;
1407 let path = entry.path();
1408 if path.extension().is_some_and(|e| e == "jsonl") {
1409 let metadata = std::fs::metadata(&path)?;
1410 let size_mb = metadata.len() / (1024 * 1024);
1411
1412 let content = std::fs::read_to_string(&path)
1413 .map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
1414 let line_count = content.lines().count();
1415
1416 if line_count > 1 {
1417 let stem = path
1419 .file_stem()
1420 .map(|s| s.to_string_lossy().to_string())
1421 .unwrap_or_default();
1422 println!(
1423 " Compacting {} ({} lines, {}MB)...",
1424 stem, line_count, size_mb
1425 );
1426
1427 match compact_session_jsonl(&path) {
1428 Ok(backup_path) => {
1429 let new_size = std::fs::metadata(&path)
1430 .map(|m| m.len() / (1024 * 1024))
1431 .unwrap_or(0);
1432 println!(
1433 " [OK] Compacted: {}MB -> {}MB (backup: {})",
1434 size_mb,
1435 new_size,
1436 backup_path
1437 .file_name()
1438 .unwrap_or_default()
1439 .to_string_lossy()
1440 );
1441 compacted += 1;
1442 }
1443 Err(e) => {
1444 println!(" [WARN] Failed to compact {}: {}", stem, e);
1445 }
1446 }
1447 } else {
1448 if let Some(first_line) = content.lines().next() {
1450 if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(first_line) {
1451 let is_kind_0 = obj
1452 .get("kind")
1453 .and_then(|k| k.as_u64())
1454 .map(|k| k == 0)
1455 .unwrap_or(false);
1456
1457 if is_kind_0 {
1458 if let Some(v) = obj.get("v") {
1459 let missing = !v.get("hasPendingEdits").is_some()
1460 || !v.get("pendingRequests").is_some()
1461 || !v.get("inputState").is_some()
1462 || !v.get("sessionId").is_some();
1463
1464 if missing {
1465 let session_id = path
1466 .file_stem()
1467 .and_then(|s| s.to_str())
1468 .map(|s| s.to_string());
1469 if let Some(v_mut) = obj.get_mut("v") {
1470 ensure_vscode_compat_fields(
1471 v_mut,
1472 session_id.as_deref(),
1473 );
1474 }
1475 let patched = serde_json::to_string(&obj).map_err(|e| {
1476 CsmError::InvalidSessionFormat(format!(
1477 "Failed to serialize: {}",
1478 e
1479 ))
1480 })?;
1481 std::fs::write(&path, &patched)?;
1482 let stem = path
1483 .file_stem()
1484 .map(|s| s.to_string_lossy().to_string())
1485 .unwrap_or_default();
1486 println!(
1487 " [OK] Fixed missing VS Code fields: {}",
1488 stem
1489 );
1490 fields_fixed += 1;
1491 }
1492 }
1493 }
1494 }
1495 }
1496 }
1497 }
1498 }
1499 }
1500
1501 let (index_fixed, _) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
1503
1504 if fields_fixed > 0 {
1505 println!(
1506 " [OK] Injected missing VS Code fields into {} session(s)",
1507 fields_fixed
1508 );
1509 }
1510
1511 Ok((compacted, index_fixed))
1512}