Improve logic for finding VSCode / Cursor settings files (#32721)

Michael Sloan created

* Fixes a bug where for Cursor, `config_dir()` (Zed's config dir) was
being used instead of `dirs::config_dir` (`~/.config` /
`$XDG_CONFIG_HOME`).

* Adds support for windows, before it was using the user profile folder
+ `/.config` which is incorrect.

* Now looks using a variety of product names - `["Code", "Code - OSS",
"Code Dev", "Code - OSS Dev", "code-oss-dev", "VSCodium"]`.

* Now shows settings path that was read before confirming import.

Including this path in the confirmation modal is a bit ugly (making it
link-styled and clickable would be nice), but I think it's better to
include it now that it is selecting the first match of a list of
candidate paths:


![image](https://github.com/user-attachments/assets/ceada4c2-96a6-4a84-a188-a1d93521ab26)

Release Notes:

- Added more settings file locations to check for VS Code / Cursor
settings import.

Change summary

Cargo.lock                            |  2 
crates/paths/src/paths.rs             | 88 +++++++++++++++++++++-------
crates/settings/src/vscode_import.rs  | 49 +++++++++++++--
crates/settings_ui/Cargo.toml         |  8 +-
crates/settings_ui/src/settings_ui.rs | 48 ++++++++-------
5 files changed, 137 insertions(+), 58 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14605,12 +14605,12 @@ dependencies = [
  "fs",
  "gpui",
  "log",
- "paths",
  "schemars",
  "serde",
  "settings",
  "theme",
  "ui",
+ "util",
  "workspace",
  "workspace-hack",
 ]

crates/paths/src/paths.rs 🔗

@@ -1,5 +1,6 @@
 //! Paths to locations used by Zed.
 
+use std::env;
 use std::path::{Path, PathBuf};
 use std::sync::OnceLock;
 
@@ -106,6 +107,7 @@ pub fn data_dir() -> &'static PathBuf {
         }
     })
 }
+
 /// Returns the path to the temp directory used by Zed.
 pub fn temp_dir() -> &'static PathBuf {
     static TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -426,32 +428,74 @@ pub fn global_ssh_config_file() -> &'static Path {
     Path::new("/etc/ssh/ssh_config")
 }
 
-/// Returns the path to the vscode user settings file
-pub fn vscode_settings_file() -> &'static PathBuf {
-    static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
-    let rel_path = "Code/User/settings.json";
-    LOGS_DIR.get_or_init(|| {
-        if cfg!(target_os = "macos") {
-            home_dir()
-                .join("Library/Application Support")
-                .join(rel_path)
-        } else {
-            home_dir().join(".config").join(rel_path)
+/// Returns candidate paths for the vscode user settings file
+pub fn vscode_settings_file_paths() -> Vec<PathBuf> {
+    let mut paths = vscode_user_data_paths();
+    for path in paths.iter_mut() {
+        path.push("User/settings.json");
+    }
+    paths
+}
+
+/// Returns candidate paths for the cursor user settings file
+pub fn cursor_settings_file_paths() -> Vec<PathBuf> {
+    let mut paths = cursor_user_data_paths();
+    for path in paths.iter_mut() {
+        path.push("User/settings.json");
+    }
+    paths
+}
+
+fn vscode_user_data_paths() -> Vec<PathBuf> {
+    // https://github.com/microsoft/vscode/blob/23e7148cdb6d8a27f0109ff77e5b1e019f8da051/src/vs/platform/environment/node/userDataPath.ts#L45
+    const VSCODE_PRODUCT_NAMES: &[&str] = &[
+        "Code",
+        "Code - OSS",
+        "VSCodium",
+        "Code Dev",
+        "Code - OSS Dev",
+        "code-oss-dev",
+    ];
+    let mut paths = Vec::new();
+    if let Ok(portable_path) = env::var("VSCODE_PORTABLE") {
+        paths.push(Path::new(&portable_path).join("user-data"));
+    }
+    if let Ok(vscode_appdata) = env::var("VSCODE_APPDATA") {
+        for product_name in VSCODE_PRODUCT_NAMES {
+            paths.push(Path::new(&vscode_appdata).join(product_name));
         }
-    })
+    }
+    for product_name in VSCODE_PRODUCT_NAMES {
+        add_vscode_user_data_paths(&mut paths, product_name);
+    }
+    paths
 }
 
-/// Returns the path to the cursor user settings file
-pub fn cursor_settings_file() -> &'static PathBuf {
-    static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
-    let rel_path = "Cursor/User/settings.json";
-    LOGS_DIR.get_or_init(|| {
-        if cfg!(target_os = "macos") {
+fn cursor_user_data_paths() -> Vec<PathBuf> {
+    let mut paths = Vec::new();
+    add_vscode_user_data_paths(&mut paths, "Cursor");
+    paths
+}
+
+fn add_vscode_user_data_paths(paths: &mut Vec<PathBuf>, product_name: &str) {
+    if cfg!(target_os = "macos") {
+        paths.push(
             home_dir()
                 .join("Library/Application Support")
-                .join(rel_path)
-        } else {
-            config_dir().join(rel_path)
+                .join(product_name),
+        );
+    } else if cfg!(target_os = "windows") {
+        if let Some(data_local_dir) = dirs::data_local_dir() {
+            paths.push(data_local_dir.join(product_name));
         }
-    })
+        if let Some(data_dir) = dirs::data_dir() {
+            paths.push(data_dir.join(product_name));
+        }
+    } else {
+        paths.push(
+            dirs::config_dir()
+                .unwrap_or(home_dir().join(".config"))
+                .join(product_name),
+        );
+    }
 }

crates/settings/src/vscode_import.rs 🔗

@@ -1,8 +1,8 @@
-use anyhow::Result;
+use anyhow::{Context as _, Result, anyhow};
 use fs::Fs;
+use paths::{cursor_settings_file_paths, vscode_settings_file_paths};
 use serde_json::{Map, Value};
-
-use std::sync::Arc;
+use std::{path::Path, rc::Rc, sync::Arc};
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 pub enum VsCodeSettingsSource {
@@ -21,26 +21,59 @@ impl std::fmt::Display for VsCodeSettingsSource {
 
 pub struct VsCodeSettings {
     pub source: VsCodeSettingsSource,
+    pub path: Rc<Path>,
     content: Map<String, Value>,
 }
 
 impl VsCodeSettings {
+    #[cfg(any(test, feature = "test-support"))]
     pub fn from_str(content: &str, source: VsCodeSettingsSource) -> Result<Self> {
         Ok(Self {
             source,
+            path: Path::new("/example-path/Code/User/settings.json").into(),
             content: serde_json_lenient::from_str(content)?,
         })
     }
 
     pub async fn load_user_settings(source: VsCodeSettingsSource, fs: Arc<dyn Fs>) -> Result<Self> {
-        let path = match source {
-            VsCodeSettingsSource::VsCode => paths::vscode_settings_file(),
-            VsCodeSettingsSource::Cursor => paths::cursor_settings_file(),
+        let candidate_paths = match source {
+            VsCodeSettingsSource::VsCode => vscode_settings_file_paths(),
+            VsCodeSettingsSource::Cursor => cursor_settings_file_paths(),
+        };
+        let mut path = None;
+        for candidate_path in candidate_paths.iter() {
+            if fs.is_file(candidate_path).await {
+                path = Some(candidate_path.clone());
+            }
+        }
+        let Some(path) = path else {
+            return Err(anyhow!(
+                "No settings file found, expected to find it in one of the following paths:\n{}",
+                candidate_paths
+                    .into_iter()
+                    .map(|path| path.to_string_lossy().to_string())
+                    .collect::<Vec<_>>()
+                    .join("\n")
+            ));
         };
-        let content = fs.load(path).await?;
+        let content = fs.load(&path).await.with_context(|| {
+            format!(
+                "Error loading {} settings file from {}",
+                source,
+                path.display()
+            )
+        })?;
+        let content = serde_json_lenient::from_str(&content).with_context(|| {
+            format!(
+                "Error parsing {} settings file from {}",
+                source,
+                path.display()
+            )
+        })?;
         Ok(Self {
             source,
-            content: serde_json_lenient::from_str(&content)?,
+            path: path.into(),
+            content,
         })
     }
 

crates/settings_ui/Cargo.toml 🔗

@@ -18,11 +18,11 @@ feature_flags.workspace = true
 fs.workspace = true
 gpui.workspace = true
 log.workspace = true
-paths.workspace = true
+schemars.workspace = true
+serde.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true
-workspace.workspace = true
+util.workspace = true
 workspace-hack.workspace = true
-serde.workspace = true
-schemars.workspace = true
+workspace.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -15,6 +15,7 @@ use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::{SettingsStore, VsCodeSettingsSource};
 use ui::prelude::*;
+use util::truncate_and_remove_front;
 use workspace::item::{Item, ItemEvent};
 use workspace::{Workspace, with_active_or_new_workspace};
 
@@ -129,33 +130,32 @@ async fn handle_import_vscode_settings(
     fs: Arc<dyn Fs>,
     cx: &mut AsyncWindowContext,
 ) {
-    let vscode = match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
-        Ok(vscode) => vscode,
-        Err(err) => {
-            println!(
-                "Failed to load {source} settings: {}",
-                err.context(format!(
-                    "Loading {source} settings from path: {:?}",
-                    paths::vscode_settings_file()
-                ))
-            );
-
-            let _ = cx.prompt(
-                gpui::PromptLevel::Info,
-                &format!("Could not find or load a {source} settings file"),
-                None,
-                &["Ok"],
-            );
-            return;
-        }
-    };
+    let vscode_settings =
+        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
+            Ok(vscode_settings) => vscode_settings,
+            Err(err) => {
+                log::error!("{err}");
+                let _ = cx.prompt(
+                    gpui::PromptLevel::Info,
+                    &format!("Could not find or load a {source} settings file"),
+                    None,
+                    &["Ok"],
+                );
+                return;
+            }
+        };
 
     let prompt = if skip_prompt {
         Task::ready(Some(0))
     } else {
         let prompt = cx.prompt(
             gpui::PromptLevel::Warning,
-            "Importing settings may overwrite your existing settings",
+            &format!(
+                "Importing {} settings may overwrite your existing settings. \
+                Will import settings from {}",
+                vscode_settings.source,
+                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
+            ),
             None,
             &["Ok", "Cancel"],
         );
@@ -166,9 +166,11 @@ async fn handle_import_vscode_settings(
     }
 
     cx.update(|_, cx| {
+        let source = vscode_settings.source;
+        let path = vscode_settings.path.clone();
         cx.global::<SettingsStore>()
-            .import_vscode_settings(fs, vscode);
-        log::info!("Imported settings from {source}");
+            .import_vscode_settings(fs, vscode_settings);
+        log::info!("Imported {source} settings from {}", path.display());
     })
     .ok();
 }