Allow configuring custom git hosting providers in project settings (#31929)

Cole Miller and Anthony Eid created

Closes #29229

Release Notes:

- Extended the support for configuring custom git hosting providers to
cover project settings in addition to global settings.

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

crates/git/src/hosting_provider.rs           | 30 +++++++--
crates/git_hosting_providers/src/settings.rs | 40 ++++++++----
crates/project/src/project_tests.rs          | 66 ++++++++++++++++++++++
crates/settings/src/settings_store.rs        | 26 ++++++++
4 files changed, 140 insertions(+), 22 deletions(-)

Detailed changes

crates/git/src/hosting_provider.rs 🔗

@@ -2,7 +2,6 @@ use std::{ops::Range, sync::Arc};
 
 use anyhow::Result;
 use async_trait::async_trait;
-use collections::BTreeMap;
 use derive_more::{Deref, DerefMut};
 use gpui::{App, Global, SharedString};
 use http_client::HttpClient;
@@ -130,7 +129,8 @@ impl Global for GlobalGitHostingProviderRegistry {}
 
 #[derive(Default)]
 struct GitHostingProviderRegistryState {
-    providers: BTreeMap<String, Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    default_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    setting_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
 }
 
 #[derive(Default)]
@@ -140,6 +140,7 @@ pub struct GitHostingProviderRegistry {
 
 impl GitHostingProviderRegistry {
     /// Returns the global [`GitHostingProviderRegistry`].
+    #[track_caller]
     pub fn global(cx: &App) -> Arc<Self> {
         cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
     }
@@ -168,7 +169,8 @@ impl GitHostingProviderRegistry {
     pub fn new() -> Self {
         Self {
             state: RwLock::new(GitHostingProviderRegistryState {
-                providers: BTreeMap::default(),
+                setting_providers: Vec::default(),
+                default_providers: Vec::default(),
             }),
         }
     }
@@ -177,7 +179,22 @@ impl GitHostingProviderRegistry {
     pub fn list_hosting_providers(
         &self,
     ) -> Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> {
-        self.state.read().providers.values().cloned().collect()
+        let state = self.state.read();
+        state
+            .default_providers
+            .iter()
+            .cloned()
+            .chain(state.setting_providers.iter().cloned())
+            .collect()
+    }
+
+    pub fn set_setting_providers(
+        &self,
+        providers: impl IntoIterator<Item = Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    ) {
+        let mut state = self.state.write();
+        state.setting_providers.clear();
+        state.setting_providers.extend(providers);
     }
 
     /// Adds the provided [`GitHostingProvider`] to the registry.
@@ -185,10 +202,7 @@ impl GitHostingProviderRegistry {
         &self,
         provider: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
     ) {
-        self.state
-            .write()
-            .providers
-            .insert(provider.name(), provider);
+        self.state.write().default_providers.push(provider);
     }
 }
 

crates/git_hosting_providers/src/settings.rs 🔗

@@ -25,22 +25,34 @@ fn init_git_hosting_provider_settings(cx: &mut App) {
 }
 
 fn update_git_hosting_providers_from_settings(cx: &mut App) {
+    let settings_store = cx.global::<SettingsStore>();
     let settings = GitHostingProviderSettings::get_global(cx);
     let provider_registry = GitHostingProviderRegistry::global(cx);
 
-    for provider in settings.git_hosting_providers.iter() {
-        let Some(url) = Url::parse(&provider.base_url).log_err() else {
-            continue;
-        };
-
-        let provider = match provider.provider {
-            GitHostingProviderKind::Bitbucket => Arc::new(Bitbucket::new(&provider.name, url)) as _,
-            GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
-            GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
-        };
-
-        provider_registry.register_hosting_provider(provider);
-    }
+    let local_values: Vec<GitHostingProviderConfig> = settings_store
+        .get_all_locals::<GitHostingProviderSettings>()
+        .into_iter()
+        .flat_map(|(_, _, providers)| providers.git_hosting_providers.clone())
+        .collect();
+
+    let iter = settings
+        .git_hosting_providers
+        .clone()
+        .into_iter()
+        .chain(local_values)
+        .filter_map(|provider| {
+            let url = Url::parse(&provider.base_url).log_err()?;
+
+            Some(match provider.provider {
+                GitHostingProviderKind::Bitbucket => {
+                    Arc::new(Bitbucket::new(&provider.name, url)) as _
+                }
+                GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
+                GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
+            })
+        });
+
+    provider_registry.set_setting_providers(iter);
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -66,7 +78,7 @@ pub struct GitHostingProviderConfig {
     pub name: String,
 }
 
-#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
 pub struct GitHostingProviderSettings {
     /// The list of custom Git hosting providers.
     #[serde(default)]

crates/project/src/project_tests.rs 🔗

@@ -11,6 +11,7 @@ use buffer_diff::{
 use fs::FakeFs;
 use futures::{StreamExt, future};
 use git::{
+    GitHostingProviderRegistry,
     repository::RepoPath,
     status::{StatusCode, TrackedStatus},
 };
@@ -216,6 +217,71 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    cx.update(|cx| {
+        GitHostingProviderRegistry::default_global(cx);
+        git_hosting_providers::init(cx);
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    let str_path = path!("/dir");
+    let path = Path::new(str_path);
+
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            ".zed": {
+                "settings.json": r#"{
+                    "git_hosting_providers": [
+                        {
+                            "provider": "gitlab",
+                            "base_url": "https://google.com",
+                            "name": "foo"
+                        }
+                    ]
+                }"#
+            },
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let (_worktree, _) =
+        project.read_with(cx, |project, cx| project.find_worktree(path, cx).unwrap());
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let provider = GitHostingProviderRegistry::global(cx);
+        assert!(
+            provider
+                .list_hosting_providers()
+                .into_iter()
+                .any(|provider| provider.name() == "foo")
+        );
+    });
+
+    fs.atomic_write(
+        Path::new(path!("/dir/.zed/settings.json")).to_owned(),
+        "{}".into(),
+    )
+    .await
+    .unwrap();
+
+    cx.run_until_parked();
+
+    cx.update(|cx| {
+        let provider = GitHostingProviderRegistry::global(cx);
+        assert!(
+            !provider
+                .list_hosting_providers()
+                .into_iter()
+                .any(|provider| provider.name() == "foo")
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/settings/src/settings_store.rs 🔗

@@ -250,6 +250,7 @@ trait AnySettingValue: 'static + Send + Sync {
         cx: &mut App,
     ) -> Result<Box<dyn Any>>;
     fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any;
+    fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)>;
     fn set_global_value(&mut self, value: Box<dyn Any>);
     fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<Path>, value: Box<dyn Any>);
     fn json_schema(
@@ -376,6 +377,24 @@ impl SettingsStore {
             .expect("no default value for setting type")
     }
 
+    /// Get all values from project specific settings
+    pub fn get_all_locals<T: Settings>(&self) -> Vec<(WorktreeId, Arc<Path>, &T)> {
+        self.setting_values
+            .get(&TypeId::of::<T>())
+            .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
+            .all_local_values()
+            .into_iter()
+            .map(|(id, path, any)| {
+                (
+                    id,
+                    path,
+                    any.downcast_ref::<T>()
+                        .expect("wrong value type for setting"),
+                )
+            })
+            .collect()
+    }
+
     /// Override the global value for a setting.
     ///
     /// The given value will be overwritten if the user settings file changes.
@@ -1235,6 +1254,13 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
         (key, value)
     }
 
+    fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)> {
+        self.local_values
+            .iter()
+            .map(|(id, path, value)| (*id, path.clone(), value as _))
+            .collect()
+    }
+
     fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any {
         if let Some(SettingsLocation { worktree_id, path }) = path {
             for (settings_root_id, settings_path, value) in self.local_values.iter().rev() {