Skip to main content

cursor_helper/cursor/
chat_sessions.rs

1//! Shared chat session discovery across Cursor storage layouts.
2
3use 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/// Stable session metadata used by list, stats, and export.
17#[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
70/// Discover stable, top-level exportable sessions for a workspace.
71pub 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
150/// Count stable, top-level exportable sessions for a workspace.
151pub fn count_workspace_sessions(workspace_dir: &Path, include_archived: bool) -> Result<usize> {
152    Ok(discover_workspace_sessions(workspace_dir, include_archived)?.len())
153}
154
155/// Count sessions, but treat a missing or unreadable global registry as "unknown"
156/// when there is no matching local workspace session data to inspect.
157pub 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
168/// Open Cursor's global `state.vscdb` if it exists.
169pub 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}