Allow filling co-authors in the git panel's commit input (#23329)

Kirill Bulatov created

https://github.com/user-attachments/assets/78db908e-cfe5-4803-b0dc-4f33bc457840


* starts to extract usernames out of `users/` GitHub API responses, and
pass those along with e-mails in the collab sessions as part of the
`User` data

* adjusts various prefill and seed test methods so that the new data can
be retrieved from GitHub properly

* if there's an active call, where guests have write permissions and
e-mails, allow to trigger `FillCoAuthors` action in the context of the
git panel, that will fill in `co-authored-by:` lines, using e-mail and
names (or GitHub handle names if name is absent)

* the action tries to not duplicate such entries, if any are present
already, and adds those below the rest of the commit input's text

Concerns:

* users with write permissions and no e-mails will be silently omitted
β€”Β adding odd entries that try to indicate this or raising pop-ups is
very intrusive (maybe, we can add `#`-prefixed comments?), logging seems
pointless

* it's not clear whether the data prefill will run properly on the
existing users β€” seems tolerable now, as it seems that we get e-mails
properly already, so we'll see GitHub handles instead of names in the
worst case. This can be prefilled better later.

* e-mails and names for a particular project may be not what the user
wants.
E.g. my `.gitconfig` has
```
[user]
    email = mail4score@gmail.com

# .....snip

[includeif "gitdir:**/work/zed/**/.git"]
    path = ~/.gitconfig.work
```

and that one has

```
[user]
    email = kirill@zed.dev
```

while my GitHub profile is configured so, that `mail4score@gmail.com` is
the public, commit e-mail.

So, when I'm a participant in a Zed session, wrong e-mail will be
picked.
The problem is, it's impossible for a host to get remote's collaborator
git metadata for a particular project, as that might not even exist on
disk for the client.

Seems that we might want to add some "project git URL <-> user name and
email" mapping in the settings(?).
The design of this is not very clear, so the PR concentrates on the
basics for now.

When https://github.com/zed-industries/zed/pull/23308 lands, most of the
issues can be solved by collaborators manually, before committing.

Release Notes:

- N/A

Change summary

crates/call/src/cross_platform/participant.rs                  | 16 
crates/call/src/macos/participant.rs                           | 16 
crates/channel/src/channel_store_tests.rs                      |  6 
crates/client/src/user.rs                                      |  4 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  1 
crates/collab/migrations/20250117100620_add_user_name.sql      |  1 
crates/collab/src/api.rs                                       |  2 
crates/collab/src/api/contributors.rs                          |  1 
crates/collab/src/auth.rs                                      |  1 
crates/collab/src/db/queries/channels.rs                       |  2 
crates/collab/src/db/queries/contributors.rs                   |  2 
crates/collab/src/db/queries/users.rs                          |  7 
crates/collab/src/db/tables/user.rs                            |  1 
crates/collab/src/db/tests.rs                                  |  1 
crates/collab/src/db/tests/buffer_tests.rs                     |  5 
crates/collab/src/db/tests/channel_tests.rs                    |  4 
crates/collab/src/db/tests/contributor_tests.rs                |  5 
crates/collab/src/db/tests/db_tests.rs                         | 14 
crates/collab/src/db/tests/feature_flag_tests.rs               |  2 
crates/collab/src/db/tests/user_tests.rs                       |  1 
crates/collab/src/rate_limiter.rs                              |  2 
crates/collab/src/rpc.rs                                       |  4 
crates/collab/src/seed.rs                                      | 21 
crates/collab/src/tests/channel_guest_tests.rs                 |  4 
crates/collab/src/tests/integration_tests.rs                   |  4 
crates/collab/src/tests/randomized_test_helpers.rs             |  1 
crates/collab/src/tests/test_server.rs                         |  1 
crates/collab/src/user_backfiller.rs                           |  2 
crates/collab_ui/src/chat_panel.rs                             |  6 
crates/git_ui/src/git_panel.rs                                 | 88 +++
crates/proto/proto/zed.proto                                   |  2 
crates/workspace/src/workspace.rs                              |  2 
32 files changed, 215 insertions(+), 14 deletions(-)

Detailed changes

crates/call/src/cross_platform/participant.rs πŸ”—

@@ -40,6 +40,15 @@ pub struct LocalParticipant {
     pub role: proto::ChannelRole,
 }
 
+impl LocalParticipant {
+    pub fn can_write(&self) -> bool {
+        matches!(
+            self.role,
+            proto::ChannelRole::Admin | proto::ChannelRole::Member
+        )
+    }
+}
+
 pub struct RemoteParticipant {
     pub user: Arc<User>,
     pub peer_id: proto::PeerId,
@@ -57,4 +66,11 @@ impl RemoteParticipant {
     pub fn has_video_tracks(&self) -> bool {
         !self.video_tracks.is_empty()
     }
+
+    pub fn can_write(&self) -> bool {
+        matches!(
+            self.role,
+            proto::ChannelRole::Admin | proto::ChannelRole::Member
+        )
+    }
 }

crates/call/src/macos/participant.rs πŸ”—

@@ -39,6 +39,15 @@ pub struct LocalParticipant {
     pub role: proto::ChannelRole,
 }
 
+impl LocalParticipant {
+    pub fn can_write(&self) -> bool {
+        matches!(
+            self.role,
+            proto::ChannelRole::Admin | proto::ChannelRole::Member
+        )
+    }
+}
+
 #[derive(Clone, Debug)]
 pub struct RemoteParticipant {
     pub user: Arc<User>,
@@ -57,4 +66,11 @@ impl RemoteParticipant {
     pub fn has_video_tracks(&self) -> bool {
         !self.video_tracks.is_empty()
     }
+
+    pub fn can_write(&self) -> bool {
+        matches!(
+            self.role,
+            proto::ChannelRole::Admin | proto::ChannelRole::Member
+        )
+    }
 }

crates/channel/src/channel_store_tests.rs πŸ”—

@@ -164,6 +164,8 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 id: 5,
                 github_login: "nathansobo".into(),
                 avatar_url: "http://avatar.com/nathansobo".into(),
+                name: None,
+                email: None,
             }],
         },
     );
@@ -216,6 +218,8 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 id: 6,
                 github_login: "maxbrunsfeld".into(),
                 avatar_url: "http://avatar.com/maxbrunsfeld".into(),
+                name: None,
+                email: None,
             }],
         },
     );
@@ -259,6 +263,8 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 id: 7,
                 github_login: "as-cii".into(),
                 avatar_url: "http://avatar.com/as-cii".into(),
+                name: None,
+                email: None,
             }],
         },
     );

crates/client/src/user.rs πŸ”—

@@ -43,6 +43,8 @@ pub struct User {
     pub id: UserId,
     pub github_login: String,
     pub avatar_uri: SharedUri,
+    pub name: Option<String>,
+    pub email: Option<String>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -798,6 +800,8 @@ impl User {
             id: message.id,
             github_login: message.github_login,
             avatar_uri: message.avatar_url.into(),
+            name: message.name,
+            email: message.email,
         })
     }
 }

crates/collab/src/api.rs πŸ”—

@@ -144,6 +144,7 @@ struct AuthenticatedUserParams {
     github_user_id: i32,
     github_login: String,
     github_email: Option<String>,
+    github_name: Option<String>,
     github_user_created_at: chrono::DateTime<chrono::Utc>,
 }
 
@@ -165,6 +166,7 @@ async fn get_authenticated_user(
             &params.github_login,
             params.github_user_id,
             params.github_email.as_deref(),
+            params.github_name.as_deref(),
             params.github_user_created_at,
             initial_channel_id,
         )

crates/collab/src/api/contributors.rs πŸ”—

@@ -115,6 +115,7 @@ async fn add_contributor(
             &params.github_login,
             params.github_user_id,
             params.github_email.as_deref(),
+            params.github_name.as_deref(),
             params.github_user_created_at,
             initial_channel_id,
         )

crates/collab/src/auth.rs πŸ”—

@@ -248,6 +248,7 @@ mod test {
         let user = db
             .create_user(
                 "example@example.com",
+                None,
                 false,
                 NewUserParams {
                     github_login: "example".into(),

crates/collab/src/db/queries/contributors.rs πŸ”—

@@ -65,6 +65,7 @@ impl Database {
         github_login: &str,
         github_user_id: i32,
         github_email: Option<&str>,
+        github_name: Option<&str>,
         github_user_created_at: DateTimeUtc,
         initial_channel_id: Option<ChannelId>,
     ) -> Result<()> {
@@ -74,6 +75,7 @@ impl Database {
                     github_login,
                     github_user_id,
                     github_email,
+                    github_name,
                     github_user_created_at.naive_utc(),
                     initial_channel_id,
                     &tx,

crates/collab/src/db/queries/users.rs πŸ”—

@@ -7,6 +7,7 @@ impl Database {
     pub async fn create_user(
         &self,
         email_address: &str,
+        name: Option<&str>,
         admin: bool,
         params: NewUserParams,
     ) -> Result<NewUserResult> {
@@ -14,6 +15,7 @@ impl Database {
             let tx = tx;
             let user = user::Entity::insert(user::ActiveModel {
                 email_address: ActiveValue::set(Some(email_address.into())),
+                name: ActiveValue::set(name.map(|s| s.into())),
                 github_login: ActiveValue::set(params.github_login.clone()),
                 github_user_id: ActiveValue::set(params.github_user_id),
                 admin: ActiveValue::set(admin),
@@ -101,6 +103,7 @@ impl Database {
         github_login: &str,
         github_user_id: i32,
         github_email: Option<&str>,
+        github_name: Option<&str>,
         github_user_created_at: DateTimeUtc,
         initial_channel_id: Option<ChannelId>,
     ) -> Result<User> {
@@ -109,6 +112,7 @@ impl Database {
                 github_login,
                 github_user_id,
                 github_email,
+                github_name,
                 github_user_created_at.naive_utc(),
                 initial_channel_id,
                 &tx,
@@ -118,11 +122,13 @@ impl Database {
         .await
     }
 
+    #[allow(clippy::too_many_arguments)]
     pub async fn get_or_create_user_by_github_account_tx(
         &self,
         github_login: &str,
         github_user_id: i32,
         github_email: Option<&str>,
+        github_name: Option<&str>,
         github_user_created_at: NaiveDateTime,
         initial_channel_id: Option<ChannelId>,
         tx: &DatabaseTransaction,
@@ -150,6 +156,7 @@ impl Database {
         } else {
             let user = user::Entity::insert(user::ActiveModel {
                 email_address: ActiveValue::set(github_email.map(|email| email.into())),
+                name: ActiveValue::set(github_name.map(|name| name.into())),
                 github_login: ActiveValue::set(github_login.into()),
                 github_user_id: ActiveValue::set(github_user_id),
                 github_user_created_at: ActiveValue::set(Some(github_user_created_at)),

crates/collab/src/db/tables/user.rs πŸ”—

@@ -13,6 +13,7 @@ pub struct Model {
     pub github_user_id: i32,
     pub github_user_created_at: Option<NaiveDateTime>,
     pub email_address: Option<String>,
+    pub name: Option<String>,
     pub admin: bool,
     pub invite_code: Option<String>,
     pub invite_count: i32,

crates/collab/src/db/tests.rs πŸ”—

@@ -177,6 +177,7 @@ static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
 async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
     db.create_user(
         email,
+        None,
         false,
         NewUserParams {
             github_login: email[0..email.find('@').unwrap()].to_string(),

crates/collab/src/db/tests/buffer_tests.rs πŸ”—

@@ -13,6 +13,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
     let a_id = db
         .create_user(
             "user_a@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user_a".into(),
@@ -25,6 +26,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
     let b_id = db
         .create_user(
             "user_b@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user_b".into(),
@@ -39,6 +41,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
     let c_id = db
         .create_user(
             "user_c@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user_c".into(),
@@ -176,6 +179,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
     let user_id = db
         .create_user(
             "user_a@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user_a".into(),
@@ -188,6 +192,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
     let observer_id = db
         .create_user(
             "user_b@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user_b".into(),

crates/collab/src/db/tests/channel_tests.rs πŸ”—

@@ -269,6 +269,7 @@ async fn test_channel_renames(db: &Arc<Database>) {
     let user_1 = db
         .create_user(
             "user1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user1".into(),
@@ -282,6 +283,7 @@ async fn test_channel_renames(db: &Arc<Database>) {
     let user_2 = db
         .create_user(
             "user2@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user2".into(),
@@ -318,6 +320,7 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
     let a_id = db
         .create_user(
             "user1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user1".into(),
@@ -372,6 +375,7 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
     let user_id = db
         .create_user(
             "user1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user1".into(),

crates/collab/src/db/tests/contributor_tests.rs πŸ”—

@@ -13,6 +13,7 @@ test_both_dbs!(
 async fn test_contributors(db: &Arc<Database>) {
     db.create_user(
         "user1@example.com",
+        None,
         false,
         NewUserParams {
             github_login: "user1".to_string(),
@@ -25,7 +26,7 @@ async fn test_contributors(db: &Arc<Database>) {
     assert_eq!(db.get_contributors().await.unwrap(), Vec::<String>::new());
 
     let user1_created_at = Utc::now();
-    db.add_contributor("user1", 1, None, user1_created_at, None)
+    db.add_contributor("user1", 1, None, None, user1_created_at, None)
         .await
         .unwrap();
     assert_eq!(
@@ -34,7 +35,7 @@ async fn test_contributors(db: &Arc<Database>) {
     );
 
     let user2_created_at = Utc::now();
-    db.add_contributor("user2", 2, None, user2_created_at, None)
+    db.add_contributor("user2", 2, None, None, user2_created_at, None)
         .await
         .unwrap();
     assert_eq!(

crates/collab/src/db/tests/db_tests.rs πŸ”—

@@ -17,6 +17,7 @@ async fn test_get_users(db: &Arc<Database>) {
         let user = db
             .create_user(
                 &format!("user{i}@example.com"),
+                None,
                 false,
                 NewUserParams {
                     github_login: format!("user{i}"),
@@ -79,6 +80,7 @@ test_both_dbs!(
 async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
     db.create_user(
         "user1@example.com",
+        None,
         false,
         NewUserParams {
             github_login: "login1".into(),
@@ -90,6 +92,7 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
     let user_id2 = db
         .create_user(
             "user2@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "login2".into(),
@@ -101,7 +104,7 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
         .user_id;
 
     let user = db
-        .get_or_create_user_by_github_account("the-new-login2", 102, None, Utc::now(), None)
+        .get_or_create_user_by_github_account("the-new-login2", 102, None, None, Utc::now(), None)
         .await
         .unwrap();
     assert_eq!(user.id, user_id2);
@@ -113,6 +116,7 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
             "login3",
             103,
             Some("user3@example.com"),
+            None,
             Utc::now(),
             None,
         )
@@ -133,6 +137,7 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
     let user_1 = db
         .create_user(
             "u1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "u1".into(),
@@ -145,6 +150,7 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
     let user_2 = db
         .create_user(
             "u2@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "u2".into(),
@@ -296,6 +302,7 @@ async fn test_add_contacts(db: &Arc<Database>) {
         user_ids.push(
             db.create_user(
                 &format!("user{i}@example.com"),
+                None,
                 false,
                 NewUserParams {
                     github_login: format!("user{i}"),
@@ -457,6 +464,7 @@ async fn test_metrics_id(db: &Arc<Database>) {
     } = db
         .create_user(
             "person1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "person1".into(),
@@ -472,6 +480,7 @@ async fn test_metrics_id(db: &Arc<Database>) {
     } = db
         .create_user(
             "person2@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "person2".into(),
@@ -500,6 +509,7 @@ async fn test_project_count(db: &Arc<Database>) {
     let user1 = db
         .create_user(
             "admin@example.com",
+            None,
             true,
             NewUserParams {
                 github_login: "admin".into(),
@@ -511,6 +521,7 @@ async fn test_project_count(db: &Arc<Database>) {
     let user2 = db
         .create_user(
             "user@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user".into(),
@@ -588,6 +599,7 @@ async fn test_fuzzy_search_users(cx: &mut gpui::TestAppContext) {
     {
         db.create_user(
             &format!("{github_login}@example.com"),
+            None,
             false,
             NewUserParams {
                 github_login: github_login.into(),

crates/collab/src/db/tests/feature_flag_tests.rs πŸ”—

@@ -15,6 +15,7 @@ async fn test_get_user_flags(db: &Arc<Database>) {
     let user_1 = db
         .create_user(
             "user1@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user1".to_string(),
@@ -28,6 +29,7 @@ async fn test_get_user_flags(db: &Arc<Database>) {
     let user_2 = db
         .create_user(
             "user2@example.com",
+            None,
             false,
             NewUserParams {
                 github_login: "user2".to_string(),

crates/collab/src/rate_limiter.rs πŸ”—

@@ -189,6 +189,7 @@ mod tests {
         let user_1 = db
             .create_user(
                 "user-1@zed.dev",
+                None,
                 false,
                 NewUserParams {
                     github_login: "user-1".into(),
@@ -201,6 +202,7 @@ mod tests {
         let user_2 = db
             .create_user(
                 "user-2@zed.dev",
+                None,
                 false,
                 NewUserParams {
                     github_login: "user-2".into(),

crates/collab/src/rpc.rs πŸ”—

@@ -2418,6 +2418,8 @@ async fn get_users(
             id: user.id.to_proto(),
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
+            email: user.email_address,
+            name: user.name,
         })
         .collect();
     response.send(proto::UsersResponse { users })?;
@@ -2449,6 +2451,8 @@ async fn fuzzy_search_users(
             id: user.id.to_proto(),
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
+            name: user.name,
+            email: user.email_address,
         })
         .collect();
     response.send(proto::UsersResponse { users })?;

crates/collab/src/seed.rs πŸ”—

@@ -16,6 +16,7 @@ struct GithubUser {
     id: i32,
     login: String,
     email: Option<String>,
+    name: Option<String>,
     created_at: DateTime<Utc>,
 }
 
@@ -75,6 +76,7 @@ pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result
         let user = db
             .create_user(
                 &user.email.unwrap_or(format!("{admin_login}@example.com")),
+                user.name.as_deref(),
                 true,
                 NewUserParams {
                     github_login: user.login,
@@ -129,6 +131,7 @@ pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result
                 &github_user.login,
                 github_user.id,
                 github_user.email.as_deref(),
+                github_user.name.as_deref(),
                 github_user.created_at,
                 None,
             )
@@ -152,14 +155,20 @@ fn load_admins(path: impl AsRef<Path>) -> anyhow::Result<SeedConfig> {
 }
 
 async fn fetch_github<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> T {
-    let response = client
-        .get(url)
+    let mut request_builder = client.get(url);
+    if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
+        request_builder =
+            request_builder.header("Authorization", format!("Bearer {}", github_token));
+    }
+    let response = request_builder
         .header("user-agent", "zed")
         .send()
         .await
         .unwrap_or_else(|error| panic!("failed to fetch '{url}': {error}"));
-    response
-        .json()
-        .await
-        .unwrap_or_else(|error| panic!("failed to deserialize github user from '{url}': {error}"))
+    let response_text = response.text().await.unwrap_or_else(|error| {
+        panic!("failed to fetch '{url}': {error}");
+    });
+    serde_json::from_str(&response_text).unwrap_or_else(|error| {
+        panic!("failed to deserialize github user from '{url}'. Error: '{error}', text: '{response_text}'");
+    })
 }

crates/collab/src/tests/channel_guest_tests.rs πŸ”—

@@ -174,7 +174,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
     server
         .app_state
         .db
-        .get_or_create_user_by_github_account("user_b", 100, None, Utc::now(), None)
+        .get_or_create_user_by_github_account("user_b", 100, None, None, Utc::now(), None)
         .await
         .unwrap();
 
@@ -278,7 +278,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
     server
         .app_state
         .db
-        .add_contributor("user_b", 100, None, Utc::now(), None)
+        .add_contributor("user_b", 100, None, None, Utc::now(), None)
         .await
         .unwrap();
 

crates/collab/src/tests/integration_tests.rs πŸ”—

@@ -1840,6 +1840,8 @@ async fn test_active_call_events(
                 id: client_a.user_id().unwrap(),
                 github_login: "user_a".to_string(),
                 avatar_uri: "avatar_a".into(),
+                name: None,
+                email: None,
             }),
             project_id: project_a_id,
             worktree_root_names: vec!["a".to_string()],
@@ -1858,6 +1860,8 @@ async fn test_active_call_events(
                 id: client_b.user_id().unwrap(),
                 github_login: "user_b".to_string(),
                 avatar_uri: "avatar_b".into(),
+                name: None,
+                email: None,
             }),
             project_id: project_b_id,
             worktree_root_names: vec!["b".to_string()]

crates/collab/src/user_backfiller.rs πŸ”—

@@ -86,6 +86,7 @@ impl UserBackfiller {
                             &user.github_login,
                             github_user.id,
                             user.email_address.as_deref(),
+                            user.name.as_deref(),
                             github_user.created_at,
                             initial_channel_id,
                         )
@@ -159,4 +160,5 @@ impl UserBackfiller {
 struct GithubUser {
     id: i32,
     created_at: DateTime<Utc>,
+    name: Option<String>,
 }

crates/collab_ui/src/chat_panel.rs πŸ”—

@@ -1194,6 +1194,8 @@ mod tests {
                 github_login: "fgh".into(),
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
+                name: None,
+                email: None,
             }),
             nonce: 5,
             mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
@@ -1248,6 +1250,8 @@ mod tests {
                 github_login: "fgh".into(),
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
+                name: None,
+                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),
@@ -1295,6 +1299,8 @@ mod tests {
                 github_login: "fgh".into(),
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
+                name: None,
+                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),

crates/git_ui/src/git_panel.rs πŸ”—

@@ -2,6 +2,7 @@ use crate::git_panel_settings::StatusStyle;
 use crate::{git_panel_settings::GitPanelSettings, git_status_icon};
 use anyhow::{Context as _, Result};
 use db::kvp::KEY_VALUE_STORE;
+use editor::actions::MoveToEnd;
 use editor::scroll::ScrollbarAutoHide;
 use editor::{Editor, EditorSettings, ShowScrollbar};
 use futures::channel::mpsc;
@@ -39,7 +40,8 @@ actions!(
         OpenMenu,
         OpenSelected,
         FocusEditor,
-        FocusChanges
+        FocusChanges,
+        FillCoAuthors,
     ]
 );
 
@@ -85,6 +87,7 @@ pub struct GitPanel {
     fs: Arc<dyn Fs>,
     hide_scrollbar_task: Option<Task<()>>,
     pending_serialization: Task<Option<()>>,
+    workspace: WeakView<Workspace>,
     project: Model<Project>,
     scroll_handle: UniformListScrollHandle,
     scrollbar_state: ScrollbarState,
@@ -154,8 +157,8 @@ impl GitPanel {
         let current_commit_message = git_state
             .as_ref()
             .map(|git_state| git_state.read(cx).commit_message.clone());
-
         let (err_sender, mut err_receiver) = mpsc::channel(1);
+        let workspace = cx.view().downgrade();
 
         let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
             let focus_handle = cx.focus_handle();
@@ -345,6 +348,7 @@ impl GitPanel {
                 project,
                 reveal_in_editor: Task::ready(()),
                 err_sender,
+                workspace,
             };
             git_panel.schedule_update();
             git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
@@ -732,6 +736,70 @@ impl GitPanel {
             .update(cx, |editor, cx| editor.set_text("", cx));
     }
 
+    fn fill_co_authors(&mut self, _: &FillCoAuthors, cx: &mut ViewContext<Self>) {
+        const CO_AUTHOR_PREFIX: &str = "co-authored-by: ";
+
+        let Some(room) = self
+            .workspace
+            .upgrade()
+            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
+        else {
+            return;
+        };
+
+        let mut existing_text = self.commit_editor.read(cx).text(cx);
+        existing_text.make_ascii_lowercase();
+        let mut ends_with_co_authors = false;
+        let existing_co_authors = existing_text
+            .lines()
+            .filter_map(|line| {
+                let line = line.trim();
+                if line.starts_with(CO_AUTHOR_PREFIX) {
+                    ends_with_co_authors = true;
+                    Some(line)
+                } else {
+                    ends_with_co_authors = false;
+                    None
+                }
+            })
+            .collect::<HashSet<_>>();
+
+        let new_co_authors = room
+            .read(cx)
+            .remote_participants()
+            .values()
+            .filter(|participant| participant.can_write())
+            .map(|participant| participant.user.clone())
+            .filter_map(|user| {
+                let email = user.email.as_deref()?;
+                let name = user.name.as_deref().unwrap_or(&user.github_login);
+                Some(format!("{CO_AUTHOR_PREFIX}{name} <{email}>"))
+            })
+            .filter(|co_author| {
+                !existing_co_authors.contains(co_author.to_ascii_lowercase().as_str())
+            })
+            .collect::<Vec<_>>();
+        if new_co_authors.is_empty() {
+            return;
+        }
+
+        self.commit_editor.update(cx, |editor, cx| {
+            let editor_end = editor.buffer().read(cx).read(cx).len();
+            let mut edit = String::new();
+            if !ends_with_co_authors {
+                edit.push('\n');
+            }
+            for co_author in new_co_authors {
+                edit.push('\n');
+                edit.push_str(&co_author);
+            }
+
+            editor.edit(Some((editor_end..editor_end, edit)), cx);
+            editor.move_to_end(&MoveToEnd, cx);
+            editor.focus(cx);
+        });
+    }
+
     fn no_entries(&self, cx: &mut ViewContext<Self>) -> bool {
         self.git_state(cx)
             .map_or(true, |git_state| git_state.read(cx).entry_count() == 0)
@@ -1332,6 +1400,19 @@ impl GitPanel {
 impl Render for GitPanel {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let project = self.project.read(cx);
+        let has_co_authors = self
+            .workspace
+            .upgrade()
+            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
+            .map(|room| {
+                let room = room.read(cx);
+                room.local_participant().can_write()
+                    && room
+                        .remote_participants()
+                        .values()
+                        .any(|remote_participant| remote_participant.can_write())
+            })
+            .unwrap_or(false);
 
         v_flex()
             .id("git_panel")
@@ -1363,6 +1444,9 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::focus_changes_list))
             .on_action(cx.listener(Self::focus_editor))
             .on_action(cx.listener(Self::toggle_staged_for_selected))
+            .when(has_co_authors, |git_panel| {
+                git_panel.on_action(cx.listener(Self::fill_co_authors))
+            })
             // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
             .on_hover(cx.listener(|this, hovered, cx| {
                 if *hovered {

crates/proto/proto/zed.proto πŸ”—

@@ -1750,6 +1750,8 @@ message User {
     uint64 id = 1;
     string github_login = 2;
     string avatar_url = 3;
+    optional string email = 4;
+    optional string name = 5;
 }
 
 message File {

crates/workspace/src/workspace.rs πŸ”—

@@ -4085,7 +4085,7 @@ impl Workspace {
         }
     }
 
-    fn active_call(&self) -> Option<&Model<ActiveCall>> {
+    pub fn active_call(&self) -> Option<&Model<ActiveCall>> {
         self.active_call.as_ref().map(|(call, _)| call)
     }