1use anyhow::{Context, Result};
4use percent_encoding::percent_decode_str;
5use rusqlite::Connection;
6use serde_json::Value;
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use crate::cursor::sqlite_value::query_optional_utf8_string_like_value;
11use crate::cursor::workspace;
12
13const GLOBAL_HEADERS_KEY: &str = "composer.composerHeaders";
14const LOCAL_COMPOSER_DATA_KEY: &str = "composer.composerData";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SessionMetadata {
19 pub composer_id: String,
20 pub title: Option<String>,
21 pub created_at_ms: Option<i64>,
22 pub updated_at_ms: Option<i64>,
23}
24
25#[derive(Debug, Clone, Default)]
26struct WorkspaceIdentity {
27 workspace_id: Option<String>,
28 folder_uri_normalized: Option<String>,
29 workspace_path_normalized: Option<String>,
30 remote_authority: Option<String>,
31 is_remote: bool,
32}
33
34impl WorkspaceIdentity {
35 fn from_workspace_dir(workspace_dir: &Path) -> Self {
36 let workspace_id = workspace_dir
37 .file_name()
38 .map(|name| name.to_string_lossy().to_string());
39
40 let workspace_json_path = workspace_dir.join("workspace.json");
41 if !workspace_json_path.exists() {
42 return Self {
43 workspace_id,
44 ..Self::default()
45 };
46 }
47
48 let folder_uri = workspace::read_workspace_target_uri(workspace_dir)
49 .ok()
50 .flatten();
51
52 let folder_uri_normalized = folder_uri.as_deref().map(normalize_uri_for_comparison);
53 let workspace_path_normalized = folder_uri.as_deref().and_then(extract_workspace_path);
54 let remote_authority = folder_uri.as_deref().and_then(extract_uri_authority);
55 let is_remote = folder_uri
56 .as_deref()
57 .and_then(uri_scheme)
58 .is_some_and(|scheme| scheme == "vscode-remote");
59
60 Self {
61 workspace_id,
62 folder_uri_normalized,
63 workspace_path_normalized,
64 remote_authority,
65 is_remote,
66 }
67 }
68}
69
70pub fn discover_workspace_sessions(
72 workspace_dir: &Path,
73 include_archived: bool,
74) -> Result<Vec<SessionMetadata>> {
75 let identity = WorkspaceIdentity::from_workspace_dir(workspace_dir);
76
77 let mut global_open_error = None;
78 let global_conn = match open_global_state_db() {
79 Ok(conn) => conn,
80 Err(err) => {
81 global_open_error = Some(err);
82 None
83 }
84 };
85
86 let mut sessions = Vec::new();
87 let mut local_registry_checked = false;
88 let mut local_registry_present = false;
89
90 if let Some(conn) = global_conn.as_ref() {
91 match load_global_registry_sessions(conn, &identity, include_archived) {
92 Ok(discovered) => sessions.extend(discovered),
93 Err(err) => {
94 global_open_error = Some(err);
95 }
96 }
97 }
98
99 let local_conn = match open_workspace_state_db(workspace_dir) {
100 Ok(conn) => conn,
101 Err(err) => {
102 if sessions.is_empty() {
103 if let Some(global_err) = global_open_error {
104 return Err(global_err).context(err.to_string());
105 }
106 return Err(err);
107 }
108 None
109 }
110 };
111 if let Some(conn) = local_conn.as_ref() {
112 local_registry_checked = true;
113 match load_legacy_local_sessions(conn, include_archived) {
114 Ok(discovered) => {
115 local_registry_present = local_registry_shape_present(conn).unwrap_or(false);
116 sessions.extend(discovered);
117 }
118 Err(err) => {
119 if sessions.is_empty() {
120 if let Some(global_err) = global_open_error {
121 return Err(global_err).context(err.to_string());
122 }
123 return Err(err);
124 }
125 }
126 }
127 }
128
129 if sessions.is_empty() {
130 if local_registry_checked && local_registry_present {
131 return Ok(vec![]);
132 }
133
134 if let Some(err) = global_open_error {
135 return Err(err);
136 }
137
138 return Ok(vec![]);
139 }
140
141 dedupe_sessions(&mut sessions);
142
143 exclude_child_sessions_from_sources(global_conn.as_ref(), local_conn.as_ref(), &mut sessions);
144
145 sort_sessions(&mut sessions);
146
147 Ok(sessions)
148}
149
150pub fn count_workspace_sessions(workspace_dir: &Path, include_archived: bool) -> Result<usize> {
152 Ok(discover_workspace_sessions(workspace_dir, include_archived)?.len())
153}
154
155pub fn count_workspace_sessions_if_available(
158 workspace_dir: &Path,
159 include_archived: bool,
160) -> Result<Option<usize>> {
161 match count_workspace_sessions(workspace_dir, include_archived) {
162 Ok(count) => Ok(Some(count)),
163 Err(err) if local_session_registry_shape_known(workspace_dir)? => Err(err),
164 Err(_) => Ok(None),
165 }
166}
167
168pub fn open_global_state_db() -> Result<Option<Connection>> {
170 let Some(db_path) = global_state_db_path()? else {
171 return Ok(None);
172 };
173
174 Ok(Some(open_read_only_db(&db_path)?))
175}
176
177fn global_state_db_path() -> Result<Option<PathBuf>> {
178 let db_path = crate::config::global_storage_dir()?.join("state.vscdb");
179 Ok(db_path.exists().then_some(db_path))
180}
181
182fn open_workspace_state_db(workspace_dir: &Path) -> Result<Option<Connection>> {
183 let db_path = workspace_dir.join("state.vscdb");
184 if !db_path.exists() {
185 return Ok(None);
186 }
187
188 Ok(Some(open_read_only_db(&db_path)?))
189}
190
191fn local_session_registry_shape_known(workspace_dir: &Path) -> Result<bool> {
192 let Some(conn) = open_workspace_state_db(workspace_dir)? else {
193 return Ok(false);
194 };
195
196 local_registry_shape_present(&conn)
197}
198
199fn local_registry_shape_present(conn: &Connection) -> Result<bool> {
200 let Some(data) = query_item_table_value(conn, LOCAL_COMPOSER_DATA_KEY)? else {
201 return Ok(false);
202 };
203
204 let json: Value =
205 serde_json::from_str(&data).context("Failed to parse workspace composer data")?;
206 Ok(json
207 .get("allComposers")
208 .and_then(|value| value.as_array())
209 .is_some())
210}
211
212fn open_read_only_db(db_path: &Path) -> Result<Connection> {
213 Connection::open_with_flags(
214 db_path,
215 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
216 )
217 .with_context(|| format!("Failed to open database: {}", db_path.display()))
218}
219
220fn load_global_registry_sessions(
221 conn: &Connection,
222 identity: &WorkspaceIdentity,
223 include_archived: bool,
224) -> Result<Vec<SessionMetadata>> {
225 let Some(data) = query_item_table_value(conn, GLOBAL_HEADERS_KEY)? else {
226 return Ok(vec![]);
227 };
228
229 parse_global_registry(&data, identity, include_archived)
230}
231
232fn load_legacy_local_sessions(
233 conn: &Connection,
234 include_archived: bool,
235) -> Result<Vec<SessionMetadata>> {
236 let Some(data) = query_item_table_value(conn, LOCAL_COMPOSER_DATA_KEY)? else {
237 return Ok(vec![]);
238 };
239
240 parse_local_registry(&data, include_archived)
241}
242
243fn query_item_table_value(conn: &Connection, key: &str) -> Result<Option<String>> {
244 query_optional_utf8_string_like_value(
245 conn,
246 "SELECT value FROM ItemTable WHERE key = ?1",
247 key,
248 "value",
249 )
250 .with_context(|| format!("Failed to query ItemTable for key: {}", key))
251}
252
253fn query_cursor_disk_value(conn: &Connection, key: &str) -> Result<Option<String>> {
254 query_optional_utf8_string_like_value(
255 conn,
256 "SELECT value FROM cursorDiskKV WHERE key = ?1",
257 key,
258 "value",
259 )
260 .with_context(|| format!("Failed to query cursorDiskKV for key: {}", key))
261}
262
263fn parse_global_registry(
264 data: &str,
265 identity: &WorkspaceIdentity,
266 include_archived: bool,
267) -> Result<Vec<SessionMetadata>> {
268 let json: Value =
269 serde_json::from_str(data).context("Failed to parse global composer headers")?;
270 let Some(composers) = json.get("allComposers").and_then(|value| value.as_array()) else {
271 return Ok(vec![]);
272 };
273
274 Ok(composers
275 .iter()
276 .filter(|value| session_matches_workspace(value, identity))
277 .filter_map(|value| parse_session_metadata(value, include_archived))
278 .collect())
279}
280
281fn parse_local_registry(data: &str, include_archived: bool) -> Result<Vec<SessionMetadata>> {
282 let json: Value =
283 serde_json::from_str(data).context("Failed to parse workspace composer data")?;
284 let Some(composers) = json.get("allComposers").and_then(|value| value.as_array()) else {
285 return Ok(vec![]);
286 };
287
288 Ok(composers
289 .iter()
290 .filter_map(|value| parse_session_metadata(value, include_archived))
291 .collect())
292}
293
294fn parse_session_metadata(value: &Value, include_archived: bool) -> Option<SessionMetadata> {
295 let is_archived = value
296 .get("isArchived")
297 .and_then(|flag| flag.as_bool())
298 .unwrap_or(false);
299 if is_archived && !include_archived {
300 return None;
301 }
302
303 let composer_id = value.get("composerId").and_then(|v| v.as_str())?;
304 let created_at_ms = value.get("createdAt").and_then(|v| v.as_i64());
305 let updated_at_ms = value
306 .get("lastUpdatedAt")
307 .and_then(|v| v.as_i64())
308 .or(created_at_ms);
309 let title = value
310 .get("name")
311 .and_then(|v| v.as_str())
312 .map(str::trim)
313 .filter(|name| !name.is_empty())
314 .map(|name| name.to_string());
315
316 Some(SessionMetadata {
317 composer_id: composer_id.to_string(),
318 title,
319 created_at_ms,
320 updated_at_ms,
321 })
322}
323
324fn session_matches_workspace(value: &Value, identity: &WorkspaceIdentity) -> bool {
325 let actual_id = value
326 .pointer("/workspaceIdentifier/id")
327 .and_then(|v| v.as_str());
328 if let (Some(expected_id), Some(actual_id)) = (identity.workspace_id.as_deref(), actual_id)
329 && expected_id == actual_id
330 {
331 return true;
332 }
333
334 let actual_uri = value
335 .pointer("/workspaceIdentifier/uri/external")
336 .and_then(|v| v.as_str());
337 if let (Some(expected_uri), Some(actual_uri)) =
338 (identity.folder_uri_normalized.as_deref(), actual_uri)
339 && expected_uri == normalize_uri_for_comparison(actual_uri)
340 {
341 return true;
342 }
343
344 if identity.is_remote {
345 if let (
346 Some(expected_authority),
347 Some(expected_path),
348 Some(actual_authority),
349 Some(actual_path),
350 ) = (
351 identity.remote_authority.as_deref(),
352 identity.workspace_path_normalized.as_deref(),
353 value
354 .pointer("/workspaceIdentifier/uri/authority")
355 .and_then(|v| v.as_str()),
356 value
357 .pointer("/workspaceIdentifier/uri/path")
358 .and_then(|v| v.as_str()),
359 ) && expected_authority == actual_authority
360 && expected_path == normalize_workspace_path(actual_path)
361 {
362 return true;
363 }
364
365 return false;
366 }
367
368 if let (Some(expected_path), Some(actual_path)) = (
369 identity.workspace_path_normalized.as_deref(),
370 value
371 .pointer("/workspaceIdentifier/uri/path")
372 .and_then(|v| v.as_str()),
373 ) && expected_path == normalize_workspace_path(actual_path)
374 {
375 return true;
376 }
377
378 false
379}
380
381fn dedupe_sessions(sessions: &mut Vec<SessionMetadata>) {
382 let mut deduped = HashMap::<String, SessionMetadata>::new();
383
384 for session in sessions.drain(..) {
385 deduped
386 .entry(session.composer_id.clone())
387 .and_modify(|existing| merge_session_metadata(existing, &session))
388 .or_insert(session);
389 }
390
391 sessions.extend(deduped.into_values());
392}
393
394fn merge_session_metadata(existing: &mut SessionMetadata, incoming: &SessionMetadata) {
395 if existing.title.is_none() {
396 existing.title = incoming.title.clone();
397 }
398
399 existing.created_at_ms = match (existing.created_at_ms, incoming.created_at_ms) {
400 (Some(lhs), Some(rhs)) => Some(lhs.min(rhs)),
401 (None, Some(rhs)) => Some(rhs),
402 (value, None) => value,
403 };
404
405 existing.updated_at_ms = match (existing.updated_at_ms, incoming.updated_at_ms) {
406 (Some(lhs), Some(rhs)) => Some(lhs.max(rhs)),
407 (None, Some(rhs)) => Some(rhs),
408 (value, None) => value,
409 };
410}
411
412fn exclude_child_sessions_from_sources(
413 global_conn: Option<&Connection>,
414 local_conn: Option<&Connection>,
415 sessions: &mut Vec<SessionMetadata>,
416) {
417 if sessions.len() <= 1 {
418 return;
419 }
420
421 let mut child_ids = HashSet::new();
422 if let Some(conn) = global_conn {
423 collect_child_ids_for_sessions(conn, sessions, &mut child_ids);
424 }
425 if let Some(conn) = local_conn {
426 collect_child_ids_for_sessions(conn, sessions, &mut child_ids);
427 }
428
429 if child_ids.is_empty() {
430 return;
431 }
432
433 sessions.retain(|session| !child_ids.contains(&session.composer_id));
434}
435
436fn collect_child_ids_for_sessions(
437 conn: &Connection,
438 sessions: &[SessionMetadata],
439 child_ids: &mut HashSet<String>,
440) {
441 let session_ids: HashSet<String> = sessions
442 .iter()
443 .map(|session| session.composer_id.clone())
444 .collect();
445
446 for session_id in &session_ids {
447 let composer_key = format!("composerData:{}", session_id);
448 let Some(data) = query_cursor_disk_value(conn, &composer_key).ok().flatten() else {
449 continue;
450 };
451 let Ok(json) = serde_json::from_str::<Value>(&data) else {
452 continue;
453 };
454
455 collect_child_ids(json.get("subComposerIds"), &session_ids, child_ids);
456 collect_child_ids(json.get("subagentComposerIds"), &session_ids, child_ids);
457 }
458}
459
460fn collect_child_ids(
461 value: Option<&Value>,
462 known_sessions: &HashSet<String>,
463 child_ids: &mut HashSet<String>,
464) {
465 let Some(entries) = value.and_then(|v| v.as_array()) else {
466 return;
467 };
468
469 for entry in entries {
470 let Some(child_id) = entry.as_str() else {
471 continue;
472 };
473 if known_sessions.contains(child_id) {
474 child_ids.insert(child_id.to_string());
475 }
476 }
477}
478
479fn sort_sessions(sessions: &mut [SessionMetadata]) {
480 sessions.sort_by(|a, b| {
481 b.updated_at_ms
482 .cmp(&a.updated_at_ms)
483 .then_with(|| b.created_at_ms.cmp(&a.created_at_ms))
484 .then_with(|| a.composer_id.cmp(&b.composer_id))
485 });
486}
487
488fn extract_workspace_path(uri: &str) -> Option<String> {
489 split_uri(uri).map(|(_, _, path)| normalize_workspace_path(&path))
490}
491
492fn uri_scheme(uri: &str) -> Option<String> {
493 split_uri(uri).map(|(scheme, _, _)| scheme.to_string())
494}
495
496fn normalize_workspace_path(path: &str) -> String {
497 let trimmed = path
498 .trim_end_matches('/')
499 .replace("%3A", ":")
500 .replace("%3a", ":");
501 let decoded = percent_decode_str(&trimmed).decode_utf8_lossy();
502 normalize_drive_letter(&decoded)
503}
504
505fn normalize_uri_for_comparison(uri: &str) -> String {
506 let trimmed = uri.trim_end_matches('/');
507 let Some((scheme, authority, path)) = split_uri(trimmed) else {
508 return trimmed.replace("%3A", ":").replace("%3a", ":");
509 };
510
511 let path = normalize_workspace_path(&path);
512
513 if authority.is_empty() {
514 format!("{}://{}", scheme.to_ascii_lowercase(), path)
515 } else {
516 format!("{}://{}{}", scheme.to_ascii_lowercase(), authority, path)
517 }
518}
519
520fn extract_uri_authority(uri: &str) -> Option<String> {
521 let (_, authority, _) = split_uri(uri)?;
522 (!authority.is_empty()).then_some(authority)
523}
524
525fn split_uri(uri: &str) -> Option<(&str, String, String)> {
526 let (scheme, rest) = uri.split_once("://")?;
527 let (authority, path) = match rest.find('/') {
528 Some(index) => (&rest[..index], &rest[index..]),
529 None => (rest, ""),
530 };
531
532 Some((scheme, authority.to_string(), path.to_string()))
533}
534
535fn normalize_drive_letter(path: &str) -> String {
536 let mut chars: Vec<char> = path.chars().collect();
537
538 let drive_index = match chars.as_slice() {
539 ['/', drive, ':', '/', ..] if drive.is_ascii_alphabetic() => Some(1),
540 [drive, ':', '/', ..] if drive.is_ascii_alphabetic() => Some(0),
541 _ => None,
542 };
543
544 if let Some(index) = drive_index {
545 chars[index] = chars[index].to_ascii_lowercase();
546 chars.into_iter().collect()
547 } else {
548 path.to_string()
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn init_test_db() -> Connection {
557 let conn = Connection::open_in_memory().unwrap();
558 conn.execute(
559 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
560 [],
561 )
562 .unwrap();
563 conn.execute(
564 "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
565 [],
566 )
567 .unwrap();
568 conn
569 }
570
571 fn insert_item(conn: &Connection, key: &str, value: &str) {
572 conn.execute(
573 "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
574 rusqlite::params![key, value],
575 )
576 .unwrap();
577 }
578
579 fn insert_disk_value(conn: &Connection, key: &str, value: &str) {
580 conn.execute(
581 "INSERT INTO cursorDiskKV (key, value) VALUES (?1, ?2)",
582 rusqlite::params![key, value],
583 )
584 .unwrap();
585 }
586
587 #[test]
588 fn parse_global_registry_matches_local_workspace_by_id() {
589 let headers = r#"{
590 "allComposers": [
591 {
592 "composerId": "session-a",
593 "name": "Main",
594 "createdAt": 1000,
595 "lastUpdatedAt": 2000,
596 "isArchived": false,
597 "workspaceIdentifier": {
598 "id": "workspace-1",
599 "uri": {
600 "external": "file:///tmp/Project"
601 }
602 }
603 },
604 {
605 "composerId": "session-b",
606 "name": "Other",
607 "createdAt": 1000,
608 "lastUpdatedAt": 2000,
609 "isArchived": false,
610 "workspaceIdentifier": {
611 "id": "workspace-2",
612 "uri": {
613 "external": "file:///tmp/Other"
614 }
615 }
616 }
617 ]
618 }"#;
619
620 let identity = WorkspaceIdentity {
621 workspace_id: Some("workspace-1".to_string()),
622 folder_uri_normalized: Some(normalize_uri_for_comparison("file:///tmp/Project")),
623 workspace_path_normalized: Some(normalize_workspace_path("/tmp/Project")),
624 remote_authority: None,
625 is_remote: false,
626 };
627
628 let sessions = parse_global_registry(headers, &identity, false).unwrap();
629 assert_eq!(sessions.len(), 1);
630 assert_eq!(sessions[0].composer_id, "session-a");
631 }
632
633 #[test]
634 fn remote_workspace_requires_matching_authority_and_path() {
635 let identity = WorkspaceIdentity {
636 workspace_id: Some("workspace-remote".to_string()),
637 folder_uri_normalized: Some(normalize_uri_for_comparison(
638 "vscode-remote://ssh-remote%2Bhost-a/home/user/project",
639 )),
640 workspace_path_normalized: Some(normalize_workspace_path("/home/user/project")),
641 remote_authority: Some("ssh-remote%2Bhost-a".to_string()),
642 is_remote: true,
643 };
644
645 let matching = serde_json::json!({
646 "workspaceIdentifier": {
647 "uri": {
648 "authority": "ssh-remote%2Bhost-a",
649 "path": "/home/user/project"
650 }
651 }
652 });
653 let wrong_host = serde_json::json!({
654 "workspaceIdentifier": {
655 "uri": {
656 "authority": "ssh-remote%2Bhost-b",
657 "path": "/home/user/project"
658 }
659 }
660 });
661
662 assert!(session_matches_workspace(&matching, &identity));
663 assert!(!session_matches_workspace(&wrong_host, &identity));
664 }
665
666 #[test]
667 fn dedupes_global_and_local_sessions_and_prefers_richer_metadata() {
668 let mut sessions = vec![
669 SessionMetadata {
670 composer_id: "session-a".to_string(),
671 title: None,
672 created_at_ms: Some(2000),
673 updated_at_ms: Some(3000),
674 },
675 SessionMetadata {
676 composer_id: "session-a".to_string(),
677 title: Some("Recovered title".to_string()),
678 created_at_ms: Some(1000),
679 updated_at_ms: Some(4000),
680 },
681 ];
682
683 dedupe_sessions(&mut sessions);
684
685 assert_eq!(sessions.len(), 1);
686 assert_eq!(sessions[0].title.as_deref(), Some("Recovered title"));
687 assert_eq!(sessions[0].created_at_ms, Some(1000));
688 assert_eq!(sessions[0].updated_at_ms, Some(4000));
689 }
690
691 #[test]
692 fn exclude_child_sessions_works_with_local_cursor_disk_kv() {
693 let conn = init_test_db();
694 insert_disk_value(
695 &conn,
696 "composerData:parent",
697 r#"{"subagentComposerIds":["child"]}"#,
698 );
699
700 let mut sessions = vec![
701 SessionMetadata {
702 composer_id: "parent".to_string(),
703 title: Some("Parent".to_string()),
704 created_at_ms: Some(1000),
705 updated_at_ms: Some(2000),
706 },
707 SessionMetadata {
708 composer_id: "child".to_string(),
709 title: Some("Child".to_string()),
710 created_at_ms: Some(1000),
711 updated_at_ms: Some(2000),
712 },
713 ];
714
715 exclude_child_sessions_from_sources(None, Some(&conn), &mut sessions);
716
717 assert_eq!(sessions.len(), 1);
718 assert_eq!(sessions[0].composer_id, "parent");
719 }
720
721 #[test]
722 fn local_registry_ignores_migrated_ui_state_without_all_composers() {
723 let conn = init_test_db();
724 insert_item(
725 &conn,
726 LOCAL_COMPOSER_DATA_KEY,
727 r#"{"selectedComposerIds":["session-a"],"hasMigratedComposerData":true}"#,
728 );
729
730 let sessions = load_legacy_local_sessions(&conn, false).unwrap();
731 assert!(sessions.is_empty());
732 }
733
734 #[test]
735 fn normalize_uri_preserves_case_for_posix_paths() {
736 assert_eq!(
737 normalize_uri_for_comparison("file:///tmp/Project"),
738 "file:///tmp/Project"
739 );
740 assert_ne!(
741 normalize_uri_for_comparison("file:///tmp/Project"),
742 normalize_uri_for_comparison("file:///tmp/project")
743 );
744 }
745
746 #[test]
747 fn normalize_uri_lowercases_only_windows_drive_letter() {
748 assert_eq!(
749 normalize_uri_for_comparison("file:///C%3A/Users/me/Project"),
750 "file:///c:/Users/me/Project"
751 );
752 }
753}