Implement global settings file (#30444)

Tristan Hume and Marshall Bowers created

Adds a `global_settings.json` file which can be set up by enterprises
with automation, enabling setting settings like edit provider by default
without interfering with user's settings files.

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

crates/paths/src/paths.rs             |   6 +
crates/settings/src/settings_store.rs | 119 ++++++++++++++++++++++++++++
crates/zed/src/main.rs                |  12 ++
crates/zed/src/zed.rs                 | 113 ++++++++++++++++++---------
4 files changed, 210 insertions(+), 40 deletions(-)

Detailed changes

crates/paths/src/paths.rs 🔗

@@ -191,6 +191,12 @@ pub fn settings_file() -> &'static PathBuf {
     SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
 }
 
+/// Returns the path to the global settings file.
+pub fn global_settings_file() -> &'static PathBuf {
+    static GLOBAL_SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
+    GLOBAL_SETTINGS_FILE.get_or_init(|| config_dir().join("global_settings.json"))
+}
+
 /// Returns the path to the `settings_backup.json` file.
 pub fn settings_backup_file() -> &'static PathBuf {
     static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();

crates/settings/src/settings_store.rs 🔗

@@ -120,6 +120,8 @@ pub trait Settings: 'static + Send + Sync {
 pub struct SettingsSources<'a, T> {
     /// The default Zed settings.
     pub default: &'a T,
+    /// Global settings (loaded before user settings).
+    pub global: Option<&'a T>,
     /// Settings provided by extensions.
     pub extensions: Option<&'a T>,
     /// The user settings.
@@ -140,8 +142,9 @@ impl<'a, T: Serialize> SettingsSources<'a, T> {
 
     /// Returns an iterator over all of the settings customizations.
     pub fn customizations(&self) -> impl Iterator<Item = &T> {
-        self.extensions
+        self.global
             .into_iter()
+            .chain(self.extensions)
             .chain(self.user)
             .chain(self.release_channel)
             .chain(self.server)
@@ -180,6 +183,7 @@ pub struct SettingsLocation<'a> {
 pub struct SettingsStore {
     setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
     raw_default_settings: Value,
+    raw_global_settings: Option<Value>,
     raw_user_settings: Value,
     raw_server_settings: Option<Value>,
     raw_extension_settings: Value,
@@ -272,6 +276,7 @@ impl SettingsStore {
         Self {
             setting_values: Default::default(),
             raw_default_settings: serde_json::json!({}),
+            raw_global_settings: None,
             raw_user_settings: serde_json::json!({}),
             raw_server_settings: None,
             raw_extension_settings: serde_json::json!({}),
@@ -341,6 +346,7 @@ impl SettingsStore {
                 .load_setting(
                     SettingsSources {
                         default: &default_settings,
+                        global: None,
                         extensions: extension_value.as_ref(),
                         user: user_value.as_ref(),
                         release_channel: release_channel_value.as_ref(),
@@ -388,6 +394,11 @@ impl SettingsStore {
         &self.raw_user_settings
     }
 
+    /// Access the raw JSON value of the global settings.
+    pub fn raw_global_settings(&self) -> Option<&Value> {
+        self.raw_global_settings.as_ref()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut App) -> Self {
         let mut this = Self::new(cx);
@@ -426,6 +437,20 @@ impl SettingsStore {
         }
     }
 
+    pub async fn load_global_settings(fs: &Arc<dyn Fs>) -> Result<String> {
+        match fs.load(paths::global_settings_file()).await {
+            result @ Ok(_) => result,
+            Err(err) => {
+                if let Some(e) = err.downcast_ref::<std::io::Error>() {
+                    if e.kind() == std::io::ErrorKind::NotFound {
+                        return Ok("{}".to_string());
+                    }
+                }
+                Err(err)
+            }
+        }
+    }
+
     pub fn update_settings_file<T: Settings>(
         &self,
         fs: Arc<dyn Fs>,
@@ -637,6 +662,24 @@ impl SettingsStore {
         Ok(settings)
     }
 
+    /// Sets the global settings via a JSON string.
+    pub fn set_global_settings(
+        &mut self,
+        global_settings_content: &str,
+        cx: &mut App,
+    ) -> Result<Value> {
+        let settings: Value = if global_settings_content.is_empty() {
+            parse_json_with_comments("{}")?
+        } else {
+            parse_json_with_comments(global_settings_content)?
+        };
+
+        anyhow::ensure!(settings.is_object(), "settings must be an object");
+        self.raw_global_settings = Some(settings.clone());
+        self.recompute_values(None, cx)?;
+        Ok(settings)
+    }
+
     pub fn set_server_settings(
         &mut self,
         server_settings_content: &str,
@@ -935,6 +978,11 @@ impl SettingsStore {
                     message: e.to_string(),
                 })?;
 
+            let global_settings = self
+                .raw_global_settings
+                .as_ref()
+                .and_then(|setting| setting_value.deserialize_setting(setting).log_err());
+
             let extension_settings = setting_value
                 .deserialize_setting(&self.raw_extension_settings)
                 .log_err();
@@ -972,6 +1020,7 @@ impl SettingsStore {
                     .load_setting(
                         SettingsSources {
                             default: &default_settings,
+                            global: global_settings.as_ref(),
                             extensions: extension_settings.as_ref(),
                             user: user_settings.as_ref(),
                             release_channel: release_channel_settings.as_ref(),
@@ -1023,6 +1072,7 @@ impl SettingsStore {
                             .load_setting(
                                 SettingsSources {
                                     default: &default_settings,
+                                    global: global_settings.as_ref(),
                                     extensions: extension_settings.as_ref(),
                                     user: user_settings.as_ref(),
                                     release_channel: release_channel_settings.as_ref(),
@@ -1139,6 +1189,9 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
         Ok(Box::new(T::load(
             SettingsSources {
                 default: values.default.0.downcast_ref::<T::FileContent>().unwrap(),
+                global: values
+                    .global
+                    .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
                 extensions: values
                     .extensions
                     .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
@@ -2072,6 +2125,70 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    fn test_global_settings(cx: &mut App) {
+        let mut store = SettingsStore::new(cx);
+        store.register_setting::<UserSettings>(cx);
+        store
+            .set_default_settings(
+                r#"{
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // Set global settings - these should override defaults but not user settings
+        store
+            .set_global_settings(
+                r#"{
+                    "user": {
+                        "name": "Global User",
+                        "age": 35,
+                        "staff": true
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // Before user settings, global settings should apply
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "Global User".to_string(),
+                age: 35,
+                staff: true,
+            }
+        );
+
+        // Set user settings - these should override both defaults and global
+        store
+            .set_user_settings(
+                r#"{
+                    "user": {
+                        "age": 40
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // User settings should override global settings
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "Global User".to_string(), // Name from global settings
+                age: 40,                         // Age from user settings
+                staff: true,                     // Staff from global settings
+            }
+        );
+    }
+
     #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
     struct LanguageSettings {
         #[serde(default)]

crates/zed/src/main.rs 🔗

@@ -294,6 +294,11 @@ fn main() {
         fs.clone(),
         paths::settings_file().clone(),
     );
+    let global_settings_file_rx = watch_config_file(
+        &app.background_executor(),
+        fs.clone(),
+        paths::global_settings_file().clone(),
+    );
     let user_keymap_file_rx = watch_config_file(
         &app.background_executor(),
         fs.clone(),
@@ -340,7 +345,12 @@ fn main() {
         }
         settings::init(cx);
         zlog_settings::init(cx);
-        handle_settings_file_changes(user_settings_file_rx, cx, handle_settings_changed);
+        handle_settings_file_changes(
+            user_settings_file_rx,
+            global_settings_file_rx,
+            cx,
+            handle_settings_changed,
+        );
         handle_keymap_file_changes(user_keymap_file_rx, cx);
         client::init_settings(cx);
         let user_agent = format!(

crates/zed/src/zed.rs 🔗

@@ -21,6 +21,7 @@ use debugger_ui::debugger_panel::DebugPanel;
 use editor::ProposedChangesEditorToolbar;
 use editor::{Editor, MultiBuffer, scroll::Autoscroll};
 use feature_flags::{DebuggerFeatureFlag, FeatureFlagViewExt};
+use futures::future::Either;
 use futures::{StreamExt, channel::mpsc, select_biased};
 use git_ui::git_panel::GitPanel;
 use git_ui::project_diff::ProjectDiffToolbar;
@@ -1089,58 +1090,84 @@ fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Contex
 
 pub fn handle_settings_file_changes(
     mut user_settings_file_rx: mpsc::UnboundedReceiver<String>,
+    mut global_settings_file_rx: mpsc::UnboundedReceiver<String>,
     cx: &mut App,
     settings_changed: impl Fn(Option<anyhow::Error>, &mut App) + 'static,
 ) {
     MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx);
-    let content = cx
+
+    // Helper function to process settings content
+    let process_settings =
+        move |content: String, is_user: bool, store: &mut SettingsStore, cx: &mut App| -> bool {
+            // Apply migrations to both user and global settings
+            let (processed_content, content_migrated) =
+                if let Ok(Some(migrated_content)) = migrate_settings(&content) {
+                    (migrated_content, true)
+                } else {
+                    (content, false)
+                };
+
+            let result = if is_user {
+                store.set_user_settings(&processed_content, cx)
+            } else {
+                store.set_global_settings(&processed_content, cx)
+            };
+
+            if let Err(err) = &result {
+                let settings_type = if is_user { "user" } else { "global" };
+                log::error!("Failed to load {} settings: {err}", settings_type);
+            }
+
+            settings_changed(result.err(), cx);
+
+            content_migrated
+        };
+
+    // Initial load of both settings files
+    let global_content = cx
+        .background_executor()
+        .block(global_settings_file_rx.next())
+        .unwrap();
+    let user_content = cx
         .background_executor()
         .block(user_settings_file_rx.next())
         .unwrap();
-    let user_settings_content = if let Ok(Some(migrated_content)) = migrate_settings(&content) {
-        migrated_content
-    } else {
-        content
-    };
+
     SettingsStore::update_global(cx, |store, cx| {
-        let result = store.set_user_settings(&user_settings_content, cx);
-        if let Err(err) = &result {
-            log::error!("Failed to load user settings: {err}");
-        }
-        settings_changed(result.err(), cx);
+        process_settings(global_content, false, store, cx);
+        process_settings(user_content, true, store, cx);
     });
+
+    // Watch for changes in both files
     cx.spawn(async move |cx| {
-        while let Some(content) = user_settings_file_rx.next().await {
-            let user_settings_content;
-            let content_migrated;
+        let mut settings_streams = futures::stream::select(
+            global_settings_file_rx.map(Either::Left),
+            user_settings_file_rx.map(Either::Right),
+        );
 
-            if let Ok(Some(migrated_content)) = migrate_settings(&content) {
-                user_settings_content = migrated_content;
-                content_migrated = true;
-            } else {
-                user_settings_content = content;
-                content_migrated = false;
-            }
+        while let Some(content) = settings_streams.next().await {
+            let (content, is_user) = match content {
+                Either::Left(content) => (content, false),
+                Either::Right(content) => (content, true),
+            };
 
-            cx.update(|cx| {
-                if let Some(notifier) = MigrationNotification::try_global(cx) {
-                    notifier.update(cx, |_, cx| {
-                        cx.emit(MigrationEvent::ContentChanged {
-                            migration_type: MigrationType::Settings,
-                            migrated: content_migrated,
-                        });
-                    });
-                }
-            })
-            .ok();
             let result = cx.update_global(|store: &mut SettingsStore, cx| {
-                let result = store.set_user_settings(&user_settings_content, cx);
-                if let Err(err) = &result {
-                    log::error!("Failed to load user settings: {err}");
+                let content_migrated = process_settings(content, is_user, store, cx);
+
+                if content_migrated {
+                    if let Some(notifier) = MigrationNotification::try_global(cx) {
+                        notifier.update(cx, |_, cx| {
+                            cx.emit(MigrationEvent::ContentChanged {
+                                migration_type: MigrationType::Settings,
+                                migrated: true,
+                            });
+                        });
+                    }
                 }
-                settings_changed(result.err(), cx);
+
                 cx.refresh_windows();
             });
+
             if result.is_err() {
                 break; // App dropped
             }
@@ -3888,7 +3915,12 @@ mod tests {
                 app_state.fs.clone(),
                 PathBuf::from("/keymap.json"),
             );
-            handle_settings_file_changes(settings_rx, cx, |_, _| {});
+            let global_settings_rx = watch_config_file(
+                &executor,
+                app_state.fs.clone(),
+                PathBuf::from("/global_settings.json"),
+            );
+            handle_settings_file_changes(settings_rx, global_settings_rx, cx, |_, _| {});
             handle_keymap_file_changes(keymap_rx, cx);
         });
         workspace
@@ -4002,7 +4034,12 @@ mod tests {
                 PathBuf::from("/keymap.json"),
             );
 
-            handle_settings_file_changes(settings_rx, cx, |_, _| {});
+            let global_settings_rx = watch_config_file(
+                &executor,
+                app_state.fs.clone(),
+                PathBuf::from("/global_settings.json"),
+            );
+            handle_settings_file_changes(settings_rx, global_settings_rx, cx, |_, _| {});
             handle_keymap_file_changes(keymap_rx, cx);
         });